From 285d0c663188eea464bea074c4c2fefc1a02246e Mon Sep 17 00:00:00 2001 From: Joseph Tams Date: Sat, 25 Jul 2026 11:01:47 -0700 Subject: [PATCH 1/4] feat: add first-non-blank motions for ^, _, and I --- CHANGELOG.md | 8 ++ README.md | 10 ++- src/index.ts | 13 +++- src/vim/normal.ts | 69 +++++++++++++++++- src/vim/tables.ts | 3 - src/vim/text.ts | 14 ++++ src/vim/util.ts | 1 + src/vim/visual.ts | 10 ++- test/vim/normal.test.ts | 158 ++++++++++++++++++++++++++++++++++++++++ test/vim/text.test.ts | 23 ++++++ test/vim/util.test.ts | 4 + test/vim/visual.test.ts | 37 ++++++++++ 12 files changed, 341 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cc206f..4d2219f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ## [Unreleased] +### Added + +- Added `_` and `I` support with Vim's first-non-blank behavior. + +### Fixed + +- `^` now moves to the first non-blank character instead of the start of the line. + ## [0.18.1] — 2026-09-11 ### Changed diff --git a/README.md b/README.md index 096e68c..eb5939b 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,9 @@ The plugin checks GitHub for new versions once per day on startup. No other netw |-----|--------| | `h` `j` `k` `l` | Left, down, up, right | | `w` `b` `e` | Word forward, backward, end of word | -| `0` `^` | Line start | +| `0` | Line start | +| `^` | First non-blank character | +| `_` | First non-blank character on the count-th line down (`4_` = `3j^`) | | `$` | Line end | | `gg` | Buffer start | | `G` | Buffer end | @@ -124,7 +126,8 @@ When the input is empty, `j`/`k` scroll through prompt history instead of moving | `de` `ce` `ye` | To end of word | | `d$` `c$` `y$` | To end of line | | `d0` `c0` `y0` | To start of line | -| `d^` `c^` `y^` | To start of line | +| `d^` `c^` `y^` | To first non-blank character | +| `d_` `c_` `y_` | Operate on whole line(s), like `dd` `cc` `yy` | | `dh` `ch` `yh` | Character left | | `dl` `cl` `yl` | Character right | | `dj` `cj` `yj` | Current + line below | @@ -155,6 +158,7 @@ So `diw`, `ci"`, `da(`, `yi{`, `vib`, `ciq` all work. `iw` covers the run under |-----|--------| | `i` | Insert at cursor | | `a` | Insert after cursor | +| `I` | Insert at first non-blank character | | `A` | Insert at end of line | | `o` | Open line below | | `O` | Open line above | @@ -173,7 +177,7 @@ Press `v` in normal mode to enter character-wise visual mode. Press `V` to selec | `V` | Select current line | | `Escape` `v` | Exit visual mode | -All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b` `e` `0` `$` `G`, with counts. +All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b` `e` `0` `^` `_` `$` `G`, with counts. ### Other diff --git a/src/index.ts b/src/index.ts index 1e56199..9c0d879 100644 --- a/src/index.ts +++ b/src/index.ts @@ -217,7 +217,18 @@ const plugin: TuiPluginModule = { } case "cursorTo": { const editor = api.renderer?.currentFocusedEditor; - if (editor) editor.cursorOffset = action.offset; + if (editor?.moveCursorRight) { + const text = editor.plainText ?? ""; + const target = Math.min(Math.max(action.offset, 0), text.length); + const lineStart = text.lastIndexOf("\n", target - 1) + 1; + + editor.cursorOffset = lineStart; + for (let i = lineStart; i < target; i++) editor.moveCursorRight(); + editor.getLayoutNode?.().markDirty?.(); + api.renderer?.requestRender?.(); + } else if (editor) { + editor.cursorOffset = action.offset; + } break; } case "selectRange": { diff --git a/src/vim/normal.ts b/src/vim/normal.ts index 8d21c48..29e6536 100644 --- a/src/vim/normal.ts +++ b/src/vim/normal.ts @@ -1,6 +1,6 @@ import { consumeCount, enterInsert, resetPending } from "./state"; import { DELETE_MOTION, MOTIONS, SELECT_MOTIONS } from "./tables"; -import { currentLineRange, endOfWord } from "./text"; +import { currentLineRange, endOfWord, firstNonBlankOnLine } from "./text"; import { resolveTextObject } from "./textobject"; import type { Action, HandlerResult, KeyEvent, Operator, PromptAccess, VimState } from "./types"; import { PASS, pushN } from "./util"; @@ -187,6 +187,52 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom return finishUndoableChange(actions); } + // `_` is linewise when used with an operator, like `dd`/`cc`/`yy`. + if (state.pending.kind === "operator" && key === "_") { + const op = state.pending.op; + const n = consumeCount(state); + + if (op === "y") { + const cursorLine = prompt.getCursorLine(); + const lines: string[] = []; + for (let i = 0; i < n; i++) lines.push(prompt.getLine(cursorLine + i)); + const text = `${lines.join("\n")}\n`; + state.yankRegister = text; + actions.push({ type: "yank", text }); + resetPending(state); + return { consume: true, actions }; + } + + pushN(actions, "input.delete.line", n); + if (op === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); + } + + // `^` is an exclusive characterwise motion to the first non-blank character. + if (state.pending.kind === "operator" && key === "^") { + const op = state.pending.op; + consumeCount(state); + const text = prompt.getPlainText(); + const offset = prompt.getCursorOffset(); + const target = firstNonBlankOnLine(text, offset); + const start = Math.min(offset, target); + const end = Math.max(offset, target) - 1; + + if (op === "y") { + const yanked = text.slice(start, end + 1); + state.yankRegister = yanked; + actions.push({ type: "yank", text: yanked }); + resetPending(state); + return { consume: true, actions }; + } + + if (start <= end) actions.push({ type: "deleteRange", start, end }); + if (op === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); + } + // Pending operator + e (end-of-word needs special handling) if (state.pending.kind === "operator" && key === "e") { const op = state.pending.op; @@ -252,6 +298,15 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom return { consume: true, actions }; } + if (key === "^" || key === "_") { + const n = consumeCount(state); + actions.push({ + type: "cursorTo", + offset: firstNonBlankOnLine(prompt.getPlainText(), prompt.getCursorOffset(), key === "_" ? n - 1 : 0), + }); + return { consume: true, actions }; + } + // Standalone motions if (key in MOTIONS) { const n = consumeCount(state); @@ -322,6 +377,12 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom return { consume: true, actions }; } + if (key === "I") { + moveToFirstNonBlank(actions, prompt.getLine(prompt.getCursorLine())); + enterInsert(state, actions); + return { consume: true, actions }; + } + if (key === "A") { actions.push({ type: "cmd", cmd: "input.line.end" }); enterInsert(state, actions); @@ -376,3 +437,9 @@ function applyOperatorRange( function isInputEmpty(prompt: PromptAccess): boolean { return prompt.getLineCount() === 1 && prompt.getLine(0) === ""; } + +function moveToFirstNonBlank(actions: Action[], line: string) { + actions.push({ type: "cmd", cmd: "input.line.home" }); + const firstNonBlank = line.search(/[^ \t\r]/); + if (firstNonBlank !== -1) pushN(actions, "input.move.right", firstNonBlank); +} diff --git a/src/vim/tables.ts b/src/vim/tables.ts index 9c01aa7..b316e1b 100644 --- a/src/vim/tables.ts +++ b/src/vim/tables.ts @@ -6,7 +6,6 @@ export const MOTIONS: Record = { w: "input.word.forward", b: "input.word.backward", "0": "input.line.home", - "^": "input.line.home", $: "input.line.end", G: "input.buffer.end", }; @@ -19,7 +18,6 @@ export const SELECT_MOTIONS: Record = { w: "input.select.word.forward", b: "input.select.word.backward", "0": "input.select.line.home", - "^": "input.select.line.home", $: "input.select.line.end", G: "input.select.buffer.end", }; @@ -29,7 +27,6 @@ export const DELETE_MOTION: Record = { b: "input.delete.word.backward", $: "input.delete.to.line.end", "0": "input.delete.to.line.start", - "^": "input.delete.to.line.start", h: "input.backspace", l: "input.delete", }; diff --git a/src/vim/text.ts b/src/vim/text.ts index 56ee4eb..191e243 100644 --- a/src/vim/text.ts +++ b/src/vim/text.ts @@ -43,6 +43,20 @@ export function currentLineRange(text: string, offset: number): { start: number; return { start, end: newline === -1 ? text.length - 1 : newline }; } +export function firstNonBlankOnLine(text: string, offset: number, linesDown = 0): number { + const safeOffset = Math.min(Math.max(offset, 0), text.length); + let start = text.lastIndexOf("\n", safeOffset - 1) + 1; + for (let i = 0; i < linesDown; i++) { + const newline = text.indexOf("\n", start); + if (newline === -1) break; + start = newline + 1; + } + const lineEnd = text.indexOf("\n", start); + const line = text.slice(start, lineEnd === -1 ? text.length : lineEnd); + const firstNonBlank = line.search(/[^ \t\r]/); + return start + Math.max(0, firstNonBlank); +} + // Inclusive [start, end] offsets for the `iw`/`aw` text object under the // cursor. A "word" is a run of one charKind (word/punct/space); runs never // cross a newline. `around` extends past a word/punct run to its trailing diff --git a/src/vim/util.ts b/src/vim/util.ts index e24a561..1e1417a 100644 --- a/src/vim/util.ts +++ b/src/vim/util.ts @@ -8,6 +8,7 @@ export function translateKey(ev: KeyEvent): string { if (/[a-z]/.test(ev.name)) key = ev.name.toUpperCase(); else if (ev.name === "4") key = "$"; else if (ev.name === "6") key = "^"; + else if (ev.name === "-") key = "_"; else if (ev.name === "[") key = "{"; else if (ev.name === "]") key = "}"; } diff --git a/src/vim/visual.ts b/src/vim/visual.ts index 4ab479d..6931b2a 100644 --- a/src/vim/visual.ts +++ b/src/vim/visual.ts @@ -1,6 +1,6 @@ import { consumeCount, enterInsert, enterNormal, exitVisual } from "./state"; import { SELECT_MOTIONS } from "./tables"; -import { endOfWord } from "./text"; +import { endOfWord, firstNonBlankOnLine } from "./text"; import { resolveTextObject } from "./textobject"; import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; import { PASS, pushN } from "./util"; @@ -81,6 +81,14 @@ export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prom return { consume: true, actions }; } + if (key === "^" || key === "_") { + const n = consumeCount(state); + const target = firstNonBlankOnLine(prompt.getPlainText(), prompt.getCursorOffset(), key === "_" ? n - 1 : 0); + actions.push({ type: "selectRange", start: state.visualAnchor ?? 0, end: target }); + actions.push({ type: "cursorTo", offset: target }); + return { consume: true, actions }; + } + // Motions extend selection if (key in SELECT_MOTIONS) { pushN(actions, SELECT_MOTIONS[key], consumeCount(state)); diff --git a/test/vim/normal.test.ts b/test/vim/normal.test.ts index d32c74b..8ac8987 100644 --- a/test/vim/normal.test.ts +++ b/test/vim/normal.test.ts @@ -49,6 +49,64 @@ describe("handleNormalKey — motions", () => { expect(cmds(r.actions)).toEqual(["input.line.home"]); }); + it("^ moves to the first non-blank character", () => { + const prompt: PromptAccess = { + ...mockPrompt, + getLine: () => " \t hello", + getPlainText: () => " \t hello", + }; + const r = handleNormalKey(state, "^", ev("6", { shift: true }), prompt); + expect(cursorTos(r.actions)).toEqual([4]); + }); + + it("_ moves to the first non-blank character", () => { + const prompt: PromptAccess = { + ...mockPrompt, + getLine: () => " \t hello", + getPlainText: () => " \t hello", + }; + const r = handleNormalKey(state, "_", ev("-", { shift: true }), prompt); + expect(cursorTos(r.actions)).toEqual([4]); + }); + + it("^ and _ move past indentation tabs", () => { + const prompt: PromptAccess = { + ...mockPrompt, + getLine: () => " \thello", + getPlainText: () => " \thello", + }; + + expect(cursorTos(handleNormalKey(state, "^", ev("6", { shift: true }), prompt).actions)).toEqual([3]); + expect(cursorTos(handleNormalKey(state, "_", ev("-", { shift: true }), prompt).actions)).toEqual([3]); + }); + + it("4_ moves to the first non-blank character three lines down", () => { + const text = "first\n second\n\t third\n fourth"; + const prompt: PromptAccess = { + ...mockPrompt, + getPlainText: () => text, + getLineCount: () => 4, + }; + handleNormalKey(state, "4", ev("4"), prompt); + const r = handleNormalKey(state, "_", ev("-", { shift: true }), prompt); + expect(cursorTos(r.actions)).toEqual([text.indexOf("fourth")]); + }); + + it("^ moves to the first non-blank character on a subsequent line", () => { + const text = "first line\n\t second line"; + const prompt: PromptAccess = { + ...mockPrompt, + getCursorLine: () => 1, + getCursorOffset: () => text.indexOf("second") + 3, + getPlainText: () => text, + getLine: (n) => (n === 0 ? "first line" : "\t second line"), + getLineCount: () => 2, + }; + + const r = handleNormalKey(state, "^", ev("6", { shift: true }), prompt); + expect(cursorTos(r.actions)).toEqual([text.indexOf("second")]); + }); + it("0 after count > 0 accumulates as digit", () => { handleNormalKey(state, "1", ev("1"), mockPrompt); handleNormalKey(state, "0", ev("0"), mockPrompt); @@ -348,6 +406,77 @@ describe("handleNormalKey — operators", () => { expect(cmds(r.actions)).toEqual(["input.delete.to.line.start"]); }); + it("d^ deletes to the first non-blank character, preserving indentation", () => { + const text = " test1 test2 test3"; + const prompt: PromptAccess = { + ...mockPrompt, + getCursorOffset: () => 17, + getPlainText: () => text, + }; + handleNormalKey(state, "d", ev("d"), prompt); + const r = handleNormalKey(state, "^", ev("6", { shift: true }), prompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 5, end: 16 }]); + }); + + it("d_ deletes the current line", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + const r = handleNormalKey(state, "_", ev("_"), mockPrompt); + + expect(cmds(r.actions)).toEqual(["input.delete.line"]); + expect(saveUndoSnapshots(r.actions)).toHaveLength(1); + }); + + it("d4_ deletes four lines", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "4", ev("4"), mockPrompt); + const r = handleNormalKey(state, "_", ev("_"), mockPrompt); + + expect(cmds(r.actions)).toEqual([ + "input.delete.line", + "input.delete.line", + "input.delete.line", + "input.delete.line", + ]); + }); + + it("c_ changes the current line", () => { + handleNormalKey(state, "c", ev("c"), mockPrompt); + const r = handleNormalKey(state, "_", ev("_"), mockPrompt); + + expect(cmds(r.actions)).toEqual(["input.delete.line"]); + expect(state.mode).toBe("insert"); + }); + + it("y_ yanks the current line", () => { + handleNormalKey(state, "y", ev("y"), mockPrompt); + const r = handleNormalKey(state, "_", ev("_"), mockPrompt); + + expect(state.yankRegister).toBe("hello world\n"); + expect(r.actions).toContainEqual({ type: "yank", text: "hello world\n" }); + }); + + it("y2_ yanks two complete lines", () => { + handleNormalKey(state, "y", ev("y"), mockPrompt); + handleNormalKey(state, "2", ev("2"), mockPrompt); + const r = handleNormalKey(state, "_", ev("_"), mockPrompt); + + expect(state.yankRegister).toBe("hello world\nsecond line\n"); + expect(r.actions).toContainEqual({ type: "yank", text: "hello world\nsecond line\n" }); + }); + + it("c^ deletes to the first non-blank character and enters insert", () => { + const text = " test1 test2 test3"; + const prompt: PromptAccess = { + ...mockPrompt, + getCursorOffset: () => 17, + getPlainText: () => text, + }; + handleNormalKey(state, "c", ev("c"), prompt); + const r = handleNormalKey(state, "^", ev("6", { shift: true }), prompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 5, end: 16 }]); + expect(state.mode).toBe("insert"); + }); + it("dj dispatches input.delete.line twice", () => { handleNormalKey(state, "d", ev("d"), mockPrompt); const r = handleNormalKey(state, "j", ev("j"), mockPrompt); @@ -396,6 +525,19 @@ describe("handleNormalKey — operators", () => { expect(r.actions.some((a) => a.type === "yankSelection")).toBe(true); }); + it("y^ yanks to the first non-blank character, excluding indentation", () => { + const text = " test1 test2 test3"; + const prompt: PromptAccess = { + ...mockPrompt, + getCursorOffset: () => 17, + getPlainText: () => text, + }; + handleNormalKey(state, "y", ev("y"), prompt); + const r = handleNormalKey(state, "^", ev("6", { shift: true }), prompt); + expect(state.yankRegister).toBe("test1 test2 "); + expect(r.actions).toContainEqual({ type: "yank", text: "test1 test2 " }); + }); + it("y3w selects 3 words and yanks", () => { handleNormalKey(state, "y", ev("y"), mockPrompt); handleNormalKey(state, "3", ev("3"), mockPrompt); @@ -629,6 +771,22 @@ describe("handleNormalKey — insert entries", () => { expect(state.mode).toBe("insert"); }); + it("I moves to the first non-blank character and enters insert", () => { + const prompt: PromptAccess = { + ...mockPrompt, + getLine: () => " \t hello", + }; + const r = handleNormalKey(state, "I", ev("i", { shift: true }), prompt); + expect(cmds(r.actions)).toEqual([ + "input.line.home", + "input.move.right", + "input.move.right", + "input.move.right", + "input.move.right", + ]); + expect(state.mode).toBe("insert"); + }); + it("A dispatches input.line.end, enters insert", () => { const r = handleNormalKey(state, "A", ev("a", { shift: true }), mockPrompt); expect(cmds(r.actions)).toContain("input.line.end"); diff --git a/test/vim/text.test.ts b/test/vim/text.test.ts index 72b0fa9..1a017e7 100644 --- a/test/vim/text.test.ts +++ b/test/vim/text.test.ts @@ -6,6 +6,7 @@ import { bracketRange, charKind, currentLineRange, + firstNonBlankOnLine, isWhitespace, quoteRange, wordRange, @@ -144,6 +145,28 @@ describe("currentLineRange", () => { }); }); +// ── firstNonBlankOnLine ───────────────────────────────────── + +describe("firstNonBlankOnLine", () => { + it("skips spaces and tabs", () => { + expect(firstNonBlankOnLine(" \t hello", 0)).toBe(4); + }); + + it("uses the cursor's current line", () => { + const text = "first line\n\t second line"; + expect(firstNonBlankOnLine(text, text.indexOf("second") + 3)).toBe(text.indexOf("second")); + }); + + it("moves down the requested number of lines", () => { + const text = "first\n second\n\t third\n fourth"; + expect(firstNonBlankOnLine(text, 0, 3)).toBe(text.indexOf("fourth")); + }); + + it("returns the line start when the line is blank", () => { + expect(firstNonBlankOnLine(" \t\r\nnext", 0)).toBe(0); + }); +}); + // ── wordRange (inner) ────────────────────────────────────── describe("wordRange (inner)", () => { diff --git a/test/vim/util.test.ts b/test/vim/util.test.ts index 2352e7a..ff4d341 100644 --- a/test/vim/util.test.ts +++ b/test/vim/util.test.ts @@ -21,6 +21,10 @@ describe("translateKey", () => { expect(translateKey(ev("6", { shift: true }))).toBe("^"); }); + it("shift+- → _", () => { + expect(translateKey(ev("-", { shift: true }))).toBe("_"); + }); + it("shift+[ → {", () => { expect(translateKey(ev("[", { shift: true }))).toBe("{"); }); diff --git a/test/vim/visual.test.ts b/test/vim/visual.test.ts index 1dd820d..45a1327 100644 --- a/test/vim/visual.test.ts +++ b/test/vim/visual.test.ts @@ -47,6 +47,43 @@ describe("handleVisualKey — motions", () => { expect(cmds(r.actions)).toEqual(["input.select.line.end"]); }); + it("^ extends to the first non-blank character", () => { + const prompt: PromptAccess = { + ...mockPrompt, + getLine: () => "\t hello", + getPlainText: () => "\t hello", + }; + const r = handleVisualKey(state, "^", ev("6", { shift: true }), prompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 3 }]); + expect(cursorTos(r.actions)).toEqual([3]); + }); + + it("_ extends to the first non-blank character", () => { + const prompt: PromptAccess = { + ...mockPrompt, + getLine: () => "\t hello", + getPlainText: () => "\t hello", + }; + const r = handleVisualKey(state, "_", ev("_"), prompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 3 }]); + expect(cursorTos(r.actions)).toEqual([3]); + }); + + it("4_ extends to the first non-blank character three lines down", () => { + const text = "first\n second\n\t third\n fourth"; + const prompt: PromptAccess = { + ...mockPrompt, + getCursorOffset: () => 2, + getPlainText: () => text, + getLineCount: () => 4, + }; + state.visualAnchor = 2; + handleVisualKey(state, "4", ev("4"), prompt); + const r = handleVisualKey(state, "_", ev("_"), prompt); + expect(selectRanges(r.actions)).toEqual([{ start: 2, end: text.indexOf("fourth") }]); + expect(cursorTos(r.actions)).toEqual([text.indexOf("fourth")]); + }); + it("3l dispatches input.select.right 3 times", () => { handleVisualKey(state, "3", ev("3")); const r = handleVisualKey(state, "l", ev("l")); From 4c84cb2590fb90845e1550a5767a32703a8ff562 Mon Sep 17 00:00:00 2001 From: Joseph Tams Date: Sun, 26 Jul 2026 14:59:05 -0700 Subject: [PATCH 2/4] fix: preserve cursor position after I escape --- CHANGELOG.md | 1 + src/index.ts | 11 +++++++++++ src/vim/insert.ts | 2 +- src/vim/types.ts | 1 + test/support.ts | 4 ++++ test/vim/insert.test.ts | 26 +++++++++++++++++++++----- 6 files changed, 39 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d2219f..9396a00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ### Fixed - `^` now moves to the first non-blank character instead of the start of the line. +- Escape after `I` now moves left from the actual textarea cursor instead of misplacing the cursor after indentation. ## [0.18.1] — 2026-09-11 diff --git a/src/index.ts b/src/index.ts index 9c0d879..8432673 100644 --- a/src/index.ts +++ b/src/index.ts @@ -231,6 +231,17 @@ const plugin: TuiPluginModule = { } break; } + case "cursorLeft": { + const editor = api.renderer?.currentFocusedEditor; + if (editor?.moveCursorLeft) { + editor.moveCursorLeft(); + editor.getLayoutNode?.().markDirty?.(); + api.renderer?.requestRender?.(); + } else if (editor) { + editor.cursorOffset = Math.max(0, (editor.cursorOffset ?? 0) - 1); + } + break; + } case "selectRange": { const editor = api.renderer?.currentFocusedEditor; if (editor) { diff --git a/src/vim/insert.ts b/src/vim/insert.ts index 31acf32..f5a2430 100644 --- a/src/vim/insert.ts +++ b/src/vim/insert.ts @@ -9,7 +9,7 @@ export function handleInsertKey(state: VimState, _key: string, ev: KeyEvent, pro // unless at position 0 or start of line. const offset = prompt.getCursorOffset(); if (offset > 0 && prompt.getPlainText()[offset - 1] !== "\n") { - actions.push({ type: "cursorTo", offset: offset - 1 }); + actions.push({ type: "cursorLeft" }); } actions.push({ type: "mode", mode: "normal" }); return { consume: true, actions }; diff --git a/src/vim/types.ts b/src/vim/types.ts index ca296c1..cd51994 100644 --- a/src/vim/types.ts +++ b/src/vim/types.ts @@ -22,6 +22,7 @@ export type Action = | { type: "saveUndoSnapshot" } | { type: "undo" } | { type: "cursorTo"; offset: number } + | { type: "cursorLeft" } | { type: "selectRange"; start: number; end: number }; export type HandlerResult = { diff --git a/test/support.ts b/test/support.ts index fbfb60a..1ff0341 100644 --- a/test/support.ts +++ b/test/support.ts @@ -8,6 +8,10 @@ export function cursorTos(actions: Action[]): number[] { return actions.filter((a): a is Extract => a.type === "cursorTo").map((a) => a.offset); } +export function cursorLefts(actions: Action[]): number { + return actions.filter((a) => a.type === "cursorLeft").length; +} + export function deleteRanges(actions: Action[]): Array<{ start: number; end: number }> { return actions .filter((a): a is Extract => a.type === "deleteRange") diff --git a/test/vim/insert.test.ts b/test/vim/insert.test.ts index c6f5ae0..54f67d3 100644 --- a/test/vim/insert.test.ts +++ b/test/vim/insert.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from "bun:test"; -import { createVimState, handleInsertKey, type PromptAccess, type VimState } from "../../src/vim"; +import { createVimState, handleInsertKey, handleNormalKey, type PromptAccess, type VimState } from "../../src/vim"; import { mockPrompt } from "../fixtures"; -import { cmds, cursorTos, ev } from "../support"; +import { cmds, cursorLefts, ev } from "../support"; let state: VimState; @@ -57,12 +57,28 @@ describe("handleInsertKey", () => { }; const r = handleInsertKey(state, "escape", ev("escape"), midLinePrompt); expect(r.consume).toBe(true); - expect(cursorTos(r.actions)).toEqual([4]); + expect(cursorLefts(r.actions)).toBe(1); + }); + + it("I then escape moves left from the textarea cursor", () => { + const prompt: PromptAccess = { + getLine: () => " test test test", + getLineCount: () => 1, + getCursorLine: () => 0, + getCursorOffset: () => 7, + getPlainText: () => " test test test", + }; + + const enterInsert = handleNormalKey(state, "I", ev("i", { shift: true }), prompt); + expect(enterInsert.actions).toContainEqual({ type: "mode", mode: "insert" }); + + const leaveInsert = handleInsertKey(state, "escape", ev("escape"), prompt); + expect(cursorLefts(leaveInsert.actions)).toBe(1); }); it("escape at position 0 does not move cursor", () => { const r = handleInsertKey(state, "escape", ev("escape"), mockPrompt); - expect(cursorTos(r.actions)).toEqual([]); + expect(cursorLefts(r.actions)).toBe(0); }); it("escape at start of line does not move cursor", () => { @@ -74,7 +90,7 @@ describe("handleInsertKey", () => { getPlainText: () => "hello world\nsecond line", }; const r = handleInsertKey(state, "escape", ev("escape"), startOfLinePrompt); - expect(cursorTos(r.actions)).toEqual([]); + expect(cursorLefts(r.actions)).toBe(0); }); it("ctrl+o enters normal mode with oneShotNormal flag", () => { From 6c3666d30629a5dfee029916ca89e220a5b693d9 Mon Sep 17 00:00:00 2001 From: Joseph Tams Date: Wed, 2 Sep 2026 16:44:28 -0700 Subject: [PATCH 3/4] docs: update vim engine inventory --- AGENTS.md | 52 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6e74784..5f3ca25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ vimcode is a TUI plugin for [OpenCode](https://opencode.ai). Before working on it, understand the plugin system: **References (read these, don't guess):** + - Official plugin docs: https://opencode.ai/docs/plugins/ - TUI plugin spec: https://github.com/sst/opencode/blob/dev/packages/opencode/specs/tui-plugins.md - Plugin types: `@opencode-ai/plugin/tui` exports `TuiPluginModule`, `TuiPluginApi` @@ -14,6 +15,7 @@ vimcode is a TUI plugin for [OpenCode](https://opencode.ai). Before working on i `keymap` (register layers, intercepts, dispatch commands), `slots` (register UI into named slots), `ui` (toasts, dialogs), `theme` (colors), `prompt` (read/write prompt text), `state` (session, config), `client` (SDK), `lifecycle` (disposal), `kv` (persistent storage), `route` (custom screens). **Gotchas we hit during development:** + - TUI plugins go in `tui.json`, not `opencode.json`. The config field is `"plugin"`. - The plugin `package.json` needs `exports: { "./tui": "./src/index.ts" }` — the loader checks `./tui`, not `.`. - **`key:before` is NOT a valid intercept type.** The keymap only supports `"key"`, `"key:after"`, and `"raw"`. Passing `"key:before"` silently registers a raw terminal sequence handler that crashes on key events. @@ -30,6 +32,7 @@ vimcode is a TUI plugin for [OpenCode](https://opencode.ai). Before working on i `api.renderer.currentFocusedEditor` (same object as `currentFocusedRenderable`) exposes the underlying Textarea widget. Not part of the documented plugin API, but stable and available at runtime. The codebase currently uses `plainText`, `cursorOffset`, `visualCursor`, `cursorStyle`, `insertText()`, and `editorView`. The rest of the surface below is available but unused. **Top-level properties (read/write):** + - `cursorOffset: number` — absolute cursor position, readable and writable - `visualCursor: { visualRow, visualCol, logicalRow, logicalCol, offset }` — full cursor coordinates (read-only in practice) - `cursorStyle: { style: "block" | "line" | "underline" | "default", blinking: boolean }` — set directly, no DECSCUSR escape needed @@ -37,12 +40,14 @@ vimcode is a TUI plugin for [OpenCode](https://opencode.ai). Before working on i - `selectionBg: RGBA`, `selectionFg: RGBA` — custom selection highlight colors **Top-level methods:** + - `moveCursorLeft/Right/Up/Down()` — direct cursor movement - `setSelection(start, end)`, `setSelectionInclusive(start, end)`, `clearSelection()` — selection control - `gotoVisualLineEnd()`, `gotoLineEnd()` — line boundary jumps - `insertText(text)` — insert at cursor **editorView methods (lower-level):** + - `setCursorByOffset(n)` — position cursor by offset - `getNextWordBoundary()`, `getPrevWordBoundary()` — word boundary detection (enables proper `e` vs `w`) - `getEOL()`, `getVisualSOL()`, `getVisualEOL()` — line boundary info @@ -59,36 +64,38 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, ``` src/ index.ts (414 lines) Plugin entry: intercept registration, action application + index.ts (408 lines) Plugin entry: intercept registration, action application vim/ Pure vim engine (thin barrel re-exports the public surface): index.ts (7 lines) Barrel — public surface only. No export *, no internals. - types.ts (57 lines) Action union, VimState, Mode, Operator, Pending, Range, KeyEvent, HandlerResult, PromptAccess - text.ts (210 lines) Pure string algorithms: charKind, endOfWord, currentLineRange, wordRange, bracketRange, quoteRange, anyBracketRange, anyQuoteRange - tables.ts (35 lines) Keybinding maps: MOTIONS, SELECT_MOTIONS, DELETE_MOTION (engine-internal) + types.ts (58 lines) Action union, VimState, Mode, Operator, Pending, Range, KeyEvent, HandlerResult, PromptAccess + text.ts (224 lines) Pure string algorithms: charKind, endOfWord, currentLineRange, firstNonBlankOnLine, wordRange, bracketRange, quoteRange, anyBracketRange, anyQuoteRange + tables.ts (32 lines) Keybinding maps: MOTIONS, SELECT_MOTIONS, DELETE_MOTION (engine-internal) textobject.ts (36 lines) resolveTextObject — object char → inclusive Range seam (iw/aw, quote/bracket pairs, iq/ib) - util.ts (19 lines) State-agnostic primitives: translateKey, PASS, pushN + util.ts (20 lines) State-agnostic primitives: translateKey, PASS, pushN state.ts (76 lines) VimState lifecycle + transitions insert.ts (32 lines) handleInsertKey - normal.ts (378 lines) handleNormalKey (+ file-local finishUndoableChange, applyOperatorRange, isInputEmpty) - visual.ts (104 lines) handleVisualKey + normal.ts (445 lines) handleNormalKey (+ file-local finishUndoableChange, applyOperatorRange, isInputEmpty, moveToFirstNonBlank) + visual.ts (112 lines) handleVisualKey leader.ts (73 lines) Leader key matching: matchesKeyLike, findMatchingLeader, leaderChar 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/ - support.ts (33 lines) Shared assertion helpers + ev() + support.ts (37 lines) Shared assertion helpers + ev() fixtures.ts (17 lines) Prompt fixtures: mockPrompt, emptyPrompt vim/ Per-module engine tests mirroring src/vim/: - text.test.ts (385) endOfWord, charKind, currentLineRange, wordRange, bracketRange, quoteRange, any* units + text.test.ts (408) endOfWord, charKind, currentLineRange, firstNonBlankOnLine, wordRange, bracketRange, quoteRange, any* units state.test.ts (70) createVimState, toggleVimMode - util.test.ts (31) translateKey - insert.test.ts (92) handleInsertKey - normal.test.ts (823) handleNormalKey branches - visual.test.ts (287) handleVisualKey branches + util.test.ts (35) translateKey + insert.test.ts (108) handleInsertKey + normal.test.ts (981) handleNormalKey branches + visual.test.ts (324) handleVisualKey branches textobject.test.ts (64) resolveTextObject dispatch seam integration.test.ts (579) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync, prompt overlay tracking leader.test.ts (125 lines) Unit tests for leader key matching functions ``` **Data flow:** + ``` KeyEvent → translateKey() → handleInsertKey/handleNormalKey/handleVisualKey() → HandlerResult { consume, actions[] } ↓ ↓ @@ -99,6 +106,7 @@ KeyEvent → translateKey() → handleInsertKey/handleNormalKey/handleVisualKey( Handlers in `src/vim/` (`insert.ts`, `normal.ts`, `visual.ts`) are pure — they take state + key + event, mutate state, return actions. They never touch `api`. The only file that calls `api.keymap.dispatchCommand` is `index.ts`. `src/vim/index.ts` is a strict barrel: it re-exports only the public surface (no `export *`, no internal helpers), and sibling modules import each other directly (`./types`, `./state`, …) never through the barrel. **Action types:** + - `{ type: "cmd", cmd: string }` — dispatched via `setTimeout(() => api.keymap.dispatchCommand(cmd), 0)` - `{ type: "mode", mode: Mode }` — updates the SolidJS signal for the indicator - `{ type: "toast", message: string }` — shows a notification @@ -106,7 +114,8 @@ Handlers in `src/vim/` (`insert.ts`, `normal.ts`, `visual.ts`) are pure — they - `{ type: "insertText", text: string }` — inserts text at cursor via `editor.insertText()` - `{ type: "yankSelection" }` — reads selected text from the focused editor, stores in yank register and clipboard - `{ type: "clearSelection" }` — clears the textarea's selection via `editorView.resetSelection()` -- `{ type: "cursorTo", offset: number }` — sets `editor.cursorOffset` directly +- `{ type: "cursorTo", offset: number }` — moves the textarea cursor to a buffer offset +- `{ type: "cursorLeft" }` — moves the textarea cursor left once - `{ 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")`. @@ -117,15 +126,23 @@ Handlers in `src/vim/` (`insert.ts`, `normal.ts`, `visual.ts`) are pure — they 2. Add the key check and return appropriate actions: ```ts if (key === "yourkey") { - return { consume: true, actions: [{ type: "cmd", cmd: "input.some.command" }] } + return { + consume: true, + actions: [{ type: "cmd", cmd: "input.some.command" }], + }; } ``` 3. Add a test in the matching `test/vim/*.test.ts` (e.g. `test/vim/normal.test.ts`), importing helpers from `../support` and fixtures from `../fixtures`: ```ts it("yourkey dispatches some.command", () => { - const result = handleNormalKey(state, "yourkey", ev("yourkey"), mockPrompt) - expect(cmds(result.actions)).toEqual(["input.some.command"]) - }) + const result = handleNormalKey( + state, + "yourkey", + ev("yourkey"), + mockPrompt, + ); + expect(cmds(result.actions)).toEqual(["input.some.command"]); + }); ``` 4. Run `bun test`, then `just dev` to verify in OpenCode. @@ -134,6 +151,7 @@ Handlers in `src/vim/` (`insert.ts`, `normal.ts`, `visual.ts`) are pure — they Operators (d/c/y) use two tables in `src/vim/tables.ts`: `MOTIONS` maps key → standalone cursor command, `DELETE_MOTION` maps key → destructive command. When an operator is pending and a motion key arrives, `handleNormalKey` (in `src/vim/normal.ts`) looks up `DELETE_MOTION[key]` and dispatches it. To add a new motion that works with operators: + 1. Add the standalone motion to `MOTIONS`: `{ "yourkey": "input.move.whatever" }` 2. Add the destructive version to `DELETE_MOTION`: `{ "yourkey": "input.delete.whatever" }` 3. If the motion needs special handling with operators (like j/k which delete multiple lines), add an explicit branch in the `state.pending.kind === "operator" && key in MOTIONS` section. From e468604494a771678d17d94b5c676b96469b7ce8 Mon Sep 17 00:00:00 2001 From: Joseph Tams Date: Sun, 20 Sep 2026 12:00:30 -0700 Subject: [PATCH 4/4] fix: position cursor directly for visual motions --- AGENTS.md | 33 +++--- src/index.ts | 73 ++++++++++--- src/vim/insert.ts | 9 +- src/vim/normal.ts | 13 ++- src/vim/state.ts | 3 + src/vim/text.ts | 2 +- src/vim/types.ts | 1 + src/vim/visual.ts | 26 ++++- test/integration.test.ts | 218 +++++++++++++++++++++++++++++++++++++++ test/vim/insert.test.ts | 4 +- test/vim/normal.test.ts | 10 +- test/vim/text.test.ts | 4 + test/vim/visual.test.ts | 25 +++-- 13 files changed, 361 insertions(+), 60 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5f3ca25..ec66175 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,8 +33,8 @@ vimcode is a TUI plugin for [OpenCode](https://opencode.ai). Before working on i **Top-level properties (read/write):** -- `cursorOffset: number` — absolute cursor position, readable and writable -- `visualCursor: { visualRow, visualCol, logicalRow, logicalCol, offset }` — full cursor coordinates (read-only in practice) +- `cursorOffset: number` — rendered cursor position, readable and writable; its setter uses display columns, so tabs make it unsuitable for raw buffer offsets +- `visualCursor: { visualRow, visualCol, logicalRow, logicalCol, offset }` — full cursor coordinates (read-only in practice); `logicalCol` and `offset` use display-width units - `cursorStyle: { style: "block" | "line" | "underline" | "default", blinking: boolean }` — set directly, no DECSCUSR escape needed - `plainText: string` — buffer content - `selectionBg: RGBA`, `selectionFg: RGBA` — custom selection highlight colors @@ -42,13 +42,13 @@ vimcode is a TUI plugin for [OpenCode](https://opencode.ai). Before working on i **Top-level methods:** - `moveCursorLeft/Right/Up/Down()` — direct cursor movement -- `setSelection(start, end)`, `setSelectionInclusive(start, end)`, `clearSelection()` — selection control +- `setSelection(start, end)`, `setSelectionInclusive(start, end)`, `clearSelection()` — selection control; range offsets use display-width units - `gotoVisualLineEnd()`, `gotoLineEnd()` — line boundary jumps - `insertText(text)` — insert at cursor **editorView methods (lower-level):** -- `setCursorByOffset(n)` — position cursor by offset +- `setCursorByOffset(n)` — position cursor by a global display-width offset, like `cursorOffset`; translate string offsets before using either - `getNextWordBoundary()`, `getPrevWordBoundary()` — word boundary detection (enables proper `e` vs `w`) - `getEOL()`, `getVisualSOL()`, `getVisualEOL()` — line boundary info - `getLineInfo()`, `getLogicalLineInfo()` — line metadata @@ -57,25 +57,28 @@ vimcode is a TUI plugin for [OpenCode](https://opencode.ai). Before working on i - `moveUpVisual()`, `moveDownVisual()` — visual line movement - `setSelection()`, `resetSelection()`, `hasSelection()` — selection management +**editBuffer methods:** + +- `setCursor(row, col)` — set the cursor without selection handling; row and column use display-width units + This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, and accurate line operations feasible. The current `setTimeout` + `dispatchCommand` approach can be replaced with direct widget manipulation for most operations. ## Architecture ``` src/ - index.ts (414 lines) Plugin entry: intercept registration, action application - index.ts (408 lines) Plugin entry: intercept registration, action application + index.ts (479 lines) Plugin entry: intercept registration, action application vim/ Pure vim engine (thin barrel re-exports the public surface): index.ts (7 lines) Barrel — public surface only. No export *, no internals. - types.ts (58 lines) Action union, VimState, Mode, Operator, Pending, Range, KeyEvent, HandlerResult, PromptAccess + types.ts (59 lines) Action union, VimState, Mode, Operator, Pending, Range, KeyEvent, HandlerResult, PromptAccess text.ts (224 lines) Pure string algorithms: charKind, endOfWord, currentLineRange, firstNonBlankOnLine, wordRange, bracketRange, quoteRange, anyBracketRange, anyQuoteRange tables.ts (32 lines) Keybinding maps: MOTIONS, SELECT_MOTIONS, DELETE_MOTION (engine-internal) textobject.ts (36 lines) resolveTextObject — object char → inclusive Range seam (iw/aw, quote/bracket pairs, iq/ib) util.ts (20 lines) State-agnostic primitives: translateKey, PASS, pushN - state.ts (76 lines) VimState lifecycle + transitions - insert.ts (32 lines) handleInsertKey - normal.ts (445 lines) handleNormalKey (+ file-local finishUndoableChange, applyOperatorRange, isInputEmpty, moveToFirstNonBlank) - visual.ts (112 lines) handleVisualKey + state.ts (79 lines) VimState lifecycle + transitions + insert.ts (39 lines) handleInsertKey + normal.ts (444 lines) handleNormalKey (+ file-local finishUndoableChange, applyOperatorRange, isInputEmpty) + visual.ts (124 lines) handleVisualKey leader.ts (73 lines) Leader key matching: matchesKeyLike, findMatchingLeader, leaderChar 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) @@ -83,14 +86,14 @@ test/ support.ts (37 lines) Shared assertion helpers + ev() fixtures.ts (17 lines) Prompt fixtures: mockPrompt, emptyPrompt vim/ Per-module engine tests mirroring src/vim/: - text.test.ts (408) endOfWord, charKind, currentLineRange, firstNonBlankOnLine, wordRange, bracketRange, quoteRange, any* units + text.test.ts (412) endOfWord, charKind, currentLineRange, firstNonBlankOnLine, wordRange, bracketRange, quoteRange, any* units state.test.ts (70) createVimState, toggleVimMode util.test.ts (35) translateKey insert.test.ts (108) handleInsertKey - normal.test.ts (981) handleNormalKey branches - visual.test.ts (324) handleVisualKey branches + normal.test.ts (977) handleNormalKey branches + visual.test.ts (329) handleVisualKey branches textobject.test.ts (64) resolveTextObject dispatch seam - integration.test.ts (579) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync, prompt overlay tracking + integration.test.ts (797) Full pipeline: cursor positioning, one-shot normal, plugin init, undo snapshots, version sync, prompt overlay tracking leader.test.ts (125 lines) Unit tests for leader key matching functions ``` diff --git a/src/index.ts b/src/index.ts index 8432673..c53e986 100644 --- a/src/index.ts +++ b/src/index.ts @@ -97,7 +97,7 @@ const plugin: TuiPluginModule = { getLine: (n: number) => getInputText().split("\n")[n] ?? "", getLineCount: () => getInputText().split("\n").length, getCursorLine: () => api.renderer?.currentFocusedEditor?.visualCursor?.logicalRow ?? 0, - getCursorOffset: () => api.renderer?.currentFocusedEditor?.cursorOffset ?? 0, + getCursorOffset: () => stringOffsetForCursor(api.renderer?.currentFocusedEditor, getInputText()), getPlainText: () => getInputText(), }; @@ -184,8 +184,8 @@ const plugin: TuiPluginModule = { const eb = editor?.editBuffer; if (eb?.deleteRange) { const text = editor.plainText ?? ""; - const [sl, sc] = offsetToLineCol(text, action.start); - const [el, ec] = offsetToLineCol(text, action.end + 1); + const [sl, sc] = stringOffsetToLineCol(text, action.start); + const [el, ec] = stringOffsetToLineCol(text, action.end + 1); eb.deleteRange(sl, sc, el, ec); } break; @@ -217,17 +217,14 @@ const plugin: TuiPluginModule = { } case "cursorTo": { const editor = api.renderer?.currentFocusedEditor; - if (editor?.moveCursorRight) { + if (editor) { const text = editor.plainText ?? ""; const target = Math.min(Math.max(action.offset, 0), text.length); - const lineStart = text.lastIndexOf("\n", target - 1) + 1; - - editor.cursorOffset = lineStart; - for (let i = lineStart; i < target; i++) editor.moveCursorRight(); + const [row, col] = stringOffsetToLineCol(text, target); + if (editor.editBuffer?.setCursor) editor.editBuffer.setCursor(row, col); + else editor.cursorOffset = stringOffsetToDisplayOffset(text, target); editor.getLayoutNode?.().markDirty?.(); api.renderer?.requestRender?.(); - } else if (editor) { - editor.cursorOffset = action.offset; } break; } @@ -245,7 +242,11 @@ const plugin: TuiPluginModule = { case "selectRange": { const editor = api.renderer?.currentFocusedEditor; if (editor) { - editor.setSelectionInclusive?.(action.start, action.end); + const text = editor.plainText ?? ""; + const start = stringOffsetToDisplayOffset(text, Math.min(action.start, action.end)); + const end = stringOffsetToDisplayOffset(text, Math.max(action.start, action.end) + 1); + if (editor.setSelection) editor.setSelection(start, end); + else editor.setSelectionInclusive?.(start, end - 1); } break; } @@ -427,10 +428,52 @@ 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]; +// OpenTUI offsets and columns count tabs as two display cells. Vim's pure +// engine uses JavaScript string offsets, so translate only at this boundary. +function stringOffsetToDisplayOffset(text: string, offset: number): number { + const target = Math.min(Math.max(offset, 0), text.length); + let displayOffset = 0; + for (let i = 0; i < target; i++) displayOffset += text[i] === "\t" ? 2 : 1; + return displayOffset; +} + +function stringOffsetToLineCol(text: string, offset: number): [number, number] { + const target = Math.min(Math.max(offset, 0), text.length); + let row = 0; + let col = 0; + for (let i = 0; i < target; i++) { + if (text[i] === "\n") { + row++; + col = 0; + } else { + col += text[i] === "\t" ? 2 : 1; + } + } + return [row, col]; +} + +function stringOffsetForCursor( + editor: { visualCursor?: { logicalRow?: number; logicalCol?: number }; cursorOffset?: number } | undefined, + text: string, +): number { + const cursor = editor?.visualCursor; + if (typeof cursor?.logicalRow === "number" && typeof cursor?.logicalCol === "number") { + const lineStart = text + .split("\n") + .slice(0, cursor.logicalRow) + .reduce((offset, line) => offset + line.length + 1, 0); + return lineStart + stringOffsetForDisplayOffset(text.slice(lineStart), cursor.logicalCol); + } + return stringOffsetForDisplayOffset(text, editor?.cursorOffset ?? 0); +} + +function stringOffsetForDisplayOffset(text: string, displayOffset: number): number { + let display = 0; + for (let i = 0; i < text.length; i++) { + if (display >= displayOffset) return i; + display += text[i] === "\t" ? 2 : 1; + } + return text.length; } export default plugin; diff --git a/src/vim/insert.ts b/src/vim/insert.ts index f5a2430..43bd289 100644 --- a/src/vim/insert.ts +++ b/src/vim/insert.ts @@ -8,25 +8,32 @@ export function handleInsertKey(state: VimState, _key: string, ev: KeyEvent, pro // Vim moves cursor one left when leaving insert mode, // unless at position 0 or start of line. const offset = prompt.getCursorOffset(); - if (offset > 0 && prompt.getPlainText()[offset - 1] !== "\n") { + const stayedAtIEntry = offset === state.insertEntryOffset; + state.insertEntryOffset = undefined; + if (!stayedAtIEntry && offset > 0 && prompt.getPlainText()[offset - 1] !== "\n") { actions.push({ type: "cursorLeft" }); } actions.push({ type: "mode", mode: "normal" }); return { consume: true, actions }; } if (ev.name === "return" && ev.ctrl) { + state.insertEntryOffset = undefined; return { consume: true, actions: [{ type: "cmd", cmd: "input.submit" }] }; } if (ev.name === "return") { + state.insertEntryOffset = undefined; return { consume: true, actions: [{ type: "cmd", cmd: "input.newline" }] }; } if (ev.name === "tab") { + state.insertEntryOffset = undefined; return { consume: true, actions: [{ type: "insertText", text: "\t" }] }; } if (ev.name === "o" && ev.ctrl) { state.mode = "normal"; state.oneShotNormal = true; + state.insertEntryOffset = undefined; return { consume: true, actions: [{ type: "mode", mode: "(insert)" }] }; } + state.insertEntryOffset = undefined; return PASS; } diff --git a/src/vim/normal.ts b/src/vim/normal.ts index 29e6536..1be24d4 100644 --- a/src/vim/normal.ts +++ b/src/vim/normal.ts @@ -378,8 +378,13 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom } if (key === "I") { - moveToFirstNonBlank(actions, prompt.getLine(prompt.getCursorLine())); + const offset = firstNonBlankOnLine(prompt.getPlainText(), prompt.getCursorOffset()); + actions.push({ + type: "cursorTo", + offset, + }); enterInsert(state, actions); + state.insertEntryOffset = offset; return { consume: true, actions }; } @@ -437,9 +442,3 @@ function applyOperatorRange( function isInputEmpty(prompt: PromptAccess): boolean { return prompt.getLineCount() === 1 && prompt.getLine(0) === ""; } - -function moveToFirstNonBlank(actions: Action[], line: string) { - actions.push({ type: "cmd", cmd: "input.line.home" }); - const firstNonBlank = line.search(/[^ \t\r]/); - if (firstNonBlank !== -1) pushN(actions, "input.move.right", firstNonBlank); -} diff --git a/src/vim/state.ts b/src/vim/state.ts index b84a4dc..b939826 100644 --- a/src/vim/state.ts +++ b/src/vim/state.ts @@ -20,6 +20,7 @@ export function toggleVimMode(state: VimState): HandlerResult { state.pending = { kind: "none" }; state.count = 0; state.oneShotNormal = false; + state.insertEntryOffset = undefined; return { consume: true, actions: [ @@ -60,6 +61,7 @@ export function enterInsert(state: VimState, actions: Action[]) { resetPending(state); state.mode = "insert"; state.oneShotNormal = false; + state.insertEntryOffset = undefined; actions.push({ type: "mode", mode: "insert" }); } @@ -67,6 +69,7 @@ export function enterNormal(state: VimState, actions: Action[]) { state.mode = "normal"; state.count = 0; state.oneShotNormal = false; + state.insertEntryOffset = undefined; actions.push({ type: "mode", mode: "normal" }); } diff --git a/src/vim/text.ts b/src/vim/text.ts index 191e243..e0ec69b 100644 --- a/src/vim/text.ts +++ b/src/vim/text.ts @@ -45,7 +45,7 @@ export function currentLineRange(text: string, offset: number): { start: number; export function firstNonBlankOnLine(text: string, offset: number, linesDown = 0): number { const safeOffset = Math.min(Math.max(offset, 0), text.length); - let start = text.lastIndexOf("\n", safeOffset - 1) + 1; + let start = safeOffset === 0 ? 0 : text.lastIndexOf("\n", safeOffset - 1) + 1; for (let i = 0; i < linesDown; i++) { const newline = text.indexOf("\n", start); if (newline === -1) break; diff --git a/src/vim/types.ts b/src/vim/types.ts index cd51994..733d28e 100644 --- a/src/vim/types.ts +++ b/src/vim/types.ts @@ -37,6 +37,7 @@ export type VimState = { yankRegister: string; oneShotNormal: boolean; disabled: boolean; + insertEntryOffset?: number; visualAnchor?: number; }; diff --git a/src/vim/visual.ts b/src/vim/visual.ts index 6931b2a..0d34ef5 100644 --- a/src/vim/visual.ts +++ b/src/vim/visual.ts @@ -76,7 +76,11 @@ export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prom if (key === "e") { const n = consumeCount(state); const target = endOfWord(prompt.getPlainText(), prompt.getCursorOffset(), n); - actions.push({ type: "selectRange", start: state.visualAnchor ?? 0, end: target }); + actions.push({ + type: "selectRange", + start: state.visualAnchor ?? 0, + end: target, + }); actions.push({ type: "cursorTo", offset: target }); return { consume: true, actions }; } @@ -84,7 +88,25 @@ export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prom if (key === "^" || key === "_") { const n = consumeCount(state); const target = firstNonBlankOnLine(prompt.getPlainText(), prompt.getCursorOffset(), key === "_" ? n - 1 : 0); - actions.push({ type: "selectRange", start: state.visualAnchor ?? 0, end: target }); + actions.push({ + type: "selectRange", + start: state.visualAnchor ?? 0, + end: target, + }); + actions.push({ type: "cursorTo", offset: target }); + return { consume: true, actions }; + } + + if (key === "h" || key === "l") { + const text = prompt.getPlainText(); + const offset = prompt.getCursorOffset(); + const count = consumeCount(state); + const target = key === "h" ? Math.max(0, offset - count) : Math.min(Math.max(0, text.length - 1), offset + count); + actions.push({ + type: "selectRange", + start: state.visualAnchor ?? 0, + end: target, + }); actions.push({ type: "cursorTo", offset: target }); return { consume: true, actions }; } diff --git a/test/integration.test.ts b/test/integration.test.ts index e208911..d114a16 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -417,6 +417,224 @@ describe("undo snapshot — deleteRange + u", () => { }); }); +// ── direct cursor positioning ─────────────────────────────── + +describe("direct cursor positioning", () => { + async function setup(text: string, cursor: number, displayCursor = cursor) { + const plugin = (await import("../src/index")).default; + let editorText = text; + let editorCursor = cursor; + let selection: { start: number; end: number } | undefined; + // biome-ignore lint/suspicious/noExplicitAny: test mock + let handler: (ctx: any) => void; + + const stringOffsetForDisplayOffset = (value: string, displayOffset: number) => { + let display = 0; + for (let i = 0; i < value.length; i++) { + if (display >= displayOffset) return i; + display += value[i] === "\t" ? 2 : 1; + } + return value.length; + }; + const displayOffsetForStringOffset = (value: string, offset: number) => { + let display = 0; + for (let i = 0; i < offset; i++) display += value[i] === "\t" ? 2 : 1; + return display; + }; + const lineColToOffset = (line: number, col: number) => { + const lines = editorText.split("\n"); + const lineStart = lines.slice(0, line).reduce((offset, current) => offset + current.length + 1, 0); + return lineStart + stringOffsetForDisplayOffset(lines[line] ?? "", col); + }; + const deleteSelection = () => { + if (!selection) return; + editorText = editorText.slice(0, selection.start) + editorText.slice(selection.end + 1); + editorCursor = selection.start; + selection = undefined; + }; + const editor = { + get plainText() { + return editorText; + }, + get cursorOffset() { + return displayCursor; + }, + set cursorOffset(value: number) { + editorCursor = stringOffsetForDisplayOffset(editorText, value); + displayCursor = value; + }, + get visualCursor() { + const lineStart = editorText.lastIndexOf("\n", editorCursor - 1) + 1; + return { + logicalRow: editorText.slice(0, editorCursor).split("\n").length - 1, + logicalCol: displayOffsetForStringOffset(editorText.slice(lineStart), editorCursor - lineStart), + offset: displayOffsetForStringOffset(editorText, editorCursor), + }; + }, + cursorStyle: { style: "block" as const, blinking: true }, + moveCursorRight: () => { + // This matches the host behavior that made cursorTo skip past a visual selection. + editorCursor = selection ? selection.end + 1 : editorCursor + 1; + }, + moveCursorLeft: () => { + editorCursor = Math.max(0, editorCursor - 1); + }, + insertText: (input: string) => { + editorText = editorText.slice(0, editorCursor) + input + editorText.slice(editorCursor); + editorCursor += input.length; + }, + setSelectionInclusive: (start: number, end: number) => { + selection = { start, end }; + }, + setSelection: (start: number, end: number) => { + selection = { + start: stringOffsetForDisplayOffset(editorText, start), + end: stringOffsetForDisplayOffset(editorText, end) - 1, + }; + }, + editorView: { + setCursorByOffset: (offset: number) => { + editorCursor = stringOffsetForDisplayOffset(editorText, offset); + displayCursor = offset; + }, + resetSelection: () => { + selection = undefined; + }, + }, + editBuffer: { + setCursor: (row: number, col: number) => { + editorCursor = lineColToOffset(row, col); + displayCursor = displayOffsetForStringOffset(editorText, editorCursor); + }, + deleteRange: (startLine: number, startCol: number, endLine: number, endCol: number) => { + const start = lineColToOffset(startLine, startCol); + const end = lineColToOffset(endLine, endCol); + editorText = editorText.slice(0, start) + editorText.slice(end); + editorCursor = start; + }, + }, + getLayoutNode: () => ({ markDirty: () => {} }), + }; + + const api = { + renderer: { + currentFocusedEditor: editor, + currentFocusedRenderable: editor, + requestRender: () => {}, + }, + ui: { toast: () => {}, dialog: { open: false } }, + keymap: { + intercept: (_event: string, callback: typeof handler) => { + handler = callback; + }, + dispatchCommand: (command: string) => { + if (command === "input.delete") { + editorText = editorText.slice(0, editorCursor) + editorText.slice(editorCursor + 1); + } + if (command === "input.backspace") deleteSelection(); + // OpenTUI incorrectly moves to the preceding line's end here. + if (command === "input.line.home") editorCursor = editorText.lastIndexOf("\n", editorCursor - 1); + return { ok: false }; + }, + }, + route: { current: { name: "home", params: {} } }, + state: { session: { question: () => [], permission: () => [] } }, + lifecycle: { onDispose: () => {} }, + kv: {}, + }; + + // biome-ignore lint/suspicious/noExplicitAny: test mock + await plugin.tui(api as any, { startMode: "normal", updateCheck: false } as any, undefined as any); + + const press = (name: string, opts: Record = {}) => { + let consumed = false; + handler?.({ + event: { name, eventType: "press", ...opts }, + consume: () => { + consumed = true; + }, + }); + if (!consumed && name.length === 1 && !opts.ctrl && !opts.meta && !opts.super) editor.insertText(name); + }; + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + + return { flush, getCursor: () => editorCursor, getText: () => editorText, press }; + } + + it("vex deletes the word's last character instead of the following space", async () => { + const { flush, getText, press } = await setup("hello world", 1); + + press("v"); + press("e"); + press("escape"); + press("x"); + await flush(); + + expect(getText()).toBe("hell world"); + }); + + it("l extends a visual selection instead of restarting it", async () => { + const { flush, getText, press } = await setup("test next", 0); + + press("v"); + press("e"); + press("l"); + press("d"); + await flush(); + + expect(getText()).toBe("next"); + }); + + it("I inserts at the current line's first non-blank character", async () => { + const { flush, getText, press } = await setup("previous\nnext", "previous\n".length); + + press("i", { shift: true }); + await flush(); + press("x"); + + expect(getText()).toBe("previous\nxnext"); + }); + + it("I positions after all leading tabs", async () => { + const text = "\t\t\tText"; + const { getText, press } = await setup(text, text.indexOf("Text")); + + press("i", { shift: true }); + press("x"); + + expect(getText()).toBe("\t\t\txText"); + }); + + it("I then escape stays after tabbed indentation", async () => { + const text = "\t\t\tTest"; + const { getCursor, press } = await setup(text, text.indexOf("Test")); + + press("i", { shift: true }); + press("escape"); + + expect(getCursor()).toBe(text.indexOf("Test")); + }); + + it("d^ preserves tabbed indentation", async () => { + const text = "\t hello world"; + const { getText, press } = await setup(text, text.indexOf("world"), text.indexOf("world") + 3); + + press("d"); + press("6", { shift: true }); + + expect(getText()).toBe("\t world"); + }); + + it("gg reaches the initial blank line", async () => { + const { getCursor, press } = await setup("\nhello", 3); + + press("g"); + press("g"); + + expect(getCursor()).toBe(0); + }); +}); + // ── arrow keys pass through the intercept (issue #63) ───── describe("arrow keys pass through the intercept", () => { diff --git a/test/vim/insert.test.ts b/test/vim/insert.test.ts index 54f67d3..a31a98e 100644 --- a/test/vim/insert.test.ts +++ b/test/vim/insert.test.ts @@ -60,7 +60,7 @@ describe("handleInsertKey", () => { expect(cursorLefts(r.actions)).toBe(1); }); - it("I then escape moves left from the textarea cursor", () => { + it("I then escape keeps the cursor at the first non-blank character", () => { const prompt: PromptAccess = { getLine: () => " test test test", getLineCount: () => 1, @@ -73,7 +73,7 @@ describe("handleInsertKey", () => { expect(enterInsert.actions).toContainEqual({ type: "mode", mode: "insert" }); const leaveInsert = handleInsertKey(state, "escape", ev("escape"), prompt); - expect(cursorLefts(leaveInsert.actions)).toBe(1); + expect(cursorLefts(leaveInsert.actions)).toBe(0); }); it("escape at position 0 does not move cursor", () => { diff --git a/test/vim/normal.test.ts b/test/vim/normal.test.ts index 8ac8987..958b182 100644 --- a/test/vim/normal.test.ts +++ b/test/vim/normal.test.ts @@ -775,15 +775,11 @@ describe("handleNormalKey — insert entries", () => { const prompt: PromptAccess = { ...mockPrompt, getLine: () => " \t hello", + getPlainText: () => " \t hello", }; const r = handleNormalKey(state, "I", ev("i", { shift: true }), prompt); - expect(cmds(r.actions)).toEqual([ - "input.line.home", - "input.move.right", - "input.move.right", - "input.move.right", - "input.move.right", - ]); + expect(cursorTos(r.actions)).toEqual([4]); + expect(cmds(r.actions)).toEqual([]); expect(state.mode).toBe("insert"); }); diff --git a/test/vim/text.test.ts b/test/vim/text.test.ts index 1a017e7..b848a68 100644 --- a/test/vim/text.test.ts +++ b/test/vim/text.test.ts @@ -165,6 +165,10 @@ describe("firstNonBlankOnLine", () => { it("returns the line start when the line is blank", () => { expect(firstNonBlankOnLine(" \t\r\nnext", 0)).toBe(0); }); + + it("keeps the cursor on an initial blank line", () => { + expect(firstNonBlankOnLine("\nhello", 0)).toBe(0); + }); }); // ── wordRange (inner) ────────────────────────────────────── diff --git a/test/vim/visual.test.ts b/test/vim/visual.test.ts index 45a1327..0421fc1 100644 --- a/test/vim/visual.test.ts +++ b/test/vim/visual.test.ts @@ -17,14 +17,18 @@ describe("handleVisualKey — motions", () => { state.mode = "visual"; }); - it("h dispatches input.select.left", () => { - const r = handleVisualKey(state, "h", ev("h")); - expect(cmds(r.actions)).toEqual(["input.select.left"]); + it("h extends the selection left", () => { + state.visualAnchor = 2; + const prompt: PromptAccess = { ...mockPrompt, getCursorOffset: () => 2 }; + const r = handleVisualKey(state, "h", ev("h"), prompt); + expect(selectRanges(r.actions)).toEqual([{ start: 2, end: 1 }]); + expect(cursorTos(r.actions)).toEqual([1]); }); - it("l dispatches input.select.right", () => { - const r = handleVisualKey(state, "l", ev("l")); - expect(cmds(r.actions)).toEqual(["input.select.right"]); + it("l extends the selection right", () => { + const r = handleVisualKey(state, "l", ev("l"), mockPrompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 1 }]); + expect(cursorTos(r.actions)).toEqual([1]); }); it("j dispatches input.select.down", () => { @@ -84,10 +88,11 @@ describe("handleVisualKey — motions", () => { expect(cursorTos(r.actions)).toEqual([text.indexOf("fourth")]); }); - it("3l dispatches input.select.right 3 times", () => { - handleVisualKey(state, "3", ev("3")); - const r = handleVisualKey(state, "l", ev("l")); - expect(cmds(r.actions)).toEqual(["input.select.right", "input.select.right", "input.select.right"]); + it("3l extends the selection right three characters", () => { + handleVisualKey(state, "3", ev("3"), mockPrompt); + const r = handleVisualKey(state, "l", ev("l"), mockPrompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 3 }]); + expect(cursorTos(r.actions)).toEqual([3]); }); it("G dispatches input.select.buffer.end", () => {