Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,12 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation,

```
src/
index.ts (148 lines) Plugin entry: intercept registration, action application
vim.ts (539 lines) Pure vim engine: state, handlers, command tables, types
index.ts (190 lines) Plugin entry: intercept registration, action application
vim.ts (549 lines) Pure vim engine: state, handlers, command tables, types
clipboard.ts (19 lines) writeClipboard() — cross-platform (pbcopy/xclip/xsel/wl-copy/clip.exe)
version.ts (46 lines) Version constant, GitHub update check (cached daily)
test/
vim.test.ts (847 lines) Characterization tests for all key handling branches
vim.test.ts (904 lines) Characterization tests for all key handling branches
```

**Data flow:**
Expand All @@ -81,6 +81,8 @@ Handlers in `vim.ts` are pure — they take state + key + event, mutate state, r
- `{ type: "clearSelection" }` — clears the textarea's selection via `editorView.resetSelection()`
- `{ type: "cursorTo", offset: number }` — sets `editor.cursorOffset` directly
- `{ type: "selectRange", start: number, end: number }` — calls `editor.setSelectionInclusive(start, end)`
- `{ type: "deleteRange", start: number, end: number }` — deletes text between inclusive offsets via `editBuffer.deleteRange()`. Saves a snapshot for single-step undo (see below).
- `{ type: "undo" }` — if an undo snapshot exists (from a `deleteRange`), restores the full buffer from it. Otherwise falls back to `dispatchCommand("input.undo")`.

### Adding a keybinding

Expand Down Expand Up @@ -112,6 +114,7 @@ To add a new motion that works with operators:
### Known limitations

- **`setTimeout` dispatch** — commands are deferred to avoid re-entrancy. Multi-command sequences (like `O` = home + newline + up) rely on ordered setTimeout execution, which works in practice but isn't guaranteed by spec. Many of these can now be replaced with direct widget manipulation (e.g., setting `cursorOffset`, calling `insertText`).
- **editBuffer undo granularity** — the host editor's undo system splits multi-line deletions into per-line entries. Operations that use `deleteRange` (like `dG`, `de`) work around this by saving a pre-operation snapshot and restoring from it on `u`. The snapshot is invalidated when any other buffer-modifying action runs (`cmd` or `insertText`).

## Development

Expand Down
2 changes: 1 addition & 1 deletion BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Ordered by priority within each category.

2. **Visual-line mode (`V`).** The widget's `getLineInfo()` and `setSelection()` make line-wise selection straightforward. Extend the existing visual mode with a `visual-line` variant.

3. **`dG`/`cG` — delete/change to buffer end.** `yG` already works. Delete and change variants need the same range calculation plus a content write.
3. ~~**`dG`/`cG` — delete/change to buffer end.**~~ Done.

4. **Proper `gg` as go-to-line.** Once `g` waits for a second keypress, `gg` goes to buffer start and `{n}G` goes to line n.

Expand Down
9 changes: 7 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version

## [Unreleased]

### Added

- `dG` and `cG` — delete/change from cursor to end of buffer. `yG` already worked, now all three operators work with `G`.

### Changed

- Cursor shape uses the editor widget's `cursorStyle` property instead of writing DECSCUSR escape sequences to stdout. Works in terminals without DECSCUSR support (e.g. macOS Terminal.app).
- Cursor shape is set via the editor widget's `cursorStyle` property instead of writing DECSCUSR escape sequences to stdout. Fixes terminals that don't support DECSCUSR (e.g. macOS Terminal.app).

### Fixed

- `yy` reads the cursor position directly from the editor widget instead of a line counter. The old counter drifted on clicks, arrow keys, and word motions, so `yy` would yank the wrong line.
- `dG`, `cG`, `de`, `ce` undo in a single `u` press. The host editor's undo system splits multi-line deletions into per-line entries, so these operations now go through `editBuffer.deleteRange()` with a pre-operation snapshot that `u` restores from directly.
- `yy` reads the cursor position from the editor widget instead of a line counter. The counter drifted on clicks, arrow keys, and word motions, causing `yy` to yank the wrong line.
- `e` moves to end of word instead of behaving like `w`. `de`, `ce`, and `ye` operate on the correct range too.
- `g` waits for a second keypress instead of jumping to buffer start on its own. `gg` now works as a proper two-key command.

Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ When the input is empty, `j`/`k` scroll through prompt history instead of moving
| `dl` `cl` `yl` | Character right |
| `dj` `cj` `yj` | Current + line below |
| `dk` `ck` `yk` | Current + line above |
| `yG` | To end of buffer (yank only) |
| `dG` `cG` `yG` | To end of buffer |

Counts work on both operator and motion: `2dd` deletes 2 lines, `d3w` deletes 3 words.

Expand Down Expand Up @@ -156,7 +156,6 @@ All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b

- `V`, `Ctrl+v` - only character-wise visual mode (`v`) is supported, no line-wise or block
- `ciw`, `di"`, etc. (text objects) - not yet implemented
- `dG`, `cG` - delete/change to buffer end not yet implemented (`yG` works)
- No persistent mode indicator - the toast fades after about a second. Cursor shape is the persistent signal, but a status bar indicator would need the host's SolidJS runtime, which external plugins can't access.

Configurable key bindings are next once the core vim coverage stabilizes.
Expand Down
42 changes: 42 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ const plugin: TuiPluginModule = {
const startMode = options?.startMode === "normal" ? "normal" : "insert";
state.mode = startMode;

// Snapshot for single-step undo of deleteRange operations.
// The host editor's undo system splits multi-line deletions into
// multiple entries, so we save/restore the buffer ourselves.
let undoSnapshot: { text: string; cursor: number } | null = null;

const prompt = {
getLine: (n: number) => getInputText().split("\n")[n] ?? "",
getLineCount: () => getInputText().split("\n").length,
Expand All @@ -26,6 +31,11 @@ const plugin: TuiPluginModule = {

function applyActions(actions: Action[]) {
for (const action of actions) {
// Any buffer-modifying action (other than our own deleteRange/undo)
// invalidates the undo snapshot.
if (action.type === "cmd" || action.type === "insertText") {
undoSnapshot = null;
}
switch (action.type) {
case "cmd":
setTimeout(() => api.keymap.dispatchCommand(action.cmd), 0);
Expand Down Expand Up @@ -61,6 +71,32 @@ const plugin: TuiPluginModule = {
case "clearSelection":
api.renderer?.currentFocusedEditor?.editorView?.resetSelection?.();
break;
case "deleteRange": {
const editor = api.renderer?.currentFocusedEditor;
const eb = editor?.editBuffer;
if (eb?.deleteRange) {
undoSnapshot = { text: editor.plainText ?? "", cursor: editor.cursorOffset ?? 0 };
const text = editor.plainText ?? "";
const [sl, sc] = offsetToLineCol(text, action.start);
const [el, ec] = offsetToLineCol(text, action.end + 1);
eb.deleteRange(sl, sc, el, ec);
}
break;
}
case "undo": {
if (undoSnapshot) {
const editor = api.renderer?.currentFocusedEditor;
const eb = editor?.editBuffer;
if (eb?.setText && editor) {
eb.setText(undoSnapshot.text);
editor.cursorOffset = undoSnapshot.cursor;
}
undoSnapshot = null;
} else {
setTimeout(() => api.keymap.dispatchCommand("input.undo"), 0);
}
break;
}
case "cursorTo": {
const editor = api.renderer?.currentFocusedEditor;
if (editor) editor.cursorOffset = action.offset;
Expand Down Expand Up @@ -145,4 +181,10 @@ const plugin: TuiPluginModule = {
},
};

function offsetToLineCol(text: string, offset: number): [number, number] {
const before = text.substring(0, offset);
const lines = before.split("\n");
return [lines.length - 1, lines[lines.length - 1].length];
}

export default plugin;
16 changes: 13 additions & 3 deletions src/vim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export type Action =
| { type: "insertText"; text: string }
| { type: "yankSelection" }
| { type: "clearSelection" }
| { type: "deleteRange"; start: number; end: number }
| { type: "undo" }
| { type: "cursorTo"; offset: number }
| { type: "selectRange"; start: number; end: number };

Expand Down Expand Up @@ -301,8 +303,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
actions.push({ type: "yank", text });
resetPending(state);
} else {
actions.push({ type: "selectRange", start: offset, end: target });
actions.push({ type: "cmd", cmd: "input.backspace" });
actions.push({ type: "deleteRange", start: offset, end: target });
if (state.pendingOp === "c") enterInsert(state, actions);
else resetPending(state);
}
Expand Down Expand Up @@ -336,6 +337,15 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
else resetPending(state);
return { consume: true, actions };
}
if (key === "G") {
consumeCount(state);
const offset = prompt.getCursorOffset();
const text = prompt.getPlainText();
actions.push({ type: "deleteRange", start: offset, end: Math.max(0, text.length - 1) });
if (state.pendingOp === "c") enterInsert(state, actions);
else resetPending(state);
return { consume: true, actions };
}

const deleteCmd = DELETE_MOTION[key];
if (deleteCmd) {
Expand Down Expand Up @@ -386,7 +396,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
}

if (key === "u") {
actions.push({ type: "cmd", cmd: "input.undo" });
actions.push({ type: "undo" });
resetPending(state);
return { consume: true, actions };
}
Expand Down
Loading
Loading