From b12b336accdf738bb484471d019b02672ce02715 Mon Sep 17 00:00:00 2001 From: Priyansh Date: Wed, 14 Oct 2026 13:58:19 +0000 Subject: [PATCH 1/3] tui: add Vim cw and cc change commands Add c as a pending key in the Vim mode controller. cw deletes the word forward and enters insert mode; cc clears the current line's text (keeping the line) and enters insert mode. c followed by any other key cancels without editing, matching how d behaves. Add LineEditor.clearCurrentLine for the cc case. Add tests for all three behaviors. Fixes #425 Signed-off-by: Priyansh --- packages/tui/src/editor.ts | 7 +++++ packages/tui/src/vim-mode.ts | 13 ++++++++- packages/tui/test/productivity.test.ts | 39 ++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/editor.ts b/packages/tui/src/editor.ts index d0bf877c..f5abf2de 100644 --- a/packages/tui/src/editor.ts +++ b/packages/tui/src/editor.ts @@ -327,6 +327,13 @@ export class LineEditor { else this.kill(0, lineEnd); } + /** Remove the current line's text, keep the line itself, and put the cursor at its start. */ + clearCurrentLine(): void { + const { lineStart, lineEnd } = this.position(); + if (lineStart === lineEnd) return; + this.kill(lineStart, lineEnd); + } + openLine(direction: "above" | "below"): void { const { lineStart, lineEnd } = this.position(); this.snapshot(); diff --git a/packages/tui/src/vim-mode.ts b/packages/tui/src/vim-mode.ts index 83571b7b..88338fec 100644 --- a/packages/tui/src/vim-mode.ts +++ b/packages/tui/src/vim-mode.ts @@ -51,7 +51,18 @@ export class VimModeController { else if (char === "w") editor.deleteWordForward(); return true; } - if (char === "d" || char === "f") { + if (this.pending === "c") { + this.pending = ""; + if (char === "w") { + editor.deleteWordForward(); + this.current = "insert"; + } else if (char === "c") { + editor.clearCurrentLine(); + this.current = "insert"; + } + return true; + } + if (char === "d" || char === "f" || char === "c") { this.pending = char; return true; } diff --git a/packages/tui/test/productivity.test.ts b/packages/tui/test/productivity.test.ts index 7d24b253..4af5fffc 100644 --- a/packages/tui/test/productivity.test.ts +++ b/packages/tui/test/productivity.test.ts @@ -54,6 +54,45 @@ test("Vim mode edits through the standard grapheme-safe editor", () => { assert.equal(editor.render(40).cursorColumn, 3); }); +test("Vim mode change commands cw and cc delete then enter insert mode", () => { + const editor = new LineEditor(); + const vim = new VimModeController(); + editor.setText("one two"); + + assert.equal(vim.handle({ kind: "escape" }, editor), true); + vim.handle({ kind: "char", char: "0" }, editor); + vim.handle({ kind: "char", char: "c" }, editor); + vim.handle({ kind: "char", char: "w" }, editor); + assert.equal(editor.text, "two"); + assert.equal(vim.mode, "insert"); +}); + +test("Vim mode cc clears the current line but keeps it", () => { + const editor = new LineEditor(); + const vim = new VimModeController(); + editor.setText("hello\nworld"); + + assert.equal(vim.handle({ kind: "escape" }, editor), true); + vim.handle({ kind: "char", char: "k" }, editor); + vim.handle({ kind: "char", char: "0" }, editor); + vim.handle({ kind: "char", char: "c" }, editor); + vim.handle({ kind: "char", char: "c" }, editor); + assert.equal(editor.text, "\nworld"); + assert.equal(vim.mode, "insert"); +}); + +test("Vim mode c followed by another key cancels without editing", () => { + const editor = new LineEditor(); + const vim = new VimModeController(); + editor.setText("abc"); + + assert.equal(vim.handle({ kind: "escape" }, editor), true); + vim.handle({ kind: "char", char: "c" }, editor); + vim.handle({ kind: "char", char: "x" }, editor); + assert.equal(editor.text, "abc"); + assert.equal(vim.mode, "normal"); +}); + test("vertical movement preserves the preferred visual column", () => { const editor = new LineEditor(); editor.setText("abcdef\nx\nabcdef"); From fc456673ca4b1988fb11c55d172de8193f289d7e Mon Sep 17 00:00:00 2001 From: Priyansh Date: Wed, 21 Oct 2026 22:42:32 +0000 Subject: [PATCH 2/3] tui: keep the separator in Vim cw --- packages/tui/src/editor.ts | 20 +++++++++++++++ packages/tui/src/vim-mode.ts | 2 +- packages/tui/test/productivity.test.ts | 34 ++++++++++++++++++++++++-- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/editor.ts b/packages/tui/src/editor.ts index f5abf2de..125d79ef 100644 --- a/packages/tui/src/editor.ts +++ b/packages/tui/src/editor.ts @@ -312,6 +312,26 @@ export class LineEditor { } } + /** + * Vim `cw`: delete to the end of the current word but keep the separator. + * Unlike `dw`, `cw` does not consume the whitespace after the word; on + * whitespace (or at the end of the buffer) Vim falls back to `dw`. + */ + changeWordForward(): void { + const onWord = + this.cursor < this.buffer.length && + wordCharacter.test(graphemeAt(this.buffer, this.cursor)); + if (!onWord) { + this.deleteWordForward(); + return; + } + let to = this.cursor; + while (to < this.buffer.length && wordCharacter.test(graphemeAt(this.buffer, to))) { + to = nextBoundary(this.buffer, to); + } + this.kill(this.cursor, to); + } + deleteWordForward(): void { let to = this.wordRight(); while (to < this.buffer.length && !wordCharacter.test(graphemeAt(this.buffer, to))) { diff --git a/packages/tui/src/vim-mode.ts b/packages/tui/src/vim-mode.ts index 88338fec..b4a2e12d 100644 --- a/packages/tui/src/vim-mode.ts +++ b/packages/tui/src/vim-mode.ts @@ -54,7 +54,7 @@ export class VimModeController { if (this.pending === "c") { this.pending = ""; if (char === "w") { - editor.deleteWordForward(); + editor.changeWordForward(); this.current = "insert"; } else if (char === "c") { editor.clearCurrentLine(); diff --git a/packages/tui/test/productivity.test.ts b/packages/tui/test/productivity.test.ts index 4af5fffc..b7ea112d 100644 --- a/packages/tui/test/productivity.test.ts +++ b/packages/tui/test/productivity.test.ts @@ -54,7 +54,7 @@ test("Vim mode edits through the standard grapheme-safe editor", () => { assert.equal(editor.render(40).cursorColumn, 3); }); -test("Vim mode change commands cw and cc delete then enter insert mode", () => { +test("Vim mode cw changes the word but keeps the separator", () => { const editor = new LineEditor(); const vim = new VimModeController(); editor.setText("one two"); @@ -63,10 +63,40 @@ test("Vim mode change commands cw and cc delete then enter insert mode", () => { vim.handle({ kind: "char", char: "0" }, editor); vim.handle({ kind: "char", char: "c" }, editor); vim.handle({ kind: "char", char: "w" }, editor); - assert.equal(editor.text, "two"); + // Vim's cw stops at the end of the word; unlike dw it does not eat the space. + assert.equal(editor.text, " two"); assert.equal(vim.mode, "insert"); }); +test("Vim mode cw mid-word and on whitespace match Vim", () => { + const editor = new LineEditor(); + const vim = new VimModeController(); + editor.setText("alpha beta gamma"); + + vim.handle({ kind: "escape" }, editor); + // Mid-word: delete from the cursor to the end of "alpha". + vim.handle({ kind: "char", char: "0" }, editor); + vim.handle({ kind: "char", char: "l" }, editor); + vim.handle({ kind: "char", char: "l" }, editor); + vim.handle({ kind: "char", char: "c" }, editor); + vim.handle({ kind: "char", char: "w" }, editor); + assert.equal(editor.text, "al beta gamma"); + + // On whitespace cw falls back to dw, which takes the whitespace run and the + // following word with it. + const second = new LineEditor(); + const secondVim = new VimModeController(); + second.setText("one two"); + secondVim.handle({ kind: "escape" }, second); + secondVim.handle({ kind: "char", char: "0" }, second); + secondVim.handle({ kind: "right" }, second); + secondVim.handle({ kind: "right" }, second); + secondVim.handle({ kind: "right" }, second); + secondVim.handle({ kind: "char", char: "c" }, second); + secondVim.handle({ kind: "char", char: "w" }, second); + assert.equal(second.text, "one"); +}); + test("Vim mode cc clears the current line but keeps it", () => { const editor = new LineEditor(); const vim = new VimModeController(); From 05189908d6d92077c66b635f3a6ce32afaca800d Mon Sep 17 00:00:00 2001 From: Priyansh Date: Fri, 23 Oct 2026 14:37:57 +0000 Subject: [PATCH 3/3] tui: cancel pending vim operators on navigation; keep cw inside punctuation Navigation keys left a pending c/d/f in place, so `c`, Left, `w` changed a word instead of moving. Any key that does not continue the operator now clears it, including navigation, enter/tab and redo. `cw` on a punctuation character fell back to `dw` and ate the following word (foo.bar -> foobaz). `cw` is `ce`: it now stops at the end of the run under the cursor, so a punctuation run is changed on its own. --- packages/tui/src/editor.ts | 15 ++++++---- packages/tui/src/vim-mode.ts | 15 ++++++++-- packages/tui/test/productivity.test.ts | 38 ++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/packages/tui/src/editor.ts b/packages/tui/src/editor.ts index 125d79ef..8f0c4975 100644 --- a/packages/tui/src/editor.ts +++ b/packages/tui/src/editor.ts @@ -201,6 +201,7 @@ export function decodeKeys(data: string): EditorKey[] { const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); const wordCharacter = /[\p{L}\p{N}_]/u; +const blankCharacter = /\s/u; function boundaries(value: string): number[] { const result = [0]; @@ -318,15 +319,19 @@ export class LineEditor { * whitespace (or at the end of the buffer) Vim falls back to `dw`. */ changeWordForward(): void { - const onWord = - this.cursor < this.buffer.length && - wordCharacter.test(graphemeAt(this.buffer, this.cursor)); - if (!onWord) { + const at = this.cursor < this.buffer.length ? graphemeAt(this.buffer, this.cursor) : ""; + // On whitespace (or at the end of the buffer) Vim's `cw` falls back to `dw`. + if (!at || blankCharacter.test(at)) { this.deleteWordForward(); return; } + // Otherwise `cw` is `ce`: change to the end of the current word, where a run + // of non-blank punctuation counts as a word of its own. + const inWord = wordCharacter.test(at); + const sameClass = (char: string): boolean => + inWord ? wordCharacter.test(char) : !wordCharacter.test(char) && !blankCharacter.test(char); let to = this.cursor; - while (to < this.buffer.length && wordCharacter.test(graphemeAt(this.buffer, to))) { + while (to < this.buffer.length && sameClass(graphemeAt(this.buffer, to))) { to = nextBoundary(this.buffer, to); } this.kill(this.cursor, to); diff --git a/packages/tui/src/vim-mode.ts b/packages/tui/src/vim-mode.ts index b4a2e12d..bbb7dd74 100644 --- a/packages/tui/src/vim-mode.ts +++ b/packages/tui/src/vim-mode.ts @@ -32,12 +32,23 @@ export class VimModeController { this.pending = ""; return true; } - if (key.kind === "enter" || key.kind === "follow-up" || key.kind === "tab") return false; + if (key.kind === "enter" || key.kind === "follow-up" || key.kind === "tab") { + // Any other key cancels a pending operator, including keys the editor + // handles itself. + this.pending = ""; + return false; + } if (key.kind === "ctrl" && key.char === "r") { + this.pending = ""; editor.apply({ kind: "redo" }); return true; } - if (key.kind !== "char") return this.handleNavigation(key, editor); + if (key.kind !== "char") { + // Navigation cancels a pending operator: `c`, Left, `w` must move by a + // word rather than change one. + this.pending = ""; + return this.handleNavigation(key, editor); + } const char = key.char; if (this.pending === "f") { diff --git a/packages/tui/test/productivity.test.ts b/packages/tui/test/productivity.test.ts index b7ea112d..8191760d 100644 --- a/packages/tui/test/productivity.test.ts +++ b/packages/tui/test/productivity.test.ts @@ -123,6 +123,44 @@ test("Vim mode c followed by another key cancels without editing", () => { assert.equal(vim.mode, "normal"); }); +test("Vim mode navigation cancels a pending operator", () => { + const editor = new LineEditor(); + const vim = new VimModeController(); + editor.setText("one two three"); + + vim.handle({ kind: "escape" }, editor); + vim.handle({ kind: "char", char: "0" }, editor); + vim.handle({ kind: "char", char: "w" }, editor); + assert.equal(editor.render(40).cursorColumn, 3); + vim.handle({ kind: "char", char: "c" }, editor); + // The exact sequence from review: `c`, Left, `w`. The Left cancels `c`, so + // `w` moves a word instead of changing one. + vim.handle({ kind: "left" }, editor); + vim.handle({ kind: "char", char: "w" }, editor); + + assert.equal(editor.text, "one two three"); + assert.equal(vim.mode, "normal"); + assert.equal(editor.render(40).cursorColumn, 3); +}); + +test("Vim mode cw on punctuation changes only the punctuation run", () => { + const editor = new LineEditor(); + const vim = new VimModeController(); + editor.setText("foo.bar baz"); + + vim.handle({ kind: "escape" }, editor); + vim.handle({ kind: "char", char: "0" }, editor); + vim.handle({ kind: "char", char: "l" }, editor); + vim.handle({ kind: "char", char: "l" }, editor); + vim.handle({ kind: "char", char: "l" }, editor); + vim.handle({ kind: "char", char: "c" }, editor); + vim.handle({ kind: "char", char: "w" }, editor); + + // Vim treats the punctuation run as its own word, so `.bar ` is not removed. + assert.equal(editor.text, "foobar baz"); + assert.equal(vim.mode, "insert"); +}); + test("vertical movement preserves the preferred visual column", () => { const editor = new LineEditor(); editor.setText("abcdef\nx\nabcdef");