From 048e7cebf03194f1d067a1f1dd938d6c6dfe3bef Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sun, 17 May 2026 22:17:19 +0800 Subject: [PATCH 01/10] feat: add delete find operators --- .../cli/cmd/tui/component/vim/vim-handler.ts | 74 +++++++++ .../opencode/test/cli/tui/vim-motions.test.ts | 145 ++++++++++++++++++ 2 files changed, 219 insertions(+) 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 8d41cfeea510..e16aabf8d9ee 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 @@ -117,6 +117,7 @@ export function createVimHandler(input: { setRegister?: (register: VimRegister, notify?: boolean) => void }) { let wantedColumn: VimWantedColumn | undefined + let pendingOperatorFind: { operation: VimOperator; find: "f" | "F" | "t" | "T" } | undefined function hasModifier(event: VimEvent) { return !!event.ctrl || !!event.meta || !!event.super @@ -326,6 +327,49 @@ export function createVimHandler(input: { return false } + function findOperation(char: string, forward: boolean, till: boolean) { + const textarea = input.textarea() + const start = textarea.cursorOffset + const text = textarea.plainText + const boundary = forward ? text.indexOf("\n", start) : text.lastIndexOf("\n", start - 1) + const end = forward ? (boundary === -1 ? text.length : boundary) : boundary + 1 + + if (forward) { + const target = text.indexOf(char, start + 1) + if (target === -1 || target >= end) return charwiseOperation(null) + const spanEnd = till ? target : target + 1 + return charwiseOperation(spanEnd > start ? { start, end: spanEnd } : null) + } + + const target = text.lastIndexOf(char, start - 1) + if (target < end) return charwiseOperation(null) + const spanStart = till ? target + 1 : target + return charwiseOperation(spanStart < start + 1 ? { start: spanStart, end: start + 1 } : null) + } + + function pendingFindOperator(event: VimEvent): boolean { + if (!pendingOperatorFind) return false + if (input.state.pending() !== pendingOperatorFind.find) { + pendingOperatorFind = undefined + return false + } + if (isPrintable(event) && !hasModifier(event)) { + const forward = pendingOperatorFind.find === "f" || pendingOperatorFind.find === "t" + const till = pendingOperatorFind.find === "t" || pendingOperatorFind.find === "T" + const char = value(event) + const operation = pendingOperatorFind.operation + pendingOperatorFind = undefined + applyOperatorResult(() => findOperation(char, forward, till), operation) + input.state.setLastFind({ char, forward, till }) + event.preventDefault() + return true + } + pendingOperatorFind = undefined + input.state.clearPending() + event.preventDefault() + return true + } + function undo() { if (!tracked()) return false const next = input.state.undo(snapshot()) @@ -373,6 +417,8 @@ export function createVimHandler(input: { return true } + if (pendingFindOperator(event)) return true + if (input.state.pending() === "vr" && input.state.isVisual()) { if (hasModifier(event)) { input.state.clearPending() @@ -653,6 +699,34 @@ export function createVimHandler(input: { return true } + if (key === "f" && !event.shift && !hasModifier(event)) { + pendingOperatorFind = { operation: "d", find: "f" } + input.state.setPending("f") + event.preventDefault() + return true + } + + if (isShifted(event, "f") && !hasModifier(event)) { + pendingOperatorFind = { operation: "d", find: "F" } + input.state.setPending("F") + event.preventDefault() + return true + } + + if (key === "t" && !event.shift && !hasModifier(event)) { + pendingOperatorFind = { operation: "d", find: "t" } + input.state.setPending("t") + event.preventDefault() + return true + } + + if (isShifted(event, "t") && !hasModifier(event)) { + pendingOperatorFind = { operation: "d", find: "T" } + input.state.setPending("T") + event.preventDefault() + return true + } + input.state.clearPending() } diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 7f752a2beffd..9890e5b5c77a 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -2915,6 +2915,151 @@ describe("vim motion handler", () => { expect(ctx.handler.handleKey(w.event)).toBe(true) expect(ctx.textarea.cursorOffset).toBe(7) }) + + test("df deletes forward including found char", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("f").event) + expect(ctx.state.pending()).toBe("f") + + const o = createEvent("o") + expect(ctx.handler.handleKey(o.event)).toBe(true) + expect(o.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe(" world") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.register()).toEqual({ text: "hello", linewise: false }) + expect(ctx.state.pending()).toBe("") + }) + + test("dF deletes backward including found char", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("F").event) + expect(ctx.state.pending()).toBe("F") + + const o = createEvent("o") + expect(ctx.handler.handleKey(o.event)).toBe(true) + expect(o.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe("hello wld") + expect(ctx.textarea.cursorOffset).toBe(7) + expect(ctx.state.register()).toEqual({ text: "or", linewise: false }) + expect(ctx.state.pending()).toBe("") + }) + + test("dt deletes forward up to found char", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("t").event) + expect(ctx.state.pending()).toBe("t") + + const o = createEvent("o") + expect(ctx.handler.handleKey(o.event)).toBe(true) + expect(o.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe("o world") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.register()).toEqual({ text: "hell", linewise: false }) + expect(ctx.state.pending()).toBe("") + }) + + test("dT deletes backward from after found char", () => { + const ctx = createHandler("abcxdefgh") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("T").event) + expect(ctx.state.pending()).toBe("T") + + const x = createEvent("x") + expect(ctx.handler.handleKey(x.event)).toBe(true) + expect(x.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe("abcxgh") + expect(ctx.textarea.cursorOffset).toBe(4) + expect(ctx.state.register()).toEqual({ text: "def", linewise: false }) + expect(ctx.state.pending()).toBe("") + }) + + test("df not found leaves text unchanged", () => { + const ctx = createHandler("hello") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("f").event) + ctx.handler.handleKey(createEvent("z").event) + expect(ctx.textarea.plainText).toBe("hello") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.pending()).toBe("") + }) + + test("df handles uppercase target char", () => { + const ctx = createHandler("hello World") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("f").event) + ctx.handler.handleKey(createEvent("W").event) + expect(ctx.textarea.plainText).toBe("orld") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.register()).toEqual({ text: "hello W", linewise: false }) + }) + + test("df ignores stale operator find after pending is cleared", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("f").event) + ctx.state.clearPending() + + ctx.handler.handleKey(createEvent("G").event) + expect(ctx.textarea.plainText).toBe("hello world") + expect(ctx.jumpCalls).toEqual(["bottom"]) + }) + + test("df undo restores original cursor", () => { + const ctx = createHandler("abc def ghi") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("t").event) + ctx.handler.handleKey(createEvent("f").event) + expect(ctx.textarea.plainText).toBe("f ghi") + + ctx.handler.handleKey(createEvent("u").event) + expect(ctx.textarea.plainText).toBe("abc def ghi") + expect(ctx.textarea.cursorOffset).toBe(0) + }) + + test("df stays on current line", () => { + const ctx = createHandler("abc\ndef") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("f").event) + ctx.handler.handleKey(createEvent("d").event) + expect(ctx.textarea.plainText).toBe("abc\ndef") + expect(ctx.textarea.cursorOffset).toBe(0) + }) + + test("dot repeats df from current cursor", () => { + const ctx = createHandler("a-b-c") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("f").event) + ctx.handler.handleKey(createEvent("-").event) + expect(ctx.textarea.plainText).toBe("b-c") + + ctx.handler.handleKey(createEvent(".").event) + expect(ctx.textarea.plainText).toBe("c") + expect(ctx.textarea.cursorOffset).toBe(0) + }) + test("yy yanks current line into register", () => { const ctx = createHandler("one\ntwo\nthree") ctx.textarea.cursorOffset = 5 From e0d3de54e429e6d99bebe38d6d8839bdae033173 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sun, 17 May 2026 22:31:07 +0800 Subject: [PATCH 02/10] test: cover delete find edge cases --- .../opencode/test/cli/tui/vim-motions.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 9890e5b5c77a..d14303f852a5 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -2996,6 +2996,17 @@ describe("vim motion handler", () => { expect(ctx.state.pending()).toBe("") }) + test("df not found preserves register", () => { + const ctx = createHandler("hello") + ctx.state.setRegister({ text: "kept", linewise: false }) + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("f").event) + ctx.handler.handleKey(createEvent("z").event) + expect(ctx.state.register()).toEqual({ text: "kept", linewise: false }) + }) + test("df handles uppercase target char", () => { const ctx = createHandler("hello World") ctx.textarea.cursorOffset = 0 @@ -3046,6 +3057,45 @@ describe("vim motion handler", () => { expect(ctx.textarea.cursorOffset).toBe(0) }) + test("dF stays on current line", () => { + const ctx = createHandler("abc\nxdef") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("F").event) + ctx.handler.handleKey(createEvent("c").event) + expect(ctx.textarea.plainText).toBe("abc\nxdef") + expect(ctx.textarea.cursorOffset).toBe(6) + expect(ctx.state.pending()).toBe("") + }) + + test("dT stays on current line", () => { + const ctx = createHandler("abc\nxdef") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("T").event) + ctx.handler.handleKey(createEvent("c").event) + expect(ctx.textarea.plainText).toBe("abc\nxdef") + expect(ctx.textarea.cursorOffset).toBe(6) + expect(ctx.state.pending()).toBe("") + }) + + test("df cancels pending operator find on modified target", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("f").event) + + const x = createEvent("x", { ctrl: true }) + expect(ctx.handler.handleKey(x.event)).toBe(true) + expect(x.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe("hello world") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.pending()).toBe("") + }) + test("dot repeats df from current cursor", () => { const ctx = createHandler("a-b-c") ctx.textarea.cursorOffset = 0 From 9e2f2eb42afdce83a0d09ecbfd951eb33bcda1c6 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sun, 17 May 2026 22:33:23 +0800 Subject: [PATCH 03/10] fix: ignore current char for backward find delete --- .../src/cli/cmd/tui/component/vim/vim-handler.ts | 2 +- packages/opencode/test/cli/tui/vim-motions.test.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) 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 e16aabf8d9ee..a6c0212ade36 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 @@ -341,7 +341,7 @@ export function createVimHandler(input: { return charwiseOperation(spanEnd > start ? { start, end: spanEnd } : null) } - const target = text.lastIndexOf(char, start - 1) + const target = start === 0 ? -1 : text.lastIndexOf(char, start - 1) if (target < end) return charwiseOperation(null) const spanStart = till ? target + 1 : target return charwiseOperation(spanStart < start + 1 ? { start: spanStart, end: start + 1 } : null) diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index d14303f852a5..240bdb7796a9 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -3069,6 +3069,18 @@ describe("vim motion handler", () => { expect(ctx.state.pending()).toBe("") }) + test("dF at start of buffer ignores current char", () => { + const ctx = createHandler("abc") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("F").event) + ctx.handler.handleKey(createEvent("a").event) + expect(ctx.textarea.plainText).toBe("abc") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.pending()).toBe("") + }) + test("dT stays on current line", () => { const ctx = createHandler("abc\nxdef") ctx.textarea.cursorOffset = 6 From 8c654c92f489b501295b7974337741b35923136e Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sun, 17 May 2026 22:50:18 +0800 Subject: [PATCH 04/10] refactor: share find target lookup --- .../cli/cmd/tui/component/vim/vim-handler.ts | 22 +++++--- .../cli/cmd/tui/component/vim/vim-motions.ts | 55 +++++++++---------- 2 files changed, 39 insertions(+), 38 deletions(-) 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 a6c0212ade36..b43a658df030 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 @@ -16,6 +16,7 @@ import { deleteUnderCursor, findChar, findCharInLine, + findCharTargetInLine, firstNonWhitespace, getLineColumn, insertLineStart, @@ -330,20 +331,23 @@ export function createVimHandler(input: { function findOperation(char: string, forward: boolean, till: boolean) { const textarea = input.textarea() const start = textarea.cursorOffset - const text = textarea.plainText - const boundary = forward ? text.indexOf("\n", start) : text.lastIndexOf("\n", start - 1) - const end = forward ? (boundary === -1 ? text.length : boundary) : boundary + 1 + const lineStart = textarea.plainText.lastIndexOf("\n", start - 1) + 1 + const lineEnd = textarea.plainText.indexOf("\n", start) + const target = findCharTargetInLine( + textarea.plainText.slice(lineStart, lineEnd === -1 ? textarea.plainText.length : lineEnd), + start - lineStart, + char, + forward, + ) + if (target === null) return charwiseOperation(null) + const offset = lineStart + target if (forward) { - const target = text.indexOf(char, start + 1) - if (target === -1 || target >= end) return charwiseOperation(null) - const spanEnd = till ? target : target + 1 + const spanEnd = till ? offset : offset + 1 return charwiseOperation(spanEnd > start ? { start, end: spanEnd } : null) } - const target = start === 0 ? -1 : text.lastIndexOf(char, start - 1) - if (target < end) return charwiseOperation(null) - const spanStart = till ? target + 1 : target + const spanStart = till ? offset + 1 : offset return charwiseOperation(spanStart < start + 1 ? { start: spanStart, end: start + 1 } : null) } 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 182b9d4947e6..ff763ca4aa1d 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 @@ -461,6 +461,19 @@ export function firstNonWhitespace(text: string, offset: number) { return pos } +export function findCharTargetInLine(text: string, offset: number, char: string, forward: boolean, skip = 1) { + if (forward) { + for (let i = offset + skip; i < text.length; i++) { + if (text[i] === char) return i + } + return null + } + for (let i = offset - skip; i >= 0; i--) { + if (text[i] === char) return i + } + return null +} + export function findCharInLine( text: string, offset: number, @@ -469,17 +482,9 @@ export function findCharInLine( till = false, repeat = false, ) { - const skip = till && repeat ? 2 : 1 - if (forward) { - for (let i = offset + skip; i < text.length; i++) { - if (text[i] === char) return till ? i - 1 : i - } - } else { - for (let i = offset - skip; i >= 0; i--) { - if (text[i] === char) return till ? i + 1 : i - } - } - return offset + const target = findCharTargetInLine(text, offset, char, forward, till && repeat ? 2 : 1) + if (target === null) return offset + return till ? target + (forward ? -1 : 1) : target } export function copyWordNext(rows: VimCopyRow[], get: (idx: number) => string, idx: number, col: number, big: boolean) { @@ -714,24 +719,16 @@ export function deleteSpan(textarea: TextareaRenderable, span: VimSpan | null): export function findChar(textarea: TextareaRenderable, char: string, forward: boolean, till = false, repeat = false) { const text = textarea.plainText const offset = textarea.cursorOffset - const skip = till && repeat ? 2 : 1 - if (forward) { - const end = lineEnd(text, offset) - for (let i = offset + skip; i < end; i++) { - if (text[i] === char) { - textarea.cursorOffset = till ? i - 1 : i - return - } - } - } else { - const start = lineStart(text, offset) - for (let i = offset - skip; i >= start; i--) { - if (text[i] === char) { - textarea.cursorOffset = till ? i + 1 : i - return - } - } - } + const start = lineStart(text, offset) + const target = findCharTargetInLine( + text.slice(start, lineEnd(text, offset)), + offset - start, + char, + forward, + till && repeat ? 2 : 1, + ) + if (target === null) return + textarea.cursorOffset = start + target + (till ? (forward ? -1 : 1) : 0) } export function joinLines(textarea: TextareaRenderable) { From 8950ca0684196643a5f95af74a8e0dc0ec7c5018 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sun, 17 May 2026 22:51:38 +0800 Subject: [PATCH 05/10] refactor: share find operator setup --- .../cli/cmd/tui/component/vim/vim-handler.ts | 35 ++++++------------- 1 file changed, 11 insertions(+), 24 deletions(-) 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 b43a658df030..c911404c19e0 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 @@ -351,6 +351,13 @@ export function createVimHandler(input: { return charwiseOperation(spanStart < start + 1 ? { start: spanStart, end: start + 1 } : null) } + function startOperatorFind(event: VimEvent, operation: VimOperator, find: "f" | "F" | "t" | "T") { + pendingOperatorFind = { operation, find } + input.state.setPending(find) + event.preventDefault() + return true + } + function pendingFindOperator(event: VimEvent): boolean { if (!pendingOperatorFind) return false if (input.state.pending() !== pendingOperatorFind.find) { @@ -703,33 +710,13 @@ export function createVimHandler(input: { return true } - if (key === "f" && !event.shift && !hasModifier(event)) { - pendingOperatorFind = { operation: "d", find: "f" } - input.state.setPending("f") - event.preventDefault() - return true - } + if (key === "f" && !event.shift && !hasModifier(event)) return startOperatorFind(event, "d", "f") - if (isShifted(event, "f") && !hasModifier(event)) { - pendingOperatorFind = { operation: "d", find: "F" } - input.state.setPending("F") - event.preventDefault() - return true - } + if (isShifted(event, "f") && !hasModifier(event)) return startOperatorFind(event, "d", "F") - if (key === "t" && !event.shift && !hasModifier(event)) { - pendingOperatorFind = { operation: "d", find: "t" } - input.state.setPending("t") - event.preventDefault() - return true - } + if (key === "t" && !event.shift && !hasModifier(event)) return startOperatorFind(event, "d", "t") - if (isShifted(event, "t") && !hasModifier(event)) { - pendingOperatorFind = { operation: "d", find: "T" } - input.state.setPending("T") - event.preventDefault() - return true - } + if (isShifted(event, "t") && !hasModifier(event)) return startOperatorFind(event, "d", "T") input.state.clearPending() } From 188856c1468bd6e6a5313ce8403d30d8af9da96d Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sun, 17 May 2026 22:52:57 +0800 Subject: [PATCH 06/10] refactor: share find operator helpers --- .../opencode/src/cli/cmd/tui/component/vim/vim-handler.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 c911404c19e0..835b3747d48f 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 @@ -76,6 +76,7 @@ export type VimEvent = { } export type VimCopyMove = "up" | "down" | "left" | "right" +type VimFindOperator = "f" | "F" | "t" | "T" export function createVimHandler(input: { enabled: Accessor @@ -118,7 +119,7 @@ export function createVimHandler(input: { setRegister?: (register: VimRegister, notify?: boolean) => void }) { let wantedColumn: VimWantedColumn | undefined - let pendingOperatorFind: { operation: VimOperator; find: "f" | "F" | "t" | "T" } | undefined + let pendingOperatorFind: { operation: VimOperator; find: VimFindOperator } | undefined function hasModifier(event: VimEvent) { return !!event.ctrl || !!event.meta || !!event.super @@ -351,7 +352,7 @@ export function createVimHandler(input: { return charwiseOperation(spanStart < start + 1 ? { start: spanStart, end: start + 1 } : null) } - function startOperatorFind(event: VimEvent, operation: VimOperator, find: "f" | "F" | "t" | "T") { + function startOperatorFind(event: VimEvent, operation: VimOperator, find: VimFindOperator) { pendingOperatorFind = { operation, find } input.state.setPending(find) event.preventDefault() From d0ed96a2d1ba58e1ff0abebf8ca91a6edd95bf31 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sun, 17 May 2026 22:54:50 +0800 Subject: [PATCH 07/10] docs: document new motions --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5a6785c85aa1..abb0748bc21e 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Toggle via command palette (`Ctrl+p` -> `Toggle vim mode`). **Editing** -`i` `I` `a` `A` `o` `O` `R` `r` `x` `~` `dd` `dw` `db` `d%` `d}` `d{` `cc` `cw` `cb` `C` `c%` `c}` `c{` `S` `J` +`i` `I` `a` `A` `o` `O` `R` `r` `x` `~` `dd` `dw` `db` `df` `dF` `dt` `dT` `d%` `d}` `d{` `cc` `cw` `cb` `C` `c%` `c}` `c{` `S` `J` **yank / put / undo / repeat** From 1b7d7f4b02a9974f8be86500d5a67bbd0704a60a Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sun, 17 May 2026 22:59:15 +0800 Subject: [PATCH 08/10] fix: show full operator find pending indicator --- .../cli/cmd/tui/component/vim/vim-handler.ts | 2 +- .../cmd/tui/component/vim/vim-indicator.ts | 2 +- .../cli/cmd/tui/component/vim/vim-state.ts | 12 +++++-- .../test/cli/tui/vim-indicator.test.ts | 7 ++++- .../opencode/test/cli/tui/vim-motions.test.ts | 31 ++++++++++++++++--- 5 files changed, 45 insertions(+), 9 deletions(-) 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 835b3747d48f..89bb6ca04123 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 @@ -354,7 +354,7 @@ export function createVimHandler(input: { function startOperatorFind(event: VimEvent, operation: VimOperator, find: VimFindOperator) { pendingOperatorFind = { operation, find } - input.state.setPending(find) + input.state.setPending(find, operation + find) event.preventDefault() return true } diff --git a/packages/opencode/src/cli/cmd/tui/component/vim/vim-indicator.ts b/packages/opencode/src/cli/cmd/tui/component/vim/vim-indicator.ts index f7159072f0b5..7ff30c583afd 100644 --- a/packages/opencode/src/cli/cmd/tui/component/vim/vim-indicator.ts +++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-indicator.ts @@ -10,7 +10,7 @@ export function useVimIndicator(input: { return createMemo(() => { if (!input.enabled() || !input.active()) return const key = input.state.pending() - if (key && key !== "w") return key + ".." + if (key && key !== "w") return (input.state.pendingDisplay() || key) + ".." if (input.state.isCopy()) { if (input.copyVisual?.() === "char") return "-- V-COPY --" if (input.copyVisual?.() === "line") return "-- VL-COPY --" diff --git a/packages/opencode/src/cli/cmd/tui/component/vim/vim-state.ts b/packages/opencode/src/cli/cmd/tui/component/vim/vim-state.ts index ced395c4f1e7..70cd784fe777 100644 --- a/packages/opencode/src/cli/cmd/tui/component/vim/vim-state.ts +++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-state.ts @@ -14,7 +14,8 @@ type VimHistory = { export function createVimState(input: { enabled: Accessor; initial?: Accessor }) { const [mode, setMode] = createSignal(input.initial?.() ?? "insert") - const [pending, setPending] = createSignal("") + const [pending, setPendingValue] = createSignal("") + const [pendingDisplay, setPendingDisplay] = createSignal("") const [lastFind, setLastFind] = createSignal(null) const [register, setRegister] = createSignal(null) const [anchor, setAnchor] = createSignal(null) @@ -29,8 +30,14 @@ export function createVimState(input: { enabled: Accessor; initial?: Ac const [exitScrollToBottom, setExitScrollToBottom] = createSignal(true) const cancelEditCallbacks = new Set<() => void>() + function setPending(next: VimPending, display = "") { + setPendingValue(next) + setPendingDisplay(display) + } + function clearPending() { - if (pending()) setPending("") + if (pending()) setPendingValue("") + if (pendingDisplay()) setPendingDisplay("") } function clearEdit() { @@ -81,6 +88,7 @@ export function createVimState(input: { enabled: Accessor; initial?: Ac mode, setMode: changeMode, pending, + pendingDisplay, setPending, clearPending, lastFind, diff --git a/packages/opencode/test/cli/tui/vim-indicator.test.ts b/packages/opencode/test/cli/tui/vim-indicator.test.ts index 377790370f9e..c3b160a30e46 100644 --- a/packages/opencode/test/cli/tui/vim-indicator.test.ts +++ b/packages/opencode/test/cli/tui/vim-indicator.test.ts @@ -8,6 +8,7 @@ function label(opts?: { active?: boolean mode?: VimMode pending?: VimPending + pendingDisplay?: string copy?: undefined | "char" | "line" }) { return createRoot((dispose) => { @@ -20,7 +21,7 @@ function label(opts?: { }) if (opts?.mode && opts.mode !== "normal") state.setMode(opts.mode) - if (opts?.pending) state.setPending(opts.pending) + if (opts?.pending) state.setPending(opts.pending, opts.pendingDisplay) const result = useVimIndicator({ enabled, @@ -44,6 +45,10 @@ describe("vim indicator", () => { expect(label({ mode: "copy", pending: "z" })).toBe("z..") }) + test("shows pending display when present", () => { + expect(label({ pending: "f", pendingDisplay: "df" })).toBe("df..") + }) + test("shows copy label when no key is pending", () => { expect(label({ mode: "copy" })).toBe("COPY") }) diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 240bdb7796a9..17f74d0f0709 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -173,9 +173,10 @@ function createHandler( const [mode, setMode] = createSignal<"normal" | "insert" | "replace" | "visual" | "visual-line" | "copy">( options?.mode ?? "normal", ) - const [pending, setPending] = createSignal< + const [pending, setPendingValue] = createSignal< "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w" | "r" | "vr" >("") + const [pendingDisplay, setPendingDisplay] = createSignal("") const [lastFind, setLastFind] = createSignal<{ char: string; forward: boolean; till: boolean } | null>(null) const [register, setRegister] = createSignal<{ text: string; linewise: boolean } | null>(null) const [anchor, setAnchor] = createSignal(null) @@ -214,8 +215,17 @@ function createHandler( let copyExitPreserveScrolls = 0 let copyFocusInputs = 0 + function setPending( + next: "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w" | "r" | "vr", + display = "", + ) { + setPendingValue(next) + setPendingDisplay(display) + } + function clearPending() { - setPending("") + setPendingValue("") + setPendingDisplay("") } function changeMode(next: "normal" | "insert" | "replace" | "visual" | "visual-line" | "copy") { @@ -237,6 +247,7 @@ function createHandler( mode, setMode: changeMode, pending, + pendingDisplay, setPending, clearPending, lastFind, @@ -2923,6 +2934,7 @@ describe("vim motion handler", () => { ctx.handler.handleKey(createEvent("d").event) ctx.handler.handleKey(createEvent("f").event) expect(ctx.state.pending()).toBe("f") + expect(ctx.state.pendingDisplay()).toBe("df") const o = createEvent("o") expect(ctx.handler.handleKey(o.event)).toBe(true) @@ -6986,9 +6998,10 @@ describe("copy mode cursor state", () => { const textarea = createTextarea("") const [enabled] = createSignal(true) const [mode, setMode] = createSignal<"normal" | "insert" | "replace" | "visual" | "visual-line" | "copy">("copy") - const [pending, setPending] = createSignal< + const [pending, setPendingValue] = createSignal< "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w" | "r" | "vr" >("") + const [pendingDisplay, setPendingDisplay] = createSignal("") const [lastFind, setLastFind] = createSignal<{ char: string; forward: boolean; till: boolean } | null>(null) const [register, setRegister] = createSignal<{ text: string; linewise: boolean } | null>(null) const [anchor, setAnchor] = createSignal(null) @@ -7029,8 +7042,17 @@ describe("copy mode cursor state", () => { return row.min } + function setPending( + next: "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w" | "r" | "vr", + display = "", + ) { + setPendingValue(next) + setPendingDisplay(display) + } + function clearPending() { - setPending("") + setPendingValue("") + setPendingDisplay("") } function changeMode(next: "normal" | "insert" | "replace" | "visual" | "visual-line" | "copy") { @@ -7052,6 +7074,7 @@ describe("copy mode cursor state", () => { mode, setMode: changeMode, pending, + pendingDisplay, setPending, clearPending, lastFind, From 5805e296ff7ded8bdb861f32a563b02251f8a917 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sun, 17 May 2026 23:02:38 +0800 Subject: [PATCH 09/10] fix: exclude cursor char from backward find delete --- .../cli/cmd/tui/component/vim/vim-handler.ts | 2 +- .../opencode/test/cli/tui/vim-motions.test.ts | 24 ++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) 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 89bb6ca04123..0c12dc753931 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 @@ -349,7 +349,7 @@ export function createVimHandler(input: { } const spanStart = till ? offset + 1 : offset - return charwiseOperation(spanStart < start + 1 ? { start: spanStart, end: start + 1 } : null) + return charwiseOperation(spanStart < start ? { start: spanStart, end: start } : null) } function startOperatorFind(event: VimEvent, operation: VimOperator, find: VimFindOperator) { diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 17f74d0f0709..185c27a6772c 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -2945,7 +2945,7 @@ describe("vim motion handler", () => { expect(ctx.state.pending()).toBe("") }) - test("dF deletes backward including found char", () => { + test("dF deletes backward including found char and excluding cursor", () => { const ctx = createHandler("hello world") ctx.textarea.cursorOffset = 8 @@ -2956,9 +2956,9 @@ describe("vim motion handler", () => { const o = createEvent("o") expect(ctx.handler.handleKey(o.event)).toBe(true) expect(o.prevented()).toBe(true) - expect(ctx.textarea.plainText).toBe("hello wld") + expect(ctx.textarea.plainText).toBe("hello wrld") expect(ctx.textarea.cursorOffset).toBe(7) - expect(ctx.state.register()).toEqual({ text: "or", linewise: false }) + expect(ctx.state.register()).toEqual({ text: "o", linewise: false }) expect(ctx.state.pending()).toBe("") }) @@ -2979,7 +2979,7 @@ describe("vim motion handler", () => { expect(ctx.state.pending()).toBe("") }) - test("dT deletes backward from after found char", () => { + test("dT deletes backward from after found char and excludes cursor", () => { const ctx = createHandler("abcxdefgh") ctx.textarea.cursorOffset = 6 @@ -2990,9 +2990,9 @@ describe("vim motion handler", () => { const x = createEvent("x") expect(ctx.handler.handleKey(x.event)).toBe(true) expect(x.prevented()).toBe(true) - expect(ctx.textarea.plainText).toBe("abcxgh") + expect(ctx.textarea.plainText).toBe("abcxfgh") expect(ctx.textarea.cursorOffset).toBe(4) - expect(ctx.state.register()).toEqual({ text: "def", linewise: false }) + expect(ctx.state.register()).toEqual({ text: "de", linewise: false }) expect(ctx.state.pending()).toBe("") }) @@ -3105,6 +3105,18 @@ describe("vim motion handler", () => { expect(ctx.state.pending()).toBe("") }) + test("dT adjacent target is a no-op", () => { + const ctx = createHandler("ab") + ctx.textarea.cursorOffset = 1 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("T").event) + ctx.handler.handleKey(createEvent("a").event) + expect(ctx.textarea.plainText).toBe("ab") + expect(ctx.textarea.cursorOffset).toBe(1) + expect(ctx.state.pending()).toBe("") + }) + test("df cancels pending operator find on modified target", () => { const ctx = createHandler("hello world") ctx.textarea.cursorOffset = 0 From 941dc3fcd406e8f6966d5f80f6935cc8756077bd Mon Sep 17 00:00:00 2001 From: Leo Henon <77656081+leohenon@users.noreply.github.com> Date: Sun, 17 May 2026 23:15:52 +0800 Subject: [PATCH 10/10] feat: add change find operators (#138) * feat: add change find operators * docs: document change find motions * refactor: share operator find dispatch --------- Co-authored-by: leohenon <77656081+lhenon999@users.noreply.github.com> --- README.md | 2 +- .../cli/cmd/tui/component/vim/vim-handler.ts | 18 +-- .../opencode/test/cli/tui/vim-motions.test.ts | 111 ++++++++++++++++++ 3 files changed, 123 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index abb0748bc21e..b81313e7e0bc 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ 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` `C` `c%` `c}` `c{` `S` `J` +`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` `J` **yank / put / undo / repeat** 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 0c12dc753931..9269cd383d83 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 @@ -359,6 +359,14 @@ export function createVimHandler(input: { return true } + function operatorFind(event: VimEvent, key: string, operation: VimOperator) { + if (key === "f" && !event.shift && !hasModifier(event)) return startOperatorFind(event, operation, "f") + if (isShifted(event, "f") && !hasModifier(event)) return startOperatorFind(event, operation, "F") + if (key === "t" && !event.shift && !hasModifier(event)) return startOperatorFind(event, operation, "t") + if (isShifted(event, "t") && !hasModifier(event)) return startOperatorFind(event, operation, "T") + return false + } + function pendingFindOperator(event: VimEvent): boolean { if (!pendingOperatorFind) return false if (input.state.pending() !== pendingOperatorFind.find) { @@ -677,6 +685,8 @@ export function createVimHandler(input: { return true } + if (operatorFind(event, key, "c")) return true + input.state.clearPending() } @@ -711,13 +721,7 @@ export function createVimHandler(input: { return true } - if (key === "f" && !event.shift && !hasModifier(event)) return startOperatorFind(event, "d", "f") - - if (isShifted(event, "f") && !hasModifier(event)) return startOperatorFind(event, "d", "F") - - if (key === "t" && !event.shift && !hasModifier(event)) return startOperatorFind(event, "d", "t") - - if (isShifted(event, "t") && !hasModifier(event)) return startOperatorFind(event, "d", "T") + if (operatorFind(event, key, "d")) return true input.state.clearPending() } diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 185c27a6772c..e04462e96f11 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -3146,6 +3146,117 @@ describe("vim motion handler", () => { expect(ctx.textarea.cursorOffset).toBe(0) }) + test("cf changes forward including found char", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("f").event) + expect(ctx.state.pending()).toBe("f") + expect(ctx.state.pendingDisplay()).toBe("cf") + + const o = createEvent("o") + expect(ctx.handler.handleKey(o.event)).toBe(true) + expect(o.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe(" world") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "hello", linewise: false }) + expect(ctx.state.pending()).toBe("") + }) + + test("cF changes backward including found char and excluding cursor", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("F").event) + expect(ctx.state.pendingDisplay()).toBe("cF") + + const o = createEvent("o") + expect(ctx.handler.handleKey(o.event)).toBe(true) + expect(ctx.textarea.plainText).toBe("hello wrld") + expect(ctx.textarea.cursorOffset).toBe(7) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "o", linewise: false }) + }) + + test("ct changes forward up to found char", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("t").event) + expect(ctx.state.pendingDisplay()).toBe("ct") + + const o = createEvent("o") + expect(ctx.handler.handleKey(o.event)).toBe(true) + expect(ctx.textarea.plainText).toBe("o world") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "hell", linewise: false }) + }) + + test("cT changes backward from after found char and excludes cursor", () => { + const ctx = createHandler("abcxdefgh") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("T").event) + expect(ctx.state.pendingDisplay()).toBe("cT") + + const x = createEvent("x") + expect(ctx.handler.handleKey(x.event)).toBe(true) + expect(ctx.textarea.plainText).toBe("abcxfgh") + expect(ctx.textarea.cursorOffset).toBe(4) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "de", linewise: false }) + }) + + test("cf not found leaves mode and register unchanged", () => { + const ctx = createHandler("hello") + ctx.state.setRegister({ text: "kept", linewise: false }) + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("f").event) + ctx.handler.handleKey(createEvent("z").event) + expect(ctx.textarea.plainText).toBe("hello") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.mode()).toBe("normal") + expect(ctx.state.register()).toEqual({ text: "kept", linewise: false }) + expect(ctx.state.pending()).toBe("") + }) + + test("cT adjacent target is a no-op", () => { + const ctx = createHandler("ab") + ctx.textarea.cursorOffset = 1 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("T").event) + ctx.handler.handleKey(createEvent("a").event) + expect(ctx.textarea.plainText).toBe("ab") + expect(ctx.textarea.cursorOffset).toBe(1) + expect(ctx.state.mode()).toBe("normal") + expect(ctx.state.pending()).toBe("") + }) + + test("dot repeats cf inserted text", () => { + const ctx = createHandler("a-b-c") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("f").event) + ctx.handler.handleKey(createEvent("-").event) + ctx.textarea.insertText("x") + ctx.handler.handleKey(createEvent("escape").event) + expect(ctx.textarea.plainText).toBe("xb-c") + + ctx.textarea.cursorOffset = 1 + ctx.handler.handleKey(createEvent(".").event) + expect(ctx.textarea.plainText).toBe("xxc") + }) + test("yy yanks current line into register", () => { const ctx = createHandler("one\ntwo\nthree") ctx.textarea.cursorOffset = 5