diff --git a/README.md b/README.md index 5a6785c85aa1..1023dfc5806c 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` `dt` `dT` `df` `dF` `d%` `d}` `d{` `cc` `cw` `cb` `ct` `cT` `cf` `cF` `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 22c206c92fae..dac5210c64d0 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 @@ -369,6 +369,68 @@ export function createVimHandler(input: { return true } + const deleteFind = input.state.pending() + if (deleteFind === "df" || deleteFind === "dF" || deleteFind === "dt" || deleteFind === "dT") { + if (isPrintable(event) && !hasModifier(event)) { + const forward = deleteFind === "df" || deleteFind === "dt" + const till = deleteFind === "dt" || deleteFind === "dT" + const textarea = input.textarea() + const start = textarea.cursorOffset + const char = value(event) + findChar(textarea, char, forward, till) + if (textarea.cursorOffset !== start) { + const span = forward + ? { start, end: textarea.cursorOffset + 1 } + : { start: textarea.cursorOffset, end: start + 1 } + const text = textarea.plainText.slice(span.start, span.end) + edit(() => { + deleteSpan(textarea, span) + if (text) setRegister({ text, linewise: false }) + textarea.cursorOffset = Math.min(start, textarea.cursorOffset) + }) + } + input.state.setLastFind({ char, forward, till }) + input.state.clearPending() + event.preventDefault() + return true + } + input.state.clearPending() + event.preventDefault() + return true + } + + const changeFind = input.state.pending() + if (changeFind === "cf" || changeFind === "cF" || changeFind === "ct" || changeFind === "cT") { + if (isPrintable(event) && !hasModifier(event)) { + const forward = changeFind === "cf" || changeFind === "ct" + const till = changeFind === "ct" || changeFind === "cT" + const textarea = input.textarea() + const start = textarea.cursorOffset + const char = value(event) + findChar(textarea, char, forward, till) + if (textarea.cursorOffset !== start) { + const span = forward + ? { start, end: textarea.cursorOffset + 1 } + : { start: textarea.cursorOffset, end: start + 1 } + const text = textarea.plainText.slice(span.start, span.end) + edit(() => { + deleteSpan(textarea, span) + if (text) setRegister({ text, linewise: false }) + textarea.cursorOffset = Math.min(start, textarea.cursorOffset) + }) + } + input.state.setLastFind({ char, forward, till }) + input.state.setMode("insert") + input.state.clearPending() + event.preventDefault() + return true + } + + input.state.clearPending() + event.preventDefault() + return true + } + const scroll = vimScroll(event) if (scroll) { input.state.clearPending() @@ -612,6 +674,30 @@ export function createVimHandler(input: { return true } + if (key === "t" && !event.shift && !hasModifier(event)) { + input.state.setPending("ct") + event.preventDefault() + return true + } + + if (isShifted(event, "t") && !hasModifier(event)) { + input.state.setPending("cT") + event.preventDefault() + return true + } + + if (key === "f" && !event.shift && !hasModifier(event)) { + input.state.setPending("cf") + event.preventDefault() + return true + } + + if (isShifted(event, "f") && !hasModifier(event)) { + input.state.setPending("cF") + event.preventDefault() + return true + } + input.state.clearPending() } @@ -682,6 +768,30 @@ export function createVimHandler(input: { return true } + if (key === "t" && !event.shift && !hasModifier(event)) { + input.state.setPending("dt") + event.preventDefault() + return true + } + + if (isShifted(event, "t") && !hasModifier(event)) { + input.state.setPending("dT") + event.preventDefault() + return true + } + + if (key === "f" && !event.shift && !hasModifier(event)) { + input.state.setPending("df") + event.preventDefault() + return true + } + + if (isShifted(event, "f") && !hasModifier(event)) { + input.state.setPending("dF") + event.preventDefault() + return true + } + input.state.clearPending() } @@ -750,8 +860,9 @@ export function createVimHandler(input: { if (isPrintable(event) && !hasModifier(event)) { const forward = find === "f" || find === "t" const till = find === "t" || find === "T" - findChar(input.textarea(), key, forward, till) - input.state.setLastFind({ char: key, forward, till }) + const char = value(event) + findChar(input.textarea(), char, forward, till) + input.state.setLastFind({ char, forward, till }) input.state.clearPending() event.preventDefault() return true 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..d6e9004084e3 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 @@ -1,7 +1,7 @@ import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" export type VimMode = "normal" | "insert" | "replace" | "visual" | "visual-line" | "copy" -export type VimPending = "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w" | "r" | "vr" +export type VimPending = "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w" | "r" | "vr" | "df" | "dF" | "dt" | "dT" | "cf" | "cF" | "ct" | "cT" export type VimFind = { char: string; forward: boolean; till: boolean } | null export type VimRegister = { text: string; linewise: boolean } | null export type VimSnapshot = { text: string; cursor: number; data?: unknown } diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index da30cda3d978..16a4cb24b0a6 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -173,9 +173,7 @@ function createHandler( const [mode, setMode] = createSignal<"normal" | "insert" | "replace" | "visual" | "visual-line" | "copy">( options?.mode ?? "normal", ) - const [pending, setPending] = createSignal< - "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w" | "r" | "vr" - >("") + const [pending, setPending] = createSignal<"" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w" | "r" | "vr" | "df" | "dF" | "dt" | "dT" | "cf" | "cF" | "ct" | "cT">("") 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) @@ -2842,6 +2840,400 @@ describe("vim motion handler", () => { expect(ctx.textarea.cursorOffset).toBe(3) }) + test("df deletes forward including found char", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("d").event) + expect(ctx.state.pending()).toBe("d") + + ctx.handler.handleKey(createEvent("f").event) + expect(ctx.state.pending()).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.pending()).toBe("") + }) + + test("dF deletes backward including found char", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("d").event) + expect(ctx.state.pending()).toBe("d") + + ctx.handler.handleKey(createEvent("F").event) + expect(ctx.state.pending()).toBe("dF") + + const o = createEvent("o") + expect(ctx.handler.handleKey(o.event)).toBe(true) + expect(o.prevented()).toBe(true) + // nearest 'o' backward from 8 is at index 7 + expect(ctx.textarea.plainText).toBe("hello wld") + expect(ctx.textarea.cursorOffset).toBe(7) + 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("dt") + + 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.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("dT") + + // 'x' is at index 3, till puts cursor at 4, deletes from 4 to 7 (start+1=7) + 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.pending()).toBe("") + }) + + test("df char 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) + }) + + test("df populates register with deleted text", () => { + 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("o").event) + expect(ctx.state.register()).toEqual({ text: "hello", linewise: false }) + }) + + test("df with unknown motion clears pending", () => { + const ctx = createHandler("hello") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("f").event) + expect(ctx.state.pending()).toBe("df") + + ctx.handler.handleKey(createEvent("escape").event) + expect(ctx.state.pending()).toBe("") + }) + + test("cf changes forward including found char", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("c").event) + expect(ctx.state.pending()).toBe("c") + + ctx.handler.handleKey(createEvent("f").event) + expect(ctx.state.pending()).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.pending()).toBe("") + }) + + test("cF changes backward including found char", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("c").event) + expect(ctx.state.pending()).toBe("c") + + ctx.handler.handleKey(createEvent("F").event) + expect(ctx.state.pending()).toBe("cF") + + 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.mode()).toBe("insert") + expect(ctx.state.pending()).toBe("") + }) + + 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.pending()).toBe("ct") + + 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.mode()).toBe("insert") + expect(ctx.state.pending()).toBe("") + }) + + test("cT changes backward from after found char", () => { + const ctx = createHandler("abcxdefgh") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("T").event) + expect(ctx.state.pending()).toBe("cT") + + 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.mode()).toBe("insert") + expect(ctx.state.pending()).toBe("") + }) + + test("cf char not found leaves text unchanged", () => { + const ctx = createHandler("hello") + 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("insert") + }) + + test("cf populates register with deleted text", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("f").event) + ctx.handler.handleKey(createEvent("o").event) + expect(ctx.state.register()).toEqual({ text: "hello", linewise: false }) + expect(ctx.state.mode()).toBe("insert") + }) + + test("cf with unknown motion clears pending", () => { + const ctx = createHandler("hello") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("f").event) + expect(ctx.state.pending()).toBe("cf") + + ctx.handler.handleKey(createEvent("escape").event) + expect(ctx.state.pending()).toBe("") + }) + + test("f finds uppercase char forward", () => { + const ctx = createHandler("Hello World") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("f").event) + expect(ctx.state.pending()).toBe("f") + + const w = createEvent("W") + expect(ctx.handler.handleKey(w.event)).toBe(true) + expect(w.prevented()).toBe(true) + expect(ctx.textarea.cursorOffset).toBe(6) + expect(ctx.state.pending()).toBe("") + }) + + test("F finds uppercase char backward", () => { + const ctx = createHandler("Hello World") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("F").event) + expect(ctx.state.pending()).toBe("F") + + const w = createEvent("W") + expect(ctx.handler.handleKey(w.event)).toBe(true) + expect(ctx.textarea.cursorOffset).toBe(6) + }) + + test("t stops before uppercase char", () => { + const ctx = createHandler("Hello World") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("t").event) + expect(ctx.state.pending()).toBe("t") + + const w = createEvent("W") + expect(ctx.handler.handleKey(w.event)).toBe(true) + expect(w.prevented()).toBe(true) + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.pending()).toBe("") + }) + + test("T stops after uppercase char backward", () => { + const ctx = createHandler("Hello World") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("T").event) + expect(ctx.state.pending()).toBe("T") + + const w = createEvent("W") + expect(ctx.handler.handleKey(w.event)).toBe(true) + expect(ctx.textarea.cursorOffset).toBe(7) + }) + + test("df deletes forward including uppercase 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("df") + + const w = createEvent("W") + expect(ctx.handler.handleKey(w.event)).toBe(true) + expect(w.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe("orld") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.pending()).toBe("") + }) + + test("dF deletes backward including uppercase 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("dF") + + const w = createEvent("W") + expect(ctx.handler.handleKey(w.event)).toBe(true) + expect(w.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe("Hello ld") + expect(ctx.textarea.cursorOffset).toBe(6) + expect(ctx.state.pending()).toBe("") + }) + + test("dt deletes forward up to uppercase 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("dt") + + const w = createEvent("W") + expect(ctx.handler.handleKey(w.event)).toBe(true) + expect(w.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe("World") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.pending()).toBe("") + }) + + test("dT deletes backward from after uppercase char", () => { + const ctx = createHandler("abcWdefgh") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("T").event) + expect(ctx.state.pending()).toBe("dT") + + const w = createEvent("W") + expect(ctx.handler.handleKey(w.event)).toBe(true) + expect(w.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe("abcWgh") + expect(ctx.textarea.cursorOffset).toBe(4) + expect(ctx.state.pending()).toBe("") + }) + + test("cf changes forward including uppercase 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("cf") + + const w = createEvent("W") + expect(ctx.handler.handleKey(w.event)).toBe(true) + expect(w.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe("orld") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.pending()).toBe("") + }) + + test("cF changes backward including uppercase char", () => { + const ctx = createHandler("Hello World") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("F").event) + expect(ctx.state.pending()).toBe("cF") + + const w = createEvent("W") + expect(ctx.handler.handleKey(w.event)).toBe(true) + expect(w.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe("Hello ld") + expect(ctx.textarea.cursorOffset).toBe(6) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.pending()).toBe("") + }) + + test("ct changes forward up to uppercase 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.pending()).toBe("ct") + + const w = createEvent("W") + expect(ctx.handler.handleKey(w.event)).toBe(true) + expect(w.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe("World") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.pending()).toBe("") + }) + + test("cT changes backward from after uppercase char", () => { + const ctx = createHandler("abcWdefgh") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("T").event) + expect(ctx.state.pending()).toBe("cT") + + const w = createEvent("W") + expect(ctx.handler.handleKey(w.event)).toBe(true) + expect(w.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe("abcWgh") + expect(ctx.textarea.cursorOffset).toBe(4) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.pending()).toBe("") + }) + test("yy yanks current line into register", () => { const ctx = createHandler("one\ntwo\nthree") ctx.textarea.cursorOffset = 5 @@ -6706,9 +7098,7 @@ 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< - "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w" | "r" | "vr" - >("") + const [pending, setPending] = createSignal<"" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w" | "r" | "vr" | "df" | "dF" | "dt" | "dT" | "cf" | "cF" | "ct" | "cT">("") 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)