From 344908132d5b4da541c13bfd8c538f5568e73016 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sun, 17 May 2026 21:51:28 +0800 Subject: [PATCH 1/4] refactor: share operator word motion handling --- .../cli/cmd/tui/component/vim/vim-handler.ts | 149 ++++++------------ .../opencode/test/cli/tui/vim-motions.test.ts | 22 +++ 2 files changed, 69 insertions(+), 102 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 00696153d2d8..eb4a64021723 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 @@ -14,9 +14,6 @@ import { deleteSelection, deleteSpan, deleteUnderCursor, - deleteWord, - deleteWordBackward, - deleteWordEnd, findChar, findCharInLine, firstNonWhitespace, @@ -47,6 +44,7 @@ import { openLineBelow, type ParagraphOperation, type ParagraphResult, + type VimSpan, type VimWantedColumn, pasteAfter, pasteBefore, @@ -63,10 +61,6 @@ import { yankLine, yankLineSpan, yankSelection, - yankWord, - yankWordEnd, - yankWordEndSpan, - yankWordSpan, } from "./vim-motions" export type VimEvent = { @@ -282,21 +276,54 @@ export function createVimHandler(input: { return true } - function changeWord(big: boolean) { + function charwiseOperation(span: VimSpan | null): ParagraphResult { + if (!span) return { span: null, register: null } + return { span, register: { text: input.textarea().plainText.slice(span.start, span.end), linewise: false } } + } + + function nextWordOperation(big: boolean) { + const textarea = input.textarea() + const start = textarea.cursorOffset + const end = nextWordStart(textarea.plainText, start, big) + return charwiseOperation(end > start ? { start, end } : null) + } + + function previousWordOperation() { + const textarea = input.textarea() + const end = textarea.cursorOffset + const start = prevWordStart(textarea.plainText, end, false) + return charwiseOperation(start < end ? { start, end } : null) + } + + function wordEndOperation(big: boolean) { + const textarea = input.textarea() + const start = textarea.cursorOffset + if (start >= textarea.plainText.length) return charwiseOperation(null) + const end = wordEnd(textarea.plainText, start, big) + 1 + return charwiseOperation(end > start ? { start, end } : null) + } + + function changeWordOperation(big: boolean) { const textarea = input.textarea() const char = textarea.plainText[textarea.cursorOffset] - return char && !/\s/.test(char) ? deleteWordEnd(textarea, big) : deleteWord(textarea) + return char && !/\s/.test(char) ? wordEndOperation(big) : nextWordOperation(big) } - function beginChangeWord(result: () => VimRegister) { - begin(() => { - const reg = result() - input.state.clearPending() - if (!reg) return false - setRegister(reg) - input.state.setMode("insert") + function wordOperator(event: VimEvent, key: string, operation: ParagraphOperation): boolean { + if ((key === "w" || isShifted(event, "w")) && !hasModifier(event)) { + const big = isShifted(event, "w") + applyOperatorResult(() => (operation === "c" ? changeWordOperation(big) : nextWordOperation(big)), operation) + return true + } + if (key === "b" && !event.shift && !hasModifier(event) && operation !== "y") { + applyOperatorResult(() => previousWordOperation(), operation) return true - }) + } + if ((key === "e" || isShifted(event, "e")) && !hasModifier(event)) { + applyOperatorResult(() => wordEndOperation(isShifted(event, "e")), operation) + return true + } + return false } function undo() { @@ -594,27 +621,7 @@ export function createVimHandler(input: { return true } - if (key === "w" && !event.shift) { - beginChangeWord(() => changeWord(false)) - event.preventDefault() - return true - } - - if (isShifted(event, "w") && !hasModifier(event)) { - beginChangeWord(() => changeWord(true)) - event.preventDefault() - return true - } - - if (key === "b" && !event.shift) { - beginChangeWord(() => deleteWordBackward(input.textarea())) - event.preventDefault() - return true - } - - if ((key === "e" || key === "E") && !hasModifier(event)) { - const big = key === "E" || !!event.shift - beginChangeWord(() => deleteWordEnd(input.textarea(), big)) + if (wordOperator(event, key, "c")) { event.preventDefault() return true } @@ -648,43 +655,7 @@ export function createVimHandler(input: { return true } - if (key === "w" && !event.shift) { - edit(() => { - const reg = deleteWord(input.textarea()) - if (reg) setRegister(reg) - input.state.clearPending() - }) - event.preventDefault() - return true - } - - if (isShifted(event, "w") && !hasModifier(event)) { - edit(() => { - const reg = deleteWord(input.textarea(), true) - if (reg) setRegister(reg) - input.state.clearPending() - }) - event.preventDefault() - return true - } - - if (key === "b" && !event.shift && !hasModifier(event)) { - edit(() => { - const reg = deleteWordBackward(input.textarea()) - if (reg) setRegister(reg) - input.state.clearPending() - }) - event.preventDefault() - return true - } - - if ((key === "e" || key === "E") && !hasModifier(event)) { - const big = key === "E" || !!event.shift - edit(() => { - const reg = deleteWordEnd(input.textarea(), big) - if (reg) setRegister(reg) - input.state.clearPending() - }) + if (wordOperator(event, key, "d")) { event.preventDefault() return true } @@ -718,33 +689,7 @@ export function createVimHandler(input: { return true } - if (key === "w" && !event.shift) { - const span = yankWordSpan(input.textarea()) - const reg = yankWord(input.textarea()) - if (reg) setRegister(reg, true) - if (span && span.end > span.start) input.flash?.(span) - input.state.clearPending() - event.preventDefault() - return true - } - - if (isShifted(event, "w") && !hasModifier(event)) { - const span = yankWordSpan(input.textarea(), true) - const reg = yankWord(input.textarea(), true) - if (reg) setRegister(reg, true) - if (span && span.end > span.start) input.flash?.(span) - input.state.clearPending() - event.preventDefault() - return true - } - - if ((key === "e" || key === "E") && !hasModifier(event)) { - const big = key === "E" || !!event.shift - const span = yankWordEndSpan(input.textarea(), big) - const reg = yankWordEnd(input.textarea(), big) - if (reg) setRegister(reg, true) - if (span && span.end > span.start) input.flash?.(span) - input.state.clearPending() + if (wordOperator(event, key, "y")) { event.preventDefault() return true } diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 98009aa20581..987da6a961a7 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -2330,6 +2330,17 @@ describe("vim motion handler", () => { expect(ctx.state.register()).toEqual({ text: "foo.bar", linewise: false }) }) + test("dW handles lowercase shifted key events", () => { + const ctx = createHandler("foo.bar baz") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("d").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("db deletes to current word start", () => { const ctx = createHandler("hello world test") ctx.textarea.cursorOffset = 8 @@ -2458,6 +2469,17 @@ describe("vim motion handler", () => { expect(ctx.state.pending()).toBe("") }) + test("dE handles lowercase shifted key events", () => { + const ctx = createHandler("foo.bar baz") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("e", { 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("dE from mid big-word deletes to end of current big-word", () => { const ctx = createHandler("foo.bar baz") ctx.textarea.cursorOffset = 2 From f4f7f3b9d40f0c201a595a80d2c5cc1979e3c75e Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sun, 17 May 2026 22:00:31 +0800 Subject: [PATCH 2/4] refactor: rename operator result types --- .../cli/cmd/tui/component/vim/vim-handler.ts | 18 ++++++------ .../cli/cmd/tui/component/vim/vim-motions.ts | 28 +++++++++---------- 2 files changed, 23 insertions(+), 23 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 eb4a64021723..c2f67815d444 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 @@ -42,8 +42,8 @@ import { nextWordStart, openLineAbove, openLineBelow, - type ParagraphOperation, - type ParagraphResult, + type VimOperator, + type VimOperatorResult, type VimSpan, type VimWantedColumn, pasteAfter, @@ -216,13 +216,13 @@ export function createVimHandler(input: { const edit = repeat.edit const begin = repeat.begin - function applyOperatorYank(result: ParagraphResult) { + function applyOperatorYank(result: VimOperatorResult) { if (result.register) setRegister(result.register, true) if (result.span && result.span.end > result.span.start) input.flash?.(result.span) input.state.clearPending() } - function applyOperatorEdit(result: () => ParagraphResult, operation: "d" | "c") { + function applyOperatorEdit(result: () => VimOperatorResult, operation: "d" | "c") { const apply = () => { const next = result() if (!next.span && !next.register) { @@ -239,7 +239,7 @@ export function createVimHandler(input: { else edit(apply) } - function applyOperatorResult(result: () => ParagraphResult, operation: ParagraphOperation) { + function applyOperatorResult(result: () => VimOperatorResult, operation: VimOperator) { const initial = result() // no motion: vim no-ops the operator without editing or changing mode. @@ -254,7 +254,7 @@ export function createVimHandler(input: { applyOperatorEdit(result, operation) } - function paragraphOperator(key: string, operation: ParagraphOperation): boolean { + function paragraphOperator(key: string, operation: VimOperator): boolean { if (key !== "{" && key !== "}") return false applyOperatorResult( @@ -268,7 +268,7 @@ export function createVimHandler(input: { return true } - function matchingBracketOperator(key: string, operation: ParagraphOperation): boolean { + function matchingBracketOperator(key: string, operation: VimOperator): boolean { if (key !== "%") return false applyOperatorResult(() => matchingBracketOperation(input.textarea()), operation) @@ -276,7 +276,7 @@ export function createVimHandler(input: { return true } - function charwiseOperation(span: VimSpan | null): ParagraphResult { + function charwiseOperation(span: VimSpan | null): VimOperatorResult { if (!span) return { span: null, register: null } return { span, register: { text: input.textarea().plainText.slice(span.start, span.end), linewise: false } } } @@ -309,7 +309,7 @@ export function createVimHandler(input: { return char && !/\s/.test(char) ? wordEndOperation(big) : nextWordOperation(big) } - function wordOperator(event: VimEvent, key: string, operation: ParagraphOperation): boolean { + function wordOperator(event: VimEvent, key: string, operation: VimOperator): boolean { if ((key === "w" || isShifted(event, "w")) && !hasModifier(event)) { const big = isShifted(event, "w") applyOperatorResult(() => (operation === "c" ? changeWordOperation(big) : nextWordOperation(big)), operation) 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 bdae3253a2e8..182b9d4947e6 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 @@ -216,14 +216,14 @@ export function moveNextParagraph(textarea: TextareaRenderable) { textarea.cursorOffset = nextParagraphTarget(textarea.plainText, textarea.cursorOffset) } -export type ParagraphOperation = "d" | "c" | "y" +export type VimOperator = "d" | "c" | "y" -export type ParagraphResult = { +export type VimOperatorResult = { span: VimSpan | null register: VimRegister } -export function matchingBracketOperation(textarea: TextareaRenderable): ParagraphResult { +export function matchingBracketOperation(textarea: TextareaRenderable): VimOperatorResult { const text = textarea.plainText const cursor = textarea.cursorOffset const target = matchingBracketTarget(text, cursor) @@ -237,12 +237,12 @@ function asLinewise(slice: string): string { return slice.endsWith("\n") ? slice : slice + "\n" } -function buildParagraphResult( +function buildOperatorResult( text: string, span: VimSpan | null, registerSpan: VimSpan | null, linewise: boolean, -): ParagraphResult { +): VimOperatorResult { if (!span) return { span: null, register: null } const register = registerSpan ?? span const slice = text.slice(register.start, register.end) @@ -276,7 +276,7 @@ function classifyNextParagraph(text: string, cursor: number): NextClassification // linewise rules derived empirically from nvim: // d: line-aligned cursor + (blank target OR motion crosses lines) // y/c: line-aligned cursor AND blank target -function isLinewiseNext(c: NextClassification, op: ParagraphOperation): boolean { +function isLinewiseNext(c: NextClassification, op: VimOperator): boolean { if (!c.lineAligned) return false return op === "d" ? c.targetIsBlank || c.multiLine : c.targetIsBlank } @@ -287,7 +287,7 @@ function isLinewiseNext(c: NextClassification, op: ParagraphOperation): boolean function nextLinewiseSpan( text: string, c: NextClassification, - op: ParagraphOperation, + op: VimOperator, ): { span: VimSpan | null; registerSpan: VimSpan | null } { if (op === "d" && !c.targetIsBlank) { const extendBack = text[text.length - 1] !== "\n" && c.lineStartOffset > 0 @@ -309,23 +309,23 @@ function nextCharwiseSpan(text: string, cursor: number, c: NextClassification): return { start: cursor, end } } -export function nextParagraphOperation(textarea: TextareaRenderable, operation: ParagraphOperation): ParagraphResult { +export function nextParagraphOperation(textarea: TextareaRenderable, operation: VimOperator): VimOperatorResult { const text = textarea.plainText const cursor = textarea.cursorOffset if (text.length === 0) return { span: null, register: null } const c = classifyNextParagraph(text, cursor) - if (!isLinewiseNext(c, operation)) return buildParagraphResult(text, nextCharwiseSpan(text, cursor, c), null, false) + if (!isLinewiseNext(c, operation)) return buildOperatorResult(text, nextCharwiseSpan(text, cursor, c), null, false) const { span, registerSpan } = nextLinewiseSpan(text, c, operation) - return buildParagraphResult(text, span, registerSpan, true) + return buildOperatorResult(text, span, registerSpan, true) } // vim `{` operator. linewise for all of y/d/c when cursor is line-aligned. // c strips the trailing \n at cursor-1; d/y keep it. export function previousParagraphOperation( textarea: TextareaRenderable, - operation: ParagraphOperation, -): ParagraphResult { + operation: VimOperator, +): VimOperatorResult { const text = textarea.plainText const cursor = textarea.cursorOffset if (text.length === 0 || cursor === 0) return { span: null, register: null } @@ -335,9 +335,9 @@ export function previousParagraphOperation( const target = previousParagraphTarget(text, cursor) if (target >= cursor) return { span: null, register: null } - if (!linewise || operation !== "c") return buildParagraphResult(text, { start: target, end: cursor }, null, linewise) + if (!linewise || operation !== "c") return buildOperatorResult(text, { start: target, end: cursor }, null, linewise) const end = text[cursor - 1] === "\n" ? cursor - 1 : cursor - return buildParagraphResult(text, end > target ? { start: target, end } : null, null, true) + return buildOperatorResult(text, end > target ? { start: target, end } : null, null, true) } export function isWord(char: string) { From ea90c15a76f32b030a6481b848194b1e232b911d Mon Sep 17 00:00:00 2001 From: Leo Henon <77656081+leohenon@users.noreply.github.com> Date: Sun, 17 May 2026 23:18:59 +0800 Subject: [PATCH 3/4] feat: add delete find operators (#137) * feat: add delete find operators * test: cover delete find edge cases * fix: ignore current char for backward find delete * refactor: share find target lookup * refactor: share find operator setup * refactor: share find operator helpers * docs: document new motions * fix: show full operator find pending indicator * fix: exclude cursor char from backward find delete * feat: add change find operators (#138) * feat: add change find operators * docs: document change find motions * refactor: share operator find dispatch --------- Co-authored-by: Brett Kulp <103147007+BrettKulp@users.noreply.github.com> --- README.md | 2 +- .../cli/cmd/tui/component/vim/vim-handler.ts | 70 ++++ .../cmd/tui/component/vim/vim-indicator.ts | 2 +- .../cli/cmd/tui/component/vim/vim-motions.ts | 55 ++- .../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 | 361 +++++++++++++++++- 7 files changed, 471 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 964752bac503..35e1a6c86032 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` `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` `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 c2f67815d444..0d2f54f02c9d 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, @@ -75,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 @@ -117,6 +119,7 @@ export function createVimHandler(input: { setRegister?: (register: VimRegister, notify?: boolean) => void }) { let wantedColumn: VimWantedColumn | undefined + let pendingOperatorFind: { operation: VimOperator; find: VimFindOperator } | undefined function hasModifier(event: VimEvent) { return !!event.ctrl || !!event.meta || !!event.super @@ -326,6 +329,67 @@ export function createVimHandler(input: { return false } + function findOperation(char: string, forward: boolean, till: boolean) { + const textarea = input.textarea() + const start = textarea.cursorOffset + 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 spanEnd = till ? offset : offset + 1 + return charwiseOperation(spanEnd > start ? { start, end: spanEnd } : null) + } + + const spanStart = till ? offset + 1 : offset + return charwiseOperation(spanStart < start ? { start: spanStart, end: start } : null) + } + + function startOperatorFind(event: VimEvent, operation: VimOperator, find: VimFindOperator) { + pendingOperatorFind = { operation, find } + input.state.setPending(find, operation + find) + event.preventDefault() + 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) { + 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 +437,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() @@ -636,6 +702,8 @@ export function createVimHandler(input: { return true } + if (operatorFind(event, key, "c")) return true + input.state.clearPending() } @@ -670,6 +738,8 @@ export function createVimHandler(input: { return true } + if (operatorFind(event, key, "d")) return true + input.state.clearPending() } 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-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) { 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 987da6a961a7..b1c73ec0a9f7 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, @@ -3006,6 +3017,337 @@ describe("vim motion handler", () => { expect(ctx.jumpCalls).toEqual([]) }) + 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") + expect(ctx.state.pendingDisplay()).toBe("df") + + 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 and excluding cursor", () => { + 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 wrld") + expect(ctx.textarea.cursorOffset).toBe(7) + expect(ctx.state.register()).toEqual({ text: "o", 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 and excludes cursor", () => { + 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("abcxfgh") + expect(ctx.textarea.cursorOffset).toBe(4) + expect(ctx.state.register()).toEqual({ text: "de", 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 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 + + 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("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("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 + + 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("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 + + 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 + + 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("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 @@ -6883,9 +7225,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) @@ -6926,8 +7269,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") { @@ -6949,6 +7301,7 @@ describe("copy mode cursor state", () => { mode, setMode: changeMode, pending, + pendingDisplay, setPending, clearPending, lastFind, From 0f493a1466064ee8e7acde3fad293e76c9930a35 Mon Sep 17 00:00:00 2001 From: Leo Henon <77656081+leohenon@users.noreply.github.com> Date: Sun, 17 May 2026 23:54:31 +0800 Subject: [PATCH 4/4] fix: normalize find target keys (#139) --- .../cli/cmd/tui/component/vim/vim-handler.ts | 10 +++-- .../opencode/test/cli/tui/vim-motions.test.ts | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 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 0d2f54f02c9d..c36c82296252 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 @@ -134,13 +134,15 @@ export function createVimHandler(input: { } function isPrintable(event: VimEvent) { - return !!event.name && (event.name.length === 1 || event.name === "space") + const key = normalizedKeyName(event) + return key.length === 1 || key === "space" } function value(event: VimEvent) { - if (event.name === "space") return " " - if (event.shift && event.name?.length === 1 && /[a-z]/.test(event.name)) return event.name.toUpperCase() - return event.name ?? "" + const key = normalizedKeyName(event) + if (key === "space") return " " + if (event.shift && key.length === 1 && /[a-z]/.test(key)) return key.toUpperCase() + return key } function replaceValue(event: VimEvent, visual = false) { diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index b1c73ec0a9f7..a39989cbc29c 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -3017,6 +3017,51 @@ describe("vim motion handler", () => { expect(ctx.jumpCalls).toEqual([]) }) + test("pending find normalizes slash target", () => { + const ctx = createHandler("ab/cd") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("f").event) + const slash = createEvent("slash") + expect(ctx.handler.handleKey(slash.event)).toBe(true) + expect(slash.prevented()).toBe(true) + expect(ctx.textarea.cursorOffset).toBe(2) + expect(ctx.state.lastFind()).toEqual({ char: "/", forward: true, till: false }) + expect(ctx.state.pending()).toBe("") + }) + + test("df normalizes slash target", () => { + const ctx = createHandler("ab/cd") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("f").event) + const slash = createEvent("slash") + expect(ctx.handler.handleKey(slash.event)).toBe(true) + expect(slash.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe("cd") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.register()).toEqual({ text: "ab/", linewise: false }) + expect(ctx.state.lastFind()).toEqual({ char: "/", forward: true, till: false }) + expect(ctx.state.pending()).toBe("") + }) + + test("cf normalizes at target", () => { + const ctx = createHandler("ab@cd") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("f").event) + const at = createEvent("at") + expect(ctx.handler.handleKey(at.event)).toBe(true) + expect(at.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe("cd") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "ab@", linewise: false }) + expect(ctx.state.lastFind()).toEqual({ char: "@", forward: true, till: false }) + }) + test("df deletes forward including found char", () => { const ctx = createHandler("hello world") ctx.textarea.cursorOffset = 0