Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ecb9c4a
feat: chain onto previously installed editor factory
kylesnowschwartz May 1, 2026
73e9e8a
feat: add editor delegate fallback
lajarre May 14, 2026
0da2ee2
fix: keep pack budget green
lajarre May 14, 2026
1eacb53
feat: route insert input to delegate
lajarre May 14, 2026
a968241
feat: apply primitives to delegate
lajarre May 14, 2026
9faafea
feat: mirror delegate editor surface
lajarre May 14, 2026
278ea6b
fix: keep pack budget green
lajarre May 14, 2026
2f89863
feat: render delegate with mode label
lajarre May 14, 2026
4dce567
test: cover pi-vim wrappability
lajarre May 14, 2026
2298f61
test: add image attachments e2e
lajarre May 14, 2026
64d3e3d
docs: document editor delegation
lajarre May 15, 2026
dd93d52
test: cover direct image path insert
lajarre May 15, 2026
88c9eea
fix: harden editor delegation
lajarre May 16, 2026
943f7f3
fix: scope delegate key releases
lajarre May 16, 2026
d84b678
fix: preserve delegate release guards
lajarre May 16, 2026
333aa9c
fix: handle modal edge cases
lajarre May 17, 2026
672ad36
fix: harden delegate wrapping
lajarre May 18, 2026
f3ce107
fix: trust pi editor reset
lajarre May 18, 2026
444ed5d
fix: harden delegate routing
lajarre May 18, 2026
e321648
docs: document delegation model
lajarre May 18, 2026
d145555
fix: keep pack check green
lajarre May 18, 2026
12a67ae
fix: harden delegate edge cases
lajarre May 18, 2026
6b61b4e
fix: harden ex paste and handler sync
lajarre May 18, 2026
472c992
fix: sanitize ex control display
lajarre May 19, 2026
af14b15
fix: harden editor compatibility
lajarre May 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
doc/feature
node_modules/
140 changes: 139 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,143 @@ Paste text ending in `\n` is treated as line-wise.
- While a mirror is in flight, `p` / `P` use the shadow so immediate yank/delete → put stays ordered.
- Pi owns the terminal clipboard backends; on Wayland external state may lag while the shadow stays authoritative for immediate puts.

## compatible editor delegation and load order

pi-vim intentionally uses Pi extension load order. Install pi-vim after another editor extension when Vim modal behavior should win. If that preceding editor is compatible, pi-vim preserves it as the INSERT-mode delegate and backing primitive editor.

Delegation requires a Pi runtime that exposes `ctx.ui.getEditorComponent()`. Older runtimes without that API still get standalone pi-vim behavior, but pi-vim cannot preserve a previous editor there.

pi-vim owns NORMAL mode, EX mode, escape handling, operators, motions, registers, and the mode label. The preceding editor receives ordinary INSERT-mode input only when it exposes the required `CustomEditor` / TUI `Editor`-compatible surface and internals that pi-vim needs for text, cursor, rendering, and primitive edits.

If the preceding editor is incompatible or its factory fails, pi-vim replaces it with standalone pi-vim behavior and shows a warning for that mounted editor. [`@jordyvd/pi-image-attachments`](https://www.npmjs.com/package/@jordyvd/pi-image-attachments) is compatible when its editor is built on or preserves `CustomEditor` behavior.

pi-vim remains structurally wrappable by later decorators that explicitly preserve pi-vim's surface. Prefer installing pi-vim last unless a later decorator documents pi-vim support.

For maintainers and extension authors, pi-vim intentionally mixes passthrough, replacement, and chained delegation depending on the editor surface. The exact composition rules are below.

### delegation model

Glossary:

- **outer editor** — the `ModalEditor` instance installed by pi-vim via `setEditorComponent`.
- **insert delegate** — a compatible previous editor stored as `insertDelegate`.
- **primitive editor** — the object used for low-level text/cursor operations; this is `insertDelegate` when present, otherwise pi-vim itself.
- **app action** — an entry in `actionHandlers`, such as `app.interrupt`, `app.exit`, or another keybinding-backed command.
- **extension shortcut** — `onExtensionShortcut(data): boolean`; `true` means "handled, stop here" and `false` means "let the next layer try".
- **delegate sync** — `syncInsertDelegate()`, which wires the outer editor's runtime surface onto the insert delegate before delegated input or rendering.

pi-vim uses three delegation patterns:

| pattern | where it applies | behavior |
|---------|------------------|----------|
| delegate / passthrough | Ordinary INSERT input and primitive text edits | pi-vim calls the insert delegate / backing primitive editor |
| replace / block | NORMAL mode, EX mode, mode labels, same-key `actionHandlers` | pi-vim owns the behavior; the delegate does not also handle it |
| run and delegate | Callback fields such as `onSubmit`, `onChange`, `onEscape`, `onCtrlD`, `onPasteImage`; `onExtensionShortcut` when unhandled | pi-vim preserves the outer callback and the delegate callback where the API supports chaining |

Input flow:

```text
handleInput(data)
├─ EX mode: pi-vim handles the mini-command line
├─ NORMAL mode: pi-vim handles modal commands, operators, and motions
└─ INSERT mode:
├─ paste / key-release guards run in pi-vim
└─ ordinary editor input goes to insertDelegate.handleInput(data)
```

pi-vim is therefore not a transparent wrapper. It is a modal router with an INSERT-mode backing editor.

#### callback fields

Single callback fields are chained when both pi-vim and the delegate may need to observe the event. For `onSubmit` and `onChange`, delegate sync installs a wrapper equivalent to:

```text
outer callback
then delegate callback
```

`onEscape`, `onCtrlD`, and `onPasteImage` follow the same outer-then-delegate chaining shape.

`onExtensionShortcut` is different because it has a boolean "handled" contract:

```text
outer onExtensionShortcut(data)
├─ returns true -> stop; delegate is blocked
└─ returns false -> delegate may try the shortcut
```

For example:

```ts
editor.onExtensionShortcut = (data) => {
if (data === "\x1bt") {
toggleTodoPanel();
return true;
}

return false;
};
```

Returning `true` prevents the same key from also becoming text input or triggering another shortcut layer.

#### actionHandlers

`actionHandlers` are not chained. They are a map from one app action to one function:

```ts
Map<AppKeybinding, () => void>
```

For same-key actions, pi-vim uses **outer-wins** replacement. Chaining same-key app actions would double-run commands such as toggles, exits, or interrupts.

`syncActionHandlers()` reconciles the delegate map with the outer map:

1. Remove stale handlers that pi-vim previously copied, but only if the delegate still points at the exact copied function.
2. Copy current outer handlers into the delegate.
3. Preserve delegate-only handlers.
4. Preserve delegate replacements only after the outer editor stops owning that action.

If the outer editor still owns the same action key, the next sync overwrites the delegate's replacement again. Outer wins while it owns the action.

Why copy handlers into the delegate at all?

In INSERT mode, pi-vim delegates input to `insertDelegate.handleInput(data)`. At that point, the delegate's `CustomEditor.handleInput()` is the code checking app actions. If the runtime installed app handlers on the outer pi-vim editor, the delegate must see those handlers too, or delegated INSERT input would stop honoring app shortcuts.

Example:

```text
top-level editor = pi-vim
insert delegate = image attachments editor

runtime installs app.openCommandPalette on pi-vim

user presses ctrl-p in INSERT mode
-> pi-vim delegates to imageEditor.handleInput(ctrl-p)
-> imageEditor checks imageEditor.actionHandlers
```

Without `syncActionHandlers()`, `imageEditor.actionHandlers` would not contain `app.openCommandPalette`, so the shortcut would be lost while INSERT input is delegated.

The identity check in `syncActionHandlers()` prevents destructive cleanup:

```text
pi-vim copied action B into delegate
delegate later replaces B with its own handler
outer pi-vim removes B
syncActionHandlers sees delegate B is no longer the copied function
so it leaves delegate B alone
```

So the rule is:

```text
same action key: outer wins while outer owns it
delegate-only action: preserved
copied outer action removed later: cleaned up
delegate replacement after outer removal: preserved
```

---

## known differences from full Vim
Expand Down Expand Up @@ -348,10 +485,11 @@ Explicitly deferred:

## architecture notes

- `index.ts` — `ModalEditor` subclass of `CustomEditor`; all key handling.
- `index.ts` — installed `ModalEditor`; all key handling, with an optional compatible preceding editor as the INSERT-mode delegate/backing primitive editor.
- `motions.ts` — pure motion calculation helpers (`findWordMotionTarget`,
`findCharMotionTarget`); no side effects.
- `types.ts` — shared types and escape-sequence constants.
- `script/image-attachments-e2e.ts` — load-order E2E check for pi-vim with `@jordyvd/pi-image-attachments`.
- `test/` — Node test runner suite; no browser / full runtime required.

Run checks:
Expand Down
Loading
Loading