diff --git a/README.md b/README.md index e435481b05fb..ae37b104c5ff 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,11 @@ Toggle via command palette (`Ctrl+p` -> `Toggle vim mode`). **Editing** -`i` `I` `a` `A` `o` `O` `R` `r` `x` `~` `dd` `dw` `db` `diw` `daw` `df` `dF` `dt` `dT` `d%` `d}` `d{` `cc` `cw` `cb` `ciw` `caw` `cf` `cF` `ct` `cT` `C` `c%` `c}` `c{` `s` `S` `J` +`i` `I` `a` `A` `o` `O` `R` `r` `x` `~` `dd` `dw` `db` `diw` `daw` `diW` `daW` `df` `dF` `dt` `dT` `d%` `d}` `d{` `cc` `cw` `cb` `ciw` `caw` `ciW` `caW` `cf` `cF` `ct` `cT` `C` `c%` `c}` `c{` `s` `S` `J` **yank / put / undo / repeat** -`yy` `yw` `yiw` `yaw` `y%` `y}` `y{` `p` `P` `u` `ctrl+r` `.` +`yy` `yw` `yiw` `yaw` `yiW` `yaW` `y%` `y}` `y{` `p` `P` `u` `ctrl+r` `.` - Copy the current prompt selection with `y` (default: `ctrl+x` then `y`). - Configure it with `keybinds.prompt_copy_selection`. diff --git a/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts b/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts index e7f05c799012..1e5c27f85c1e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts +++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts @@ -386,8 +386,9 @@ export function createVimHandler(input: { } function resolveTextObject(event: VimEvent, key: string, scope: VimTextObjectScope) { - if (key === "w" && !event.shift && !hasModifier(event)) { - return () => wordTextObjectOperation(input.textarea(), scope === "around") + if ((key === "w" || isShifted(event, "w")) && !hasModifier(event)) { + const big = isShifted(event, "w") + return () => wordTextObjectOperation(input.textarea(), scope === "around", big) } } diff --git a/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts b/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts index 7320a342fae6..a0bbbfe8260a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts +++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts @@ -404,62 +404,62 @@ export function wordEnd(text: string, offset: number, big: boolean) { return wordRunEnd(text, pos, big) } -export function wordTextObjectOperation(textarea: TextareaRenderable, around: boolean): VimOperatorResult { +export function wordTextObjectOperation(textarea: TextareaRenderable, around: boolean, big = false): VimOperatorResult { const text = textarea.plainText if (!text.length) return { span: null, register: null } - const blank = wordTextObjectBlankSpan(text, textarea.cursorOffset) + const blank = wordTextObjectBlankSpan(text, textarea.cursorOffset, big) if (blank) { if (!around) return buildOperatorResult(text, blank, null, false) - return buildOperatorResult(text, wordTextObjectAroundBlankSpan(text, blank), null, false) + return buildOperatorResult(text, wordTextObjectAroundBlankSpan(text, blank, big), null, false) } - const inner = wordTextObjectInnerSpan(text, textarea.cursorOffset) + const inner = wordTextObjectInnerSpan(text, textarea.cursorOffset, big) if (!inner) return { span: null, register: null } if (!around) return buildOperatorResult(text, inner, null, false) let end = inner.end - while (end < text.length && text[end] !== "\n" && wordClass(text[end], false) === "blank") end++ + while (end < text.length && text[end] !== "\n" && wordClass(text[end], big) === "blank") end++ if (end > inner.end) return buildOperatorResult(text, { start: inner.start, end }, null, false) let start = inner.start - while (start > 0 && text[start - 1] !== "\n" && wordClass(text[start - 1], false) === "blank") start-- + while (start > 0 && text[start - 1] !== "\n" && wordClass(text[start - 1], big) === "blank") start-- return buildOperatorResult(text, { start, end: inner.end }, null, false) } -function wordTextObjectInnerSpan(text: string, cursor: number): VimSpan | null { +function wordTextObjectInnerSpan(text: string, cursor: number, big: boolean): VimSpan | null { const pos = Math.min(cursor, text.length - 1) if (text[pos] === "\n") return null - const target = wordClass(text[pos], false) + const target = wordClass(text[pos], big) let start = pos - while (start > 0 && wordClass(text[start - 1], false) === target) start-- + while (start > 0 && wordClass(text[start - 1], big) === target) start-- let end = pos + 1 - while (end < text.length && wordClass(text[end], false) === target) end++ + while (end < text.length && wordClass(text[end], big) === target) end++ return start < end ? { start, end } : null } -function wordTextObjectBlankSpan(text: string, cursor: number): VimSpan | null { +function wordTextObjectBlankSpan(text: string, cursor: number, big: boolean): VimSpan | null { let start = Math.min(cursor, text.length - 1) - if (wordClass(text[start], false) !== "blank" || text[start] === "\n") return null - while (start > 0 && text[start - 1] !== "\n" && wordClass(text[start - 1], false) === "blank") start-- + if (wordClass(text[start], big) !== "blank" || text[start] === "\n") return null + while (start > 0 && text[start - 1] !== "\n" && wordClass(text[start - 1], big) === "blank") start-- let end = start - while (end < text.length && text[end] !== "\n" && wordClass(text[end], false) === "blank") end++ + while (end < text.length && text[end] !== "\n" && wordClass(text[end], big) === "blank") end++ return start < end ? { start, end } : null } -function wordTextObjectAroundBlankSpan(text: string, blank: VimSpan): VimSpan | null { +function wordTextObjectAroundBlankSpan(text: string, blank: VimSpan, big: boolean): VimSpan | null { let end = blank.end - while (end < text.length && text[end] !== "\n" && wordClass(text[end], false) === "blank") end++ + while (end < text.length && text[end] !== "\n" && wordClass(text[end], big) === "blank") end++ if (end >= text.length || text[end] === "\n") return null - const inner = wordTextObjectInnerSpan(text, end) + const inner = wordTextObjectInnerSpan(text, end, big) if (!inner) return null end = inner.end - while (end < text.length && text[end] !== "\n" && wordClass(text[end], false) === "blank") end++ + while (end < text.length && text[end] !== "\n" && wordClass(text[end], big) === "blank") end++ return { start: blank.start, end } } diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index c5a787a19d21..ad4443f26608 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -2674,6 +2674,83 @@ describe("vim motion handler", () => { expect(ctx.state.register()).toEqual({ text: " world", linewise: false }) }) + test("diW deletes inner big word", () => { + const ctx = createHandler("foo.bar baz") + ctx.textarea.cursorOffset = 2 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("W").event) + + expect(ctx.textarea.plainText).toBe(" baz") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.register()).toEqual({ text: "foo.bar", linewise: false }) + }) + + test("ciW changes inner big word", () => { + const ctx = createHandler("foo.bar baz") + ctx.textarea.cursorOffset = 2 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("W").event) + + expect(ctx.textarea.plainText).toBe(" baz") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "foo.bar", linewise: false }) + }) + + test("daW deletes big word and following whitespace", () => { + const ctx = createHandler("foo.bar baz") + ctx.textarea.cursorOffset = 2 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("W").event) + + expect(ctx.textarea.plainText).toBe("baz") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.register()).toEqual({ text: "foo.bar ", linewise: false }) + }) + + test("yiW yanks inner big word", () => { + const ctx = createHandler("foo.bar baz") + ctx.textarea.cursorOffset = 2 + + ctx.handler.handleKey(createEvent("y").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("W").event) + + expect(ctx.textarea.plainText).toBe("foo.bar baz") + expect(ctx.state.register()).toEqual({ text: "foo.bar", linewise: false }) + }) + + test("yaW yanks big word and following whitespace", () => { + const ctx = createHandler("foo.bar baz") + ctx.textarea.cursorOffset = 2 + + ctx.handler.handleKey(createEvent("y").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("W").event) + + expect(ctx.textarea.plainText).toBe("foo.bar baz") + expect(ctx.state.register()).toEqual({ text: "foo.bar ", linewise: false }) + }) + + test("diW handles lowercase shifted key events", () => { + const ctx = createHandler("foo.bar baz") + ctx.textarea.cursorOffset = 2 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w", { shift: true }).event) + + expect(ctx.textarea.plainText).toBe(" baz") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.register()).toEqual({ text: "foo.bar", linewise: false }) + }) + test("text object pending display shows operator and object scope", () => { const ctx = createHandler("hello world") @@ -5430,6 +5507,21 @@ describe("vim dot repeat", () => { expect(ctx.textarea.plainText).toBe("hi hi") }) + test("dot repeats ciW inserted text", () => { + const ctx = createHandler("foo.bar baz.qux") + + press(ctx, "c") + press(ctx, "i") + press(ctx, "W") + ctx.textarea.insertText("hi") + press(ctx, "escape") + expect(ctx.textarea.plainText).toBe("hi baz.qux") + + ctx.textarea.cursorOffset = 3 + press(ctx, ".") + expect(ctx.textarea.plainText).toBe("hi hi") + }) + test("dot repeats s inserted text", () => { const ctx = createHandler("abc def")