diff --git a/README.md b/README.md index 5ec3ebcc20f1..e435481b05fb 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` `df` `dF` `dt` `dT` `d%` `d}` `d{` `cc` `cw` `cb` `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` `df` `dF` `dt` `dT` `d%` `d}` `d{` `cc` `cw` `cb` `ciw` `caw` `cf` `cF` `ct` `cT` `C` `c%` `c}` `c{` `s` `S` `J` **yank / put / undo / repeat** -`yy` `yw` `y%` `y}` `y{` `p` `P` `u` `ctrl+r` `.` +`yy` `yw` `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 c36c82296252..e7f05c799012 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 @@ -59,6 +59,7 @@ import { toggleCase, toggleSelectionCase, wordEnd, + wordTextObjectOperation, yankLine, yankLineSpan, yankSelection, @@ -77,6 +78,7 @@ export type VimEvent = { export type VimCopyMove = "up" | "down" | "left" | "right" type VimFindOperator = "f" | "F" | "t" | "T" +type VimTextObjectScope = "inner" | "around" export function createVimHandler(input: { enabled: Accessor @@ -120,6 +122,7 @@ export function createVimHandler(input: { }) { let wantedColumn: VimWantedColumn | undefined let pendingOperatorFind: { operation: VimOperator; find: VimFindOperator } | undefined + let pendingTextObject: { operation: VimOperator; scope: VimTextObjectScope } | undefined function hasModifier(event: VimEvent) { return !!event.ctrl || !!event.meta || !!event.super @@ -369,6 +372,46 @@ export function createVimHandler(input: { return false } + function startTextObject(event: VimEvent, operation: VimOperator, scope: VimTextObjectScope) { + pendingTextObject = { operation, scope } + input.state.setPending(operation, operation + (scope === "around" ? "a" : "i")) + event.preventDefault() + return true + } + + function operatorTextObject(event: VimEvent, key: string, operation: VimOperator) { + if (key === "i" && !event.shift && !hasModifier(event)) return startTextObject(event, operation, "inner") + if (key === "a" && !event.shift && !hasModifier(event)) return startTextObject(event, operation, "around") + return false + } + + function resolveTextObject(event: VimEvent, key: string, scope: VimTextObjectScope) { + if (key === "w" && !event.shift && !hasModifier(event)) { + return () => wordTextObjectOperation(input.textarea(), scope === "around") + } + } + + function pendingTextObjectOperator(event: VimEvent, key: string): boolean { + if (!pendingTextObject) return false + if (input.state.pending() !== pendingTextObject.operation) { + pendingTextObject = undefined + return false + } + + const textObject = pendingTextObject + const operation = resolveTextObject(event, key, textObject.scope) + pendingTextObject = undefined + if (operation) { + applyOperatorResult(operation, textObject.operation) + event.preventDefault() + return true + } + + input.state.clearPending() + event.preventDefault() + return true + } + function pendingFindOperator(event: VimEvent): boolean { if (!pendingOperatorFind) return false if (input.state.pending() !== pendingOperatorFind.find) { @@ -440,6 +483,7 @@ export function createVimHandler(input: { } if (pendingFindOperator(event)) return true + if (pendingTextObjectOperator(event, key)) return true if (input.state.pending() === "vr" && input.state.isVisual()) { if (hasModifier(event)) { @@ -694,6 +738,8 @@ export function createVimHandler(input: { return true } + if (operatorTextObject(event, key, "c")) return true + if (paragraphOperator(key, "c")) { event.preventDefault() return true @@ -706,6 +752,7 @@ export function createVimHandler(input: { if (operatorFind(event, key, "c")) return true + pendingTextObject = undefined input.state.clearPending() } @@ -730,6 +777,8 @@ export function createVimHandler(input: { return true } + if (operatorTextObject(event, key, "d")) return true + if (paragraphOperator(key, "d")) { event.preventDefault() return true @@ -742,6 +791,7 @@ export function createVimHandler(input: { if (operatorFind(event, key, "d")) return true + pendingTextObject = undefined input.state.clearPending() } @@ -766,6 +816,8 @@ export function createVimHandler(input: { return true } + if (operatorTextObject(event, key, "y")) return true + if (paragraphOperator(key, "y")) { event.preventDefault() return true @@ -776,6 +828,7 @@ export function createVimHandler(input: { return true } + pendingTextObject = undefined input.state.clearPending() } 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 ff763ca4aa1d..7320a342fae6 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,6 +404,66 @@ export function wordEnd(text: string, offset: number, big: boolean) { return wordRunEnd(text, pos, big) } +export function wordTextObjectOperation(textarea: TextareaRenderable, around: boolean): VimOperatorResult { + const text = textarea.plainText + if (!text.length) return { span: null, register: null } + + const blank = wordTextObjectBlankSpan(text, textarea.cursorOffset) + if (blank) { + if (!around) return buildOperatorResult(text, blank, null, false) + return buildOperatorResult(text, wordTextObjectAroundBlankSpan(text, blank), null, false) + } + + const inner = wordTextObjectInnerSpan(text, textarea.cursorOffset) + 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++ + 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-- + return buildOperatorResult(text, { start, end: inner.end }, null, false) +} + +function wordTextObjectInnerSpan(text: string, cursor: number): VimSpan | null { + const pos = Math.min(cursor, text.length - 1) + if (text[pos] === "\n") return null + const target = wordClass(text[pos], false) + let start = pos + while (start > 0 && wordClass(text[start - 1], false) === target) start-- + + let end = pos + 1 + while (end < text.length && wordClass(text[end], false) === target) end++ + + return start < end ? { start, end } : null +} + +function wordTextObjectBlankSpan(text: string, cursor: number): 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-- + + let end = start + while (end < text.length && text[end] !== "\n" && wordClass(text[end], false) === "blank") end++ + + return start < end ? { start, end } : null +} + +function wordTextObjectAroundBlankSpan(text: string, blank: VimSpan): VimSpan | null { + let end = blank.end + while (end < text.length && text[end] !== "\n" && wordClass(text[end], false) === "blank") end++ + if (end >= text.length || text[end] === "\n") return null + + const inner = wordTextObjectInnerSpan(text, end) + if (!inner) return null + end = inner.end + while (end < text.length && text[end] !== "\n" && wordClass(text[end], false) === "blank") end++ + + return { start: blank.start, end } +} + function deleteOffsets(textarea: TextareaRenderable, startOffset: number, endOffset: number) { if (endOffset <= startOffset) return const end = Math.min(endOffset, textarea.plainText.length) diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index a39989cbc29c..c5a787a19d21 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -2412,6 +2412,292 @@ describe("vim motion handler", () => { expect(reg).toEqual({ text: "wo", linewise: false }) }) + test("diw deletes inner word", () => { + const ctx = createHandler("hello world test") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello test") + expect(ctx.textarea.cursorOffset).toBe(6) + expect(ctx.state.register()).toEqual({ text: "world", linewise: false }) + expect(ctx.state.pending()).toBe("") + }) + + test("ciw changes inner word", () => { + const ctx = createHandler("hello world test") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello test") + expect(ctx.textarea.cursorOffset).toBe(6) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "world", linewise: false }) + }) + + test("diw deletes punctuation text object", () => { + const ctx = createHandler("foo...bar") + ctx.textarea.cursorOffset = 4 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("foobar") + expect(ctx.textarea.cursorOffset).toBe(3) + expect(ctx.state.register()).toEqual({ text: "...", linewise: false }) + }) + + test("caw changes punctuation and following whitespace", () => { + const ctx = createHandler("foo... bar") + ctx.textarea.cursorOffset = 4 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("foobar") + expect(ctx.textarea.cursorOffset).toBe(3) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "... ", linewise: false }) + }) + + test("daw deletes word and following whitespace", () => { + const ctx = createHandler("hello world test") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello test") + expect(ctx.textarea.cursorOffset).toBe(6) + expect(ctx.state.register()).toEqual({ text: "world ", linewise: false }) + }) + + test("daw deletes leading whitespace for the final word", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.register()).toEqual({ text: " world", linewise: false }) + }) + + test("caw changes word and following whitespace", () => { + const ctx = createHandler("hello world test") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello test") + expect(ctx.textarea.cursorOffset).toBe(6) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "world ", linewise: false }) + }) + + test("yiw yanks inner word", () => { + const ctx = createHandler("hello world test") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("y").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello world test") + expect(ctx.state.register()).toEqual({ text: "world", linewise: false }) + }) + + test("diw deletes middle whitespace run", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("helloworld") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.register()).toEqual({ text: " ", linewise: false }) + }) + + test("diw deletes trailing whitespace run", () => { + const ctx = createHandler("hello world ") + ctx.textarea.cursorOffset = 12 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello world") + expect(ctx.textarea.cursorOffset).toBe(11) + expect(ctx.state.register()).toEqual({ text: " ", linewise: false }) + }) + + test("ciw changes trailing whitespace run", () => { + const ctx = createHandler("hello world ") + ctx.textarea.cursorOffset = 12 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello world") + expect(ctx.textarea.cursorOffset).toBe(11) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: " ", linewise: false }) + }) + + test("diw deletes trailing whitespace run before line end", () => { + const ctx = createHandler("hello \nworld") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello\nworld") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.register()).toEqual({ text: " ", linewise: false }) + }) + + test("diw on newline does not join lines", () => { + const ctx = createHandler("hello\nworld") + ctx.textarea.cursorOffset = 5 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello\nworld") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.register()).toBeNull() + }) + + test("ciw on newline does not enter insert", () => { + const ctx = createHandler("hello\nworld") + ctx.textarea.cursorOffset = 5 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello\nworld") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.mode()).toBe("normal") + expect(ctx.state.register()).toBeNull() + }) + + test("yiw on newline does not yank", () => { + const ctx = createHandler("hello\nworld") + ctx.textarea.cursorOffset = 5 + + ctx.handler.handleKey(createEvent("y").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello\nworld") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.register()).toBeNull() + }) + + test("yiw yanks trailing whitespace run", () => { + const ctx = createHandler("hello world ") + ctx.textarea.cursorOffset = 12 + + ctx.handler.handleKey(createEvent("y").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello world ") + expect(ctx.state.register()).toEqual({ text: " ", linewise: false }) + }) + + test("yaw yanks word and following whitespace", () => { + const ctx = createHandler("hello world test") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("y").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello world test") + expect(ctx.state.register()).toEqual({ text: "world ", linewise: false }) + }) + + test("daw deletes middle whitespace with following word", () => { + const ctx = createHandler("hello world test") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hellotest") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.register()).toEqual({ text: " world ", linewise: false }) + }) + + test("daw does not delete newline after word", () => { + const ctx = createHandler("hello\nworld") + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("\nworld") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.register()).toEqual({ text: "hello", linewise: false }) + }) + + test("daw does not delete newline before word", () => { + const ctx = createHandler("hello\n world") + ctx.textarea.cursorOffset = 9 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello\n") + expect(ctx.textarea.cursorOffset).toBe(6) + expect(ctx.state.register()).toEqual({ text: " world", linewise: false }) + }) + + test("text object pending display shows operator and object scope", () => { + const ctx = createHandler("hello world") + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("i").event) + + expect(ctx.state.pending()).toBe("c") + expect(ctx.state.pendingDisplay()).toBe("ci") + }) + + test("text object invalid target clears pending", () => { + const ctx = createHandler("hello world") + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + const invalid = createEvent("x") + expect(ctx.handler.handleKey(invalid.event)).toBe(true) + expect(invalid.prevented()).toBe(true) + + expect(ctx.textarea.plainText).toBe("hello world") + expect(ctx.state.pending()).toBe("") + expect(ctx.state.pendingDisplay()).toBe("") + }) + test("de deletes to end of word and clears pending", () => { const ctx = createHandler("hello world test") ctx.textarea.cursorOffset = 0 @@ -3746,11 +4032,11 @@ describe("vim motion handler", () => { expect(ctx.handler.handleKey(createEvent("d").event)).toBe(true) expect(ctx.state.pending()).toBe("d") - const i = createEvent("i") - expect(ctx.handler.handleKey(i.event)).toBe(true) - expect(i.prevented()).toBe(true) + const q = createEvent("q") + expect(ctx.handler.handleKey(q.event)).toBe(true) + expect(q.prevented()).toBe(true) expect(ctx.state.pending()).toBe("") - expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.mode()).toBe("normal") }) test("mode switch clears pending state", () => { @@ -3758,7 +4044,7 @@ describe("vim motion handler", () => { expect(ctx.handler.handleKey(createEvent("d").event)).toBe(true) expect(ctx.state.pending()).toBe("d") - expect(ctx.handler.handleKey(createEvent("i").event)).toBe(true) + expect(ctx.handler.handleKey(createEvent("o").event)).toBe(true) expect(ctx.state.mode()).toBe("insert") expect(ctx.state.pending()).toBe("") @@ -5129,6 +5415,21 @@ describe("vim dot repeat", () => { expect(ctx.textarea.plainText).toBe("hi hi") }) + test("dot repeats ciw inserted text", () => { + const ctx = createHandler("hello world") + + press(ctx, "c") + press(ctx, "i") + press(ctx, "w") + ctx.textarea.insertText("hi") + press(ctx, "escape") + expect(ctx.textarea.plainText).toBe("hi world") + + ctx.textarea.cursorOffset = 3 + press(ctx, ".") + expect(ctx.textarea.plainText).toBe("hi hi") + }) + test("dot repeats s inserted text", () => { const ctx = createHandler("abc def")