From 6eaa37f802d2cc0b49550ddf3b0cdd527969f158 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sat, 30 May 2026 14:10:25 +0400 Subject: [PATCH 01/15] feat: add copy mode search --- .../cli/cmd/tui/component/prompt/index.tsx | 99 +++++++++++ .../cli/cmd/tui/component/vim/vim-handler.ts | 72 +++++++- .../cmd/tui/component/vim/vim-indicator.ts | 3 + .../cli/cmd/tui/routes/session/copy-mode.ts | 167 +++++++++++++++++- .../cmd/tui/routes/session/copy-overlay.tsx | 3 +- .../opencode/test/cli/tui/vim-motions.test.ts | 135 +++++++++++++- 6 files changed, 472 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 2893a32e90d6..8a09fe877142 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -115,6 +115,17 @@ export type PromptProps = { matchingBracket: () => boolean nextParagraph: () => boolean previousParagraph: () => boolean + searchStart: (direction: "forward" | "backward") => void + searchAppend: (value: string) => boolean + searchBackspace: () => boolean + searchSubmit: () => boolean + searchCancel: () => void + searchClear: () => boolean + searchActive: () => boolean + searchHighlighted: () => boolean + searchDisplay: () => string | undefined + searchNext: () => boolean + searchPrevious: () => boolean text: () => string col: () => number setCol: (offset: number) => void @@ -547,6 +558,7 @@ export function Prompt(props: PromptProps) { active: () => store.mode === "normal", state: vimState, copyVisual: () => props.copy?.visualMode(), + copySearch: () => props.copy?.searchDisplay(), }) let flash = 0 let timer: ReturnType | undefined @@ -773,6 +785,36 @@ export function Prompt(props: PromptProps) { copyPreviousParagraph() { return props.copy?.previousParagraph() ?? false }, + copySearchStart(direction) { + props.copy?.searchStart(direction) + }, + copySearchAppend(value) { + return props.copy?.searchAppend(value) ?? false + }, + copySearchBackspace() { + return props.copy?.searchBackspace() ?? false + }, + copySearchSubmit() { + return props.copy?.searchSubmit() ?? true + }, + copySearchCancel() { + props.copy?.searchCancel() + }, + copySearchClear() { + return props.copy?.searchClear() ?? false + }, + copySearchActive() { + return props.copy?.searchActive() ?? false + }, + copySearchHighlighted() { + return props.copy?.searchHighlighted() ?? false + }, + copySearchNext() { + return props.copy?.searchNext() ?? false + }, + copySearchPrevious() { + return props.copy?.searchPrevious() ?? false + }, copyText() { return props.copy?.text() ?? "" }, @@ -1189,6 +1231,63 @@ export function Prompt(props: PromptProps) { })), })) + useBindings(() => ({ + target: inputTarget, + priority: 200, + enabled: + inputTarget() !== undefined && + !props.disabled && + vimEnabled() && + store.mode === "normal" && + vimState.isCopy() && + !!props.copy?.searchActive(), + bindings: [ + ...["backspace", "shift+backspace", "delete", "ctrl+h"].map((key) => ({ + key, + desc: "Delete search character", + group: "Copy mode", + cmd: () => { + props.copy?.searchBackspace() + return true + }, + })), + { + key: "return", + desc: "Submit search", + group: "Copy mode", + cmd: () => { + if (props.copy?.searchSubmit() === false) toast.show({ message: "Pattern not found", variant: "warning" }) + return true + }, + }, + ], + })) + + useBindings(() => ({ + target: inputTarget, + priority: 200, + enabled: + inputTarget() !== undefined && + !props.disabled && + vimEnabled() && + store.mode === "normal" && + vimState.isCopy() && + !props.copy?.isVisual() && + !!props.copy?.searchHighlighted(), + bindings: [ + { + key: "escape", + desc: "Clear search highlights", + group: "Copy mode", + cmd: () => { + if (props.copy?.searchActive()) props.copy.searchCancel() + else props.copy?.searchClear() + return true + }, + }, + ], + })) + useBindings(() => ({ target: inputTarget, priority: 100, 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 d1b38f1b8736..40cb2d24fee6 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 @@ -97,13 +97,14 @@ function vimEventText(event: VimKeyLike) { } function normalizedKeyName(event: VimKeyLike) { - if (event.name === "slash") return "/" + if (event.name === "backspace" || event.sequence === "\b" || event.sequence === "\x7f" || event.raw === "\b" || event.raw === "\x7f") return "backspace" + if (event.name === "slash") return event.shift ? "?" : "/" if (event.name === "at") return "@" if (event.name === "quote") return '"' if (event.name === "apostrophe") return "'" if (event.name === "backtick") return "`" const text = vimEventText(event) - if (text && (text === "/" || text === "@" || text === '"' || text === "'" || text === "`" || "()[]{}<>".includes(text))) return text + if (text && (text === "/" || text === "?" || text === "@" || text === '"' || text === "'" || text === "`" || "()[]{}<>".includes(text))) return text if (event.shift) { if (event.name === "9") return "(" if (event.name === "0") return ")" @@ -142,6 +143,16 @@ export function createVimHandler(input: { copyMatchingBracket?: () => boolean copyNextParagraph?: () => boolean copyPreviousParagraph?: () => boolean + copySearchStart?: (direction: "forward" | "backward") => void + copySearchAppend?: (value: string) => boolean + copySearchBackspace?: () => boolean + copySearchSubmit?: () => boolean + copySearchCancel?: () => void + copySearchClear?: () => boolean + copySearchActive?: () => boolean + copySearchHighlighted?: () => boolean + copySearchNext?: () => boolean + copySearchPrevious?: () => boolean copyText?: () => string copyCol?: () => number setCopyCol?: (offset: number) => void @@ -1320,6 +1331,31 @@ export function createVimHandler(input: { } function copy(event: VimEvent, key: string): boolean { + if (input.copySearchActive?.()) { + if (key === "return") { + input.copySearchSubmit?.() + event.preventDefault() + return true + } + if (key === "escape") { + input.copySearchCancel?.() + event.preventDefault() + return true + } + if (key === "backspace" || key === "delete" || (key === "h" && event.ctrl && !event.meta && !event.super)) { + input.copySearchBackspace?.() + event.preventDefault() + return true + } + if (!hasModifier(event) && isPrintable(event)) { + input.copySearchAppend?.(value(event)) + event.preventDefault() + return true + } + event.preventDefault() + return true + } + if (input.state.pending() === "" && isShifted(event, "y") && !hasModifier(event)) { if (input.copyIsVisual?.()) { input.copyYank?.() @@ -1400,6 +1436,10 @@ export function createVimHandler(input: { event.preventDefault() return true } + if (input.copySearchHighlighted?.() && input.copySearchClear?.()) { + event.preventDefault() + return true + } input.state.setMode("normal") event.preventDefault() return true @@ -1448,6 +1488,34 @@ export function createVimHandler(input: { return false } + if (key === "/") { + clearCopyPending() + input.copySearchStart?.("forward") + event.preventDefault() + return true + } + + if (key === "?") { + clearCopyPending() + input.copySearchStart?.("backward") + event.preventDefault() + return true + } + + if (key === "n" && !event.shift) { + clearCopyPending() + input.copySearchNext?.() + event.preventDefault() + return true + } + + if (isShifted(event, "n")) { + clearCopyPending() + input.copySearchPrevious?.() + event.preventDefault() + return true + } + if (isShifted(event, "h")) { clearCopyPending() input.copyJump?.("high") 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 7ff30c583afd..680000ea258e 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 @@ -6,12 +6,15 @@ export function useVimIndicator(input: { active: Accessor state: ReturnType copyVisual?: Accessor + copySearch?: Accessor }) { return createMemo(() => { if (!input.enabled() || !input.active()) return const key = input.state.pending() if (key && key !== "w") return (input.state.pendingDisplay() || key) + ".." if (input.state.isCopy()) { + const search = input.copySearch?.() + if (search !== undefined) return search if (input.copyVisual?.() === "char") return "-- V-COPY --" if (input.copyVisual?.() === "line") return "-- VL-COPY --" return "COPY" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts index 87189801dd74..be06e239f4b0 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts +++ b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts @@ -29,6 +29,7 @@ export type CopyHighlight = { left: number right: number text: string + current?: boolean } type CopyState = { @@ -40,6 +41,16 @@ type CopyState = { anchor: undefined | { idx: number; col: number } } +type CopySearch = { + query: string + direction: "forward" | "backward" +} + +type CopySearchMatch = { + idx: number + col: number +} + const empty: CopyState = { active: false, idx: -1, @@ -71,8 +82,16 @@ export function createCopyMode(input: { const [unified, setUnified] = createSignal(false) const [yankLineFlash, setYankLineFlash] = createSignal(undefined) const [yankRangeFlash, setYankRangeFlash] = createSignal<{ start: Endpoint; end: Endpoint } | undefined>(undefined) + const [activeSearch, setActiveSearch] = createSignal(undefined) + const [searchVersion, setSearchVersion] = createSignal(0) let yankFlashTimer: ReturnType | undefined let lastCursor: CopyRow | undefined + let lastSearch: CopySearch | undefined + + function setLastSearch(next: CopySearch | undefined) { + lastSearch = next + setSearchVersion((value) => value + 1) + } function flashYankRange(start: Endpoint, end: Endpoint) { setYankRangeFlash(orderEndpoints(start, end)) @@ -588,6 +607,11 @@ export function createCopyMode(input: { setTimeout(() => init(), 0) } + function clearSearchState() { + setLastSearch(undefined) + setActiveSearch(undefined) + } + function exit(scrollToBottom?: boolean) { if (scrollToBottom === false) { exitPreserveScroll() @@ -595,6 +619,7 @@ export function createCopyMode(input: { } lastCursor = undefined batch(() => { + clearSearchState() setState({ ...empty }) setUnified(false) }) @@ -605,6 +630,7 @@ export function createCopyMode(input: { lastCursor = row() const snap = snapshotScroll() batch(() => { + clearSearchState() setState((s) => ({ ...s, active: false, visual: undefined, anchor: undefined })) setUnified(false) }) @@ -745,6 +771,117 @@ export function createCopyMode(input: { return paragraphMove(copyPreviousParagraph) } + // --- search --- + + function searchMatches(query: string): CopySearchMatch[] { + const needle = query + if (!needle) return [] + const list = rows() + const cache = new Map(input.scroll().getChildren().map((c) => [c.id, c])) + const sensitive = /[A-Z]/.test(needle) + const target = sensitive ? needle : needle.toLowerCase() + return list.flatMap((row, idx) => { + const text = rowText(row, cache) + const haystack = sensitive ? text : text.toLowerCase() + const min = copyMin(row, cache) + const matches: CopySearchMatch[] = [] + let from = 0 + while (from <= haystack.length) { + const found = haystack.indexOf(target, from) + if (found < 0) break + matches.push({ idx, col: min + found }) + from = found + Math.max(1, target.length) + } + return matches + }) + } + + function pickSearchMatch(matches: CopySearchMatch[], direction: "forward" | "backward") { + const s = state() + if (direction === "forward") { + return matches.find((match) => match.idx > s.idx || (match.idx === s.idx && match.col > s.col)) ?? matches[0] + } + return ( + matches.findLast((match) => match.idx < s.idx || (match.idx === s.idx && match.col < s.col)) ?? + matches[matches.length - 1] + ) + } + + function moveToSearchMatch(match: CopySearchMatch) { + sync(match.idx) + const row = rows()[state().idx] + if (!row) return false + const min = copyMin(row) + setState((s) => ({ + ...s, + col: Math.max(min, match.col), + stick: Math.max(0, match.col - min), + visual: undefined, + anchor: undefined, + })) + return true + } + + function search(query: string, direction: "forward" | "backward") { + const matches = searchMatches(query) + const match = pickSearchMatch(matches, direction) + if (!match) return false + setLastSearch({ query, direction }) + return moveToSearchMatch(match) + } + + function startSearch(direction: "forward" | "backward") { + setActiveSearch({ query: "", direction }) + } + + function updateSearch(query: string) { + const current = activeSearch() + if (!current) return false + setActiveSearch({ ...current, query }) + if (!query) return false + return search(query, current.direction) + } + + function appendSearch(value: string) { + return updateSearch((activeSearch()?.query ?? "") + value) + } + + function backspaceSearch() { + return updateSearch((activeSearch()?.query ?? "").slice(0, -1)) + } + + function submitSearch() { + const current = activeSearch() + setActiveSearch(undefined) + if (!current?.query) return true + const found = searchMatches(current.query).length > 0 + if (found) setLastSearch(current) + return found + } + + function cancelSearch() { + clearSearchState() + } + + function clearSearch() { + if (activeSearch()) { + setActiveSearch(undefined) + return true + } + if (!lastSearch) return false + setLastSearch(undefined) + return true + } + + function repeatSearch(reverse = false) { + if (!lastSearch) return false + const previous = lastSearch + const direction = reverse ? (previous.direction === "forward" ? "backward" : "forward") : previous.direction + const moved = search(previous.query, direction) + if (moved && reverse) setLastSearch(previous) + return moved + } + // --- visual --- function visual(mode: "char" | "line") { @@ -960,13 +1097,14 @@ export function createCopyMode(input: { const out = new Map() if (!s.active) return out const flashIdx = yankLineFlash() - const addHighlight = (row: CopyRow, min: number, text: string, left: number, right: number) => { + const addHighlight = (row: CopyRow, min: number, text: string, left: number, right: number, current = false) => { if (left > right) return const entry = { line: row.line, left, right, text: text.slice(Math.max(0, left - min), Math.max(0, right - min + 1)), + current, } const arr = out.get(row.id) if (arr) arr.push(entry) @@ -982,6 +1120,18 @@ export function createCopyMode(input: { .map((c) => [c.id, c]), ) + searchVersion() + const searchQuery = activeSearch() ? activeSearch()?.query : lastSearch?.query + if (searchQuery) { + for (const match of searchMatches(searchQuery)) { + const row = list[match.idx] + if (!row) continue + const text = rowText(row, cache) || "" + const min = copyMin(row, cache) + addHighlight(row, min, text, match.col, match.col + searchQuery.length - 1, match.idx === s.idx && match.col === s.col) + } + } + if (flashIdx !== undefined) { const row = list[flashIdx] if (row) { @@ -1070,6 +1220,21 @@ export function createCopyMode(input: { matchingBracket, nextParagraph, previousParagraph, + searchStart: startSearch, + searchAppend: appendSearch, + searchBackspace: backspaceSearch, + searchSubmit: submitSearch, + searchCancel: cancelSearch, + searchClear: clearSearch, + searchActive: () => activeSearch() !== undefined, + searchHighlighted: () => (searchVersion(), activeSearch() !== undefined || lastSearch !== undefined), + searchDisplay: () => { + const search = activeSearch() + if (!search) return undefined + return `${search.direction === "forward" ? "/" : "?"}${search.query}` + }, + searchNext: () => repeatSearch(false), + searchPrevious: () => repeatSearch(true), text: copyText, col, setCol, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/copy-overlay.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/copy-overlay.tsx index 9224bd865d96..0cab437bae51 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/copy-overlay.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/copy-overlay.tsx @@ -10,6 +10,7 @@ export function CopyOverlay(props: { copy?: CopyPosition; topOffset?: number; hi const { theme } = useTheme() const top = (line: number) => line + (props.topOffset ?? 0) const highlightFg = createMemo(() => selectedForeground(theme, theme.secondary)) + const currentHighlightFg = createMemo(() => selectedForeground(theme, theme.primary)) const cursorFg = createMemo(() => selectedForeground(theme, theme.text)) return ( <> @@ -26,7 +27,7 @@ export function CopyOverlay(props: { copy?: CopyPosition; topOffset?: number; hi {(highlight) => ( - + {highlight.text || " "} diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 71a6ee967056..709683475fc5 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -207,6 +207,16 @@ function createHandler( const copyJumps: Array = [] const copyVisualCalls: Array<"char" | "line"> = [] const copyScrollCalls: Array<"center" | "top" | "bottom"> = [] + const copySearchCalls: Array<"forward" | "backward"> = [] + const copySearchAppends: string[] = [] + let copySearchBackspaces = 0 + let copySearchSubmits = 0 + let copySearchCancels = 0 + let copySearchClears = 0 + const [copySearchActive, setCopySearchActive] = createSignal(false) + const [copySearchHighlighted, setCopySearchHighlighted] = createSignal(false) + let copySearchNexts = 0 + let copySearchPreviouses = 0 let copyYanks = 0 let copyYankLines = 0 let copyCopies = 0 @@ -470,6 +480,44 @@ function createHandler( setCopyCol(col) return true }, + copySearchStart(direction) { + copySearchCalls.push(direction) + setCopySearchActive(true) + setCopySearchHighlighted(true) + }, + copySearchAppend(value) { + copySearchAppends.push(value) + return true + }, + copySearchBackspace() { + copySearchBackspaces++ + return true + }, + copySearchSubmit() { + copySearchSubmits++ + setCopySearchActive(false) + return true + }, + copySearchCancel() { + copySearchCancels++ + setCopySearchActive(false) + }, + copySearchClear() { + copySearchClears++ + setCopySearchActive(false) + setCopySearchHighlighted(false) + return true + }, + copySearchActive, + copySearchHighlighted, + copySearchNext() { + copySearchNexts++ + return true + }, + copySearchPrevious() { + copySearchPreviouses++ + return true + }, copyText() { return options?.copy?.texts?.[copyIdx()] ?? options?.copy?.text ?? "alpha beta gamma" }, @@ -510,6 +558,16 @@ function createHandler( copyVisual, copyVisualCalls, copyScrollCalls, + copySearchCalls, + copySearchAppends, + copySearchBackspaces: () => copySearchBackspaces, + copySearchSubmits: () => copySearchSubmits, + copySearchCancels: () => copySearchCancels, + copySearchClears: () => copySearchClears, + copySearchActive, + copySearchHighlighted, + copySearchNexts: () => copySearchNexts, + copySearchPreviouses: () => copySearchPreviouses, copyYanks: () => copyYanks, copyYankLines: () => copyYankLines, copyCopies: () => copyCopies, @@ -7532,9 +7590,9 @@ describe("copy mode", () => { cm.prompt.setCol(11) expect(cm.prompt.yankMatchingBracket()).toEqual({ text: "(\n value\n)", linewise: false }) expect(cm.highlights().get("text-part")).toEqual([ - { line: 0, left: 11, right: 11, text: "(" }, - { line: 1, left: 7, right: 13, text: " value" }, - { line: 2, left: 7, right: 7, text: ")" }, + { line: 0, left: 11, right: 11, text: "(", current: false }, + { line: 1, left: 7, right: 13, text: " value", current: false }, + { line: 2, left: 7, right: 7, text: ")", current: false }, ]) await new Promise((resolve) => setTimeout(resolve, 100)) @@ -7970,6 +8028,58 @@ describe("copy mode", () => { expect(ctx.copyFocusInputs()).toBe(0) }) + test("/ and ? start copy search", () => { + const ctx = createHandler("abc", { mode: "copy" }) + + const slash = createEvent("/") + expect(ctx.handler.handleKey(slash.event)).toBe(true) + expect(slash.prevented()).toBe(true) + ctx.handler.handleKey(createEvent("escape").event) + + const question = createEvent("slash", { shift: true }) + expect(ctx.handler.handleKey(question.event)).toBe(true) + expect(question.prevented()).toBe(true) + + expect(ctx.copySearchCalls).toEqual(["forward", "backward"]) + expect(ctx.state.mode()).toBe("copy") + }) + + test("copy search updates as keys are typed", () => { + const ctx = createHandler("abc", { mode: "copy" }) + + ctx.handler.handleKey(createEvent("/").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("b").event) + ctx.handler.handleKey(createEvent("backspace").event) + ctx.handler.handleKey(createEvent("", { raw: "\x7f" }).event) + ctx.handler.handleKey(createEvent("delete").event) + ctx.handler.handleKey(createEvent("h", { ctrl: true }).event) + ctx.handler.handleKey(createEvent("return").event) + + expect(ctx.copySearchCalls).toEqual(["forward"]) + expect(ctx.copySearchAppends).toEqual(["a", "b"]) + expect(ctx.copySearchBackspaces()).toBe(4) + expect(ctx.copySearchSubmits()).toBe(1) + expect(ctx.copySearchCancels()).toBe(0) + expect(ctx.copySearchActive()).toBe(false) + }) + + test("n and N repeat copy search", () => { + const ctx = createHandler("abc", { mode: "copy" }) + + const next = createEvent("n") + expect(ctx.handler.handleKey(next.event)).toBe(true) + expect(next.prevented()).toBe(true) + + const previous = createEvent("n", { shift: true }) + expect(ctx.handler.handleKey(previous.event)).toBe(true) + expect(previous.prevented()).toBe(true) + + expect(ctx.copySearchNexts()).toBe(1) + expect(ctx.copySearchPreviouses()).toBe(1) + expect(ctx.state.mode()).toBe("copy") + }) + test("i from copy mode starts undoable insert session", () => { const ctx = createHandler("ab", { mode: "copy" }) ctx.textarea.cursorOffset = 1 @@ -7995,6 +8105,25 @@ describe("copy mode", () => { expect(ctx.copyExitVisuals()).toBe(0) }) + test("first escape clears copy search highlights and second exits copy mode", () => { + const ctx = createHandler("abc", { mode: "copy" }) + + ctx.handler.handleKey(createEvent("/").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("return").event) + + const clear = createEvent("escape") + expect(ctx.handler.handleKey(clear.event)).toBe(true) + expect(clear.prevented()).toBe(true) + expect(ctx.copySearchClears()).toBe(1) + expect(ctx.state.mode()).toBe("copy") + + const exit = createEvent("escape") + expect(ctx.handler.handleKey(exit.event)).toBe(true) + expect(exit.prevented()).toBe(true) + expect(ctx.state.mode()).toBe("normal") + }) + test("escape exits visual submode without leaving copy mode", () => { const ctx = createHandler("abc", { mode: "copy", copy: { isVisual: true } }) From d6dc54f5f21d1b135ce75a78a6c2f578c0a62333 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sat, 30 May 2026 14:21:49 +0400 Subject: [PATCH 02/15] refactor: search state --- .../cli/cmd/tui/routes/session/copy-mode.ts | 32 +++++++++---------- .../opencode/test/cli/tui/vim-motions.test.ts | 6 ++-- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts index be06e239f4b0..23020110d260 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts +++ b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts @@ -83,15 +83,9 @@ export function createCopyMode(input: { const [yankLineFlash, setYankLineFlash] = createSignal(undefined) const [yankRangeFlash, setYankRangeFlash] = createSignal<{ start: Endpoint; end: Endpoint } | undefined>(undefined) const [activeSearch, setActiveSearch] = createSignal(undefined) - const [searchVersion, setSearchVersion] = createSignal(0) + const [lastSearch, setLastSearch] = createSignal(undefined) let yankFlashTimer: ReturnType | undefined let lastCursor: CopyRow | undefined - let lastSearch: CopySearch | undefined - - function setLastSearch(next: CopySearch | undefined) { - lastSearch = next - setSearchVersion((value) => value + 1) - } function flashYankRange(start: Endpoint, end: Endpoint) { setYankRangeFlash(orderEndpoints(start, end)) @@ -868,14 +862,14 @@ export function createCopyMode(input: { setActiveSearch(undefined) return true } - if (!lastSearch) return false + if (!lastSearch()) return false setLastSearch(undefined) return true } function repeatSearch(reverse = false) { - if (!lastSearch) return false - const previous = lastSearch + const previous = lastSearch() + if (!previous) return false const direction = reverse ? (previous.direction === "forward" ? "backward" : "forward") : previous.direction const moved = search(previous.query, direction) if (moved && reverse) setLastSearch(previous) @@ -1099,12 +1093,12 @@ export function createCopyMode(input: { const flashIdx = yankLineFlash() const addHighlight = (row: CopyRow, min: number, text: string, left: number, right: number, current = false) => { if (left > right) return - const entry = { + const entry: CopyHighlight = { line: row.line, left, right, text: text.slice(Math.max(0, left - min), Math.max(0, right - min + 1)), - current, + ...(current ? { current: true } : {}), } const arr = out.get(row.id) if (arr) arr.push(entry) @@ -1120,15 +1114,21 @@ export function createCopyMode(input: { .map((c) => [c.id, c]), ) - searchVersion() - const searchQuery = activeSearch() ? activeSearch()?.query : lastSearch?.query + const searchQuery = activeSearch() ? activeSearch()?.query : lastSearch()?.query if (searchQuery) { for (const match of searchMatches(searchQuery)) { const row = list[match.idx] if (!row) continue const text = rowText(row, cache) || "" const min = copyMin(row, cache) - addHighlight(row, min, text, match.col, match.col + searchQuery.length - 1, match.idx === s.idx && match.col === s.col) + addHighlight( + row, + min, + text, + match.col, + match.col + searchQuery.length - 1, + match.idx === s.idx && match.col === s.col, + ) } } @@ -1227,7 +1227,7 @@ export function createCopyMode(input: { searchCancel: cancelSearch, searchClear: clearSearch, searchActive: () => activeSearch() !== undefined, - searchHighlighted: () => (searchVersion(), activeSearch() !== undefined || lastSearch !== undefined), + searchHighlighted: () => activeSearch() !== undefined || lastSearch() !== undefined, searchDisplay: () => { const search = activeSearch() if (!search) return undefined diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 709683475fc5..19d3a2bf1275 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -7590,9 +7590,9 @@ describe("copy mode", () => { cm.prompt.setCol(11) expect(cm.prompt.yankMatchingBracket()).toEqual({ text: "(\n value\n)", linewise: false }) expect(cm.highlights().get("text-part")).toEqual([ - { line: 0, left: 11, right: 11, text: "(", current: false }, - { line: 1, left: 7, right: 13, text: " value", current: false }, - { line: 2, left: 7, right: 7, text: ")", current: false }, + { line: 0, left: 11, right: 11, text: "(" }, + { line: 1, left: 7, right: 13, text: " value" }, + { line: 2, left: 7, right: 7, text: ")" }, ]) await new Promise((resolve) => setTimeout(resolve, 100)) From 352e535e59c8c6b37ef55d3b073b5a52b1085189 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sat, 30 May 2026 14:23:30 +0400 Subject: [PATCH 03/15] refactor: rename search direction types --- .../src/cli/cmd/tui/component/prompt/index.tsx | 4 +++- .../src/cli/cmd/tui/component/vim/vim-handler.ts | 3 ++- .../src/cli/cmd/tui/routes/session/copy-mode.ts | 10 ++++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 8a09fe877142..df01d5f7195f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -80,6 +80,8 @@ import { VIM_WINDOW_TOKEN, } from "../../keymap" +type CopySearchDirection = "forward" | "backward" + export type PromptProps = { sessionID?: string workspaceID?: string @@ -115,7 +117,7 @@ export type PromptProps = { matchingBracket: () => boolean nextParagraph: () => boolean previousParagraph: () => boolean - searchStart: (direction: "forward" | "backward") => void + searchStart: (direction: CopySearchDirection) => void searchAppend: (value: string) => boolean searchBackspace: () => boolean searchSubmit: () => boolean 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 40cb2d24fee6..468ec7565a3d 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 @@ -84,6 +84,7 @@ export type VimEvent = { export type VimCopyMove = "up" | "down" | "left" | "right" type VimFindOperator = "f" | "F" | "t" | "T" +type VimSearchDirection = "forward" | "backward" type VimTextObjectScope = "inner" | "around" type VimKeyLike = { name?: string; shift?: boolean; sequence?: string; raw?: string } @@ -143,7 +144,7 @@ export function createVimHandler(input: { copyMatchingBracket?: () => boolean copyNextParagraph?: () => boolean copyPreviousParagraph?: () => boolean - copySearchStart?: (direction: "forward" | "backward") => void + copySearchStart?: (direction: VimSearchDirection) => void copySearchAppend?: (value: string) => boolean copySearchBackspace?: () => boolean copySearchSubmit?: () => boolean diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts index 23020110d260..cf95e07d5001 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts +++ b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts @@ -41,9 +41,11 @@ type CopyState = { anchor: undefined | { idx: number; col: number } } +type CopySearchDirection = "forward" | "backward" + type CopySearch = { query: string - direction: "forward" | "backward" + direction: CopySearchDirection } type CopySearchMatch = { @@ -790,7 +792,7 @@ export function createCopyMode(input: { }) } - function pickSearchMatch(matches: CopySearchMatch[], direction: "forward" | "backward") { + function pickSearchMatch(matches: CopySearchMatch[], direction: CopySearchDirection) { const s = state() if (direction === "forward") { return matches.find((match) => match.idx > s.idx || (match.idx === s.idx && match.col > s.col)) ?? matches[0] @@ -816,7 +818,7 @@ export function createCopyMode(input: { return true } - function search(query: string, direction: "forward" | "backward") { + function search(query: string, direction: CopySearchDirection) { const matches = searchMatches(query) const match = pickSearchMatch(matches, direction) if (!match) return false @@ -824,7 +826,7 @@ export function createCopyMode(input: { return moveToSearchMatch(match) } - function startSearch(direction: "forward" | "backward") { + function startSearch(direction: CopySearchDirection) { setActiveSearch({ query: "", direction }) } From 48fc20556c77d5fd3c5a33120eb3b666c5ef7fd7 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sat, 30 May 2026 14:32:53 +0400 Subject: [PATCH 04/15] fix: clear stale copy search on failed submit --- packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts index cf95e07d5001..d89a03dded17 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts +++ b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts @@ -851,7 +851,7 @@ export function createCopyMode(input: { setActiveSearch(undefined) if (!current?.query) return true const found = searchMatches(current.query).length > 0 - if (found) setLastSearch(current) + setLastSearch(found ? current : undefined) return found } From 23dd875e8624959cf932a93fff2349fd329fdb34 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sat, 30 May 2026 14:33:59 +0400 Subject: [PATCH 05/15] refactor: simplify copy search clearing --- .../opencode/src/cli/cmd/tui/routes/session/copy-mode.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts index d89a03dded17..27f9de74fae8 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts +++ b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts @@ -860,12 +860,8 @@ export function createCopyMode(input: { } function clearSearch() { - if (activeSearch()) { - setActiveSearch(undefined) - return true - } - if (!lastSearch()) return false - setLastSearch(undefined) + if (!activeSearch() && !lastSearch()) return false + clearSearchState() return true } From a98d4644c1129e4e782affcbecbfa614ec61ef2c Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sat, 30 May 2026 14:38:27 +0400 Subject: [PATCH 06/15] test: cover copy search matching --- .../opencode/test/cli/tui/vim-motions.test.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 19d3a2bf1275..c2d1caaca523 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -7599,6 +7599,74 @@ describe("copy mode", () => { expect(cm.highlights().get("text-part")).toBeUndefined() }) + test("search jumps forward and highlights current match", () => { + const cm = createRenderedCopyMode(["alpha", "beta alpha", "alpha"]) + + cm.prompt.searchStart("forward") + expect(cm.prompt.searchAppend("alpha")).toBe(true) + + expect(cm.state().idx).toBe(1) + expect(cm.state().col).toBe(12) + expect(cm.highlights().get("text-part")).toEqual([ + { line: 0, left: 7, right: 11, text: "alpha" }, + { line: 1, left: 12, right: 16, text: "alpha", current: true }, + { line: 2, left: 7, right: 11, text: "alpha" }, + ]) + }) + + test("search jumps backward and wraps", () => { + const cm = createRenderedCopyMode(["alpha", "beta alpha", "alpha"]) + + cm.prompt.searchStart("backward") + expect(cm.prompt.searchAppend("alpha")).toBe(true) + + expect(cm.state().idx).toBe(2) + expect(cm.state().col).toBe(7) + }) + + test("search repeat cycles through matches", () => { + const cm = createRenderedCopyMode(["alpha", "beta alpha", "alpha"]) + + cm.prompt.searchStart("forward") + cm.prompt.searchAppend("alpha") + expect(cm.prompt.searchSubmit()).toBe(true) + + expect(cm.state().idx).toBe(1) + expect(cm.prompt.searchNext()).toBe(true) + expect(cm.state().idx).toBe(2) + expect(cm.prompt.searchPrevious()).toBe(true) + expect(cm.state().idx).toBe(1) + }) + + test("failed search submit clears stale highlights", () => { + const cm = createRenderedCopyMode(["alpha", "beta alpha", "alpha"]) + + cm.prompt.searchStart("forward") + cm.prompt.searchAppend("alpha") + expect(cm.prompt.searchSubmit()).toBe(true) + expect(cm.highlights().get("text-part")?.length).toBe(3) + + cm.prompt.searchStart("forward") + expect(cm.prompt.searchAppend("missing")).toBe(false) + expect(cm.prompt.searchSubmit()).toBe(false) + + expect(cm.prompt.searchHighlighted()).toBe(false) + expect(cm.highlights().get("text-part")).toBeUndefined() + }) + + test("search uses smartcase matching", () => { + const cm = createRenderedCopyMode(["error", "Error"]) + + cm.prompt.searchStart("forward") + expect(cm.prompt.searchAppend("error")).toBe(true) + expect(cm.highlights().get("text-part")?.map((highlight) => highlight.text)).toEqual(["error", "Error"]) + + cm.prompt.searchCancel() + cm.prompt.searchStart("forward") + expect(cm.prompt.searchAppend("Error")).toBe(true) + expect(cm.highlights().get("text-part")).toEqual([{ line: 1, left: 7, right: 11, text: "Error", current: true }]) + }) + test("word motions use copy row minimum columns", () => { const cm = createRenderedCopyMode(["alpha beta", " gamma delta"]) From ee7377a4763b0c13a17c7581f3dbc70e15f627d6 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sat, 30 May 2026 14:50:42 +0400 Subject: [PATCH 07/15] docs: document copy mode search --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 28b32acb62b5..6aa672d38b12 100644 --- a/README.md +++ b/README.md @@ -99,9 +99,15 @@ Text selection from the chat session view. | `i` | Focus the prompt input in insert mode without scrolling | | `z`, `zt`, `zz`, `zb` | Adjust copy-mode scroll positioning | | `H`, `M`, `L` | Jump to the top, middle, or bottom of the viewport | +| `/`, `?` | Search forward or backward in chat history | +| `n`, `N` | Repeat search in the same or opposite direction | + +When in search mode, `Enter` submits the search, and `Escape` clears search highlights before exiting copy mode. + +> Search uses smartcase, lowercase queries are case-insensitive, and queries containing uppercase letters are case-sensitive. > [!TIP] -> Configure the entry key with `keybinds.copy_mode`. +> Configure the copy mode entry key with `keybinds.copy_mode`. ### Minimal UI From de8fce0c75e62800222664b4cf218147524a0d67 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sat, 30 May 2026 14:56:30 +0400 Subject: [PATCH 08/15] refactor: reuse copy search rows for highlights --- .../cli/cmd/tui/routes/session/copy-mode.ts | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts index 27f9de74fae8..db03b08b71db 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts +++ b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts @@ -769,11 +769,13 @@ export function createCopyMode(input: { // --- search --- - function searchMatches(query: string): CopySearchMatch[] { + function childCache() { + return new Map(input.scroll().getChildren().map((c) => [c.id, c])) + } + + function searchMatches(query: string, list: CopyRow[], cache: Map): CopySearchMatch[] { const needle = query if (!needle) return [] - const list = rows() - const cache = new Map(input.scroll().getChildren().map((c) => [c.id, c])) const sensitive = /[A-Z]/.test(needle) const target = sensitive ? needle : needle.toLowerCase() return list.flatMap((row, idx) => { @@ -792,6 +794,10 @@ export function createCopyMode(input: { }) } + function currentSearchMatches(query: string) { + return searchMatches(query, rows(), childCache()) + } + function pickSearchMatch(matches: CopySearchMatch[], direction: CopySearchDirection) { const s = state() if (direction === "forward") { @@ -819,7 +825,7 @@ export function createCopyMode(input: { } function search(query: string, direction: CopySearchDirection) { - const matches = searchMatches(query) + const matches = currentSearchMatches(query) const match = pickSearchMatch(matches, direction) if (!match) return false setLastSearch({ query, direction }) @@ -850,7 +856,7 @@ export function createCopyMode(input: { const current = activeSearch() setActiveSearch(undefined) if (!current?.query) return true - const found = searchMatches(current.query).length > 0 + const found = currentSearchMatches(current.query).length > 0 setLastSearch(found ? current : undefined) return found } @@ -1105,16 +1111,11 @@ export function createCopyMode(input: { const flashRange = yankRangeFlash() const list = rows() - const cache = new Map( - input - .scroll() - .getChildren() - .map((c) => [c.id, c]), - ) + const cache = childCache() const searchQuery = activeSearch() ? activeSearch()?.query : lastSearch()?.query if (searchQuery) { - for (const match of searchMatches(searchQuery)) { + for (const match of searchMatches(searchQuery, list, cache)) { const row = list[match.idx] if (!row) continue const text = rowText(row, cache) || "" From f6567d9bcb6e193b76a0c699e0e071b3395d6323 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sat, 30 May 2026 15:06:04 +0400 Subject: [PATCH 09/15] fix: keep copy search origin stable while typing --- .../cli/cmd/tui/routes/session/copy-mode.ts | 26 ++++++++++++------- .../opencode/test/cli/tui/vim-motions.test.ts | 12 +++++++++ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts index db03b08b71db..fccfdad528cc 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts +++ b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts @@ -43,9 +43,15 @@ type CopyState = { type CopySearchDirection = "forward" | "backward" +type CopySearchOrigin = { + idx: number + col: number +} + type CopySearch = { query: string direction: CopySearchDirection + origin: CopySearchOrigin } type CopySearchMatch = { @@ -798,13 +804,12 @@ export function createCopyMode(input: { return searchMatches(query, rows(), childCache()) } - function pickSearchMatch(matches: CopySearchMatch[], direction: CopySearchDirection) { - const s = state() + function pickSearchMatch(matches: CopySearchMatch[], direction: CopySearchDirection, origin: CopySearchOrigin) { if (direction === "forward") { - return matches.find((match) => match.idx > s.idx || (match.idx === s.idx && match.col > s.col)) ?? matches[0] + return matches.find((match) => match.idx > origin.idx || (match.idx === origin.idx && match.col > origin.col)) ?? matches[0] } return ( - matches.findLast((match) => match.idx < s.idx || (match.idx === s.idx && match.col < s.col)) ?? + matches.findLast((match) => match.idx < origin.idx || (match.idx === origin.idx && match.col < origin.col)) ?? matches[matches.length - 1] ) } @@ -824,16 +829,17 @@ export function createCopyMode(input: { return true } - function search(query: string, direction: CopySearchDirection) { + function search(query: string, direction: CopySearchDirection, origin: CopySearchOrigin = state()) { const matches = currentSearchMatches(query) - const match = pickSearchMatch(matches, direction) + const match = pickSearchMatch(matches, direction, origin) if (!match) return false - setLastSearch({ query, direction }) + setLastSearch({ query, direction, origin }) return moveToSearchMatch(match) } function startSearch(direction: CopySearchDirection) { - setActiveSearch({ query: "", direction }) + const s = state() + setActiveSearch({ query: "", direction, origin: { idx: s.idx, col: s.col } }) } function updateSearch(query: string) { @@ -841,7 +847,7 @@ export function createCopyMode(input: { if (!current) return false setActiveSearch({ ...current, query }) if (!query) return false - return search(query, current.direction) + return search(query, current.direction, current.origin) } function appendSearch(value: string) { @@ -876,7 +882,7 @@ export function createCopyMode(input: { if (!previous) return false const direction = reverse ? (previous.direction === "forward" ? "backward" : "forward") : previous.direction const moved = search(previous.query, direction) - if (moved && reverse) setLastSearch(previous) + if (moved && reverse) setLastSearch({ ...previous, origin: state() }) return moved } diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index c2d1caaca523..2dcf5642ba7e 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -7614,6 +7614,18 @@ describe("copy mode", () => { ]) }) + test("incremental search keeps the original cursor as its origin", () => { + const cm = createRenderedCopyMode(["abcdef", "abcdef", "abcdef", "abcdef"]) + + cm.prompt.searchStart("forward") + expect(cm.prompt.searchAppend("a")).toBe(true) + expect(cm.state().idx).toBe(1) + expect(cm.prompt.searchAppend("b")).toBe(true) + expect(cm.state().idx).toBe(1) + expect(cm.prompt.searchAppend("c")).toBe(true) + expect(cm.state().idx).toBe(1) + }) + test("search jumps backward and wraps", () => { const cm = createRenderedCopyMode(["alpha", "beta alpha", "alpha"]) From 36fa23aa70afd204446dc26ab48f56fce3bdc285 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sat, 30 May 2026 15:19:35 +0400 Subject: [PATCH 10/15] feat: distinguish copy search highlights --- .../cli/cmd/tui/routes/session/copy-mode.ts | 25 +++++++++++-------- .../cmd/tui/routes/session/copy-overlay.tsx | 25 +++++++++++++------ .../opencode/test/cli/tui/vim-motions.test.ts | 10 +++++--- 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts index fccfdad528cc..c31eaa4de997 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts +++ b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts @@ -29,6 +29,7 @@ export type CopyHighlight = { left: number right: number text: string + kind?: "search" current?: boolean } @@ -1101,14 +1102,22 @@ export function createCopyMode(input: { const out = new Map() if (!s.active) return out const flashIdx = yankLineFlash() - const addHighlight = (row: CopyRow, min: number, text: string, left: number, right: number, current = false) => { + const addHighlight = ( + row: CopyRow, + min: number, + text: string, + left: number, + right: number, + options?: Pick, + ) => { if (left > right) return const entry: CopyHighlight = { line: row.line, left, right, text: text.slice(Math.max(0, left - min), Math.max(0, right - min + 1)), - ...(current ? { current: true } : {}), + ...(options?.kind ? { kind: options.kind } : {}), + ...(options?.current ? { current: true } : {}), } const arr = out.get(row.id) if (arr) arr.push(entry) @@ -1126,14 +1135,10 @@ export function createCopyMode(input: { if (!row) continue const text = rowText(row, cache) || "" const min = copyMin(row, cache) - addHighlight( - row, - min, - text, - match.col, - match.col + searchQuery.length - 1, - match.idx === s.idx && match.col === s.col, - ) + addHighlight(row, min, text, match.col, match.col + searchQuery.length - 1, { + kind: "search", + current: match.idx === s.idx && match.col === s.col, + }) } } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/copy-overlay.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/copy-overlay.tsx index 0cab437bae51..f0b813a48754 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/copy-overlay.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/copy-overlay.tsx @@ -10,6 +10,7 @@ export function CopyOverlay(props: { copy?: CopyPosition; topOffset?: number; hi const { theme } = useTheme() const top = (line: number) => line + (props.topOffset ?? 0) const highlightFg = createMemo(() => selectedForeground(theme, theme.secondary)) + const searchHighlightFg = createMemo(() => selectedForeground(theme, theme.textMuted)) const currentHighlightFg = createMemo(() => selectedForeground(theme, theme.primary)) const cursorFg = createMemo(() => selectedForeground(theme, theme.text)) return ( @@ -25,13 +26,23 @@ export function CopyOverlay(props: { copy?: CopyPosition; topOffset?: number; hi /> - {(highlight) => ( - - - {highlight.text || " "} - - - )} + {(highlight) => { + const background = () => + highlight.kind === "search" ? (highlight.current ? theme.primary : theme.textMuted) : theme.secondary + const foreground = () => + highlight.kind === "search" + ? highlight.current + ? currentHighlightFg() + : searchHighlightFg() + : highlightFg() + return ( + + + {highlight.text || " "} + + + ) + }} diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 2dcf5642ba7e..90cf20a37702 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -7608,9 +7608,9 @@ describe("copy mode", () => { expect(cm.state().idx).toBe(1) expect(cm.state().col).toBe(12) expect(cm.highlights().get("text-part")).toEqual([ - { line: 0, left: 7, right: 11, text: "alpha" }, - { line: 1, left: 12, right: 16, text: "alpha", current: true }, - { line: 2, left: 7, right: 11, text: "alpha" }, + { line: 0, left: 7, right: 11, text: "alpha", kind: "search" }, + { line: 1, left: 12, right: 16, text: "alpha", kind: "search", current: true }, + { line: 2, left: 7, right: 11, text: "alpha", kind: "search" }, ]) }) @@ -7676,7 +7676,9 @@ describe("copy mode", () => { cm.prompt.searchCancel() cm.prompt.searchStart("forward") expect(cm.prompt.searchAppend("Error")).toBe(true) - expect(cm.highlights().get("text-part")).toEqual([{ line: 1, left: 7, right: 11, text: "Error", current: true }]) + expect(cm.highlights().get("text-part")).toEqual([ + { line: 1, left: 7, right: 11, text: "Error", kind: "search", current: true }, + ]) }) test("word motions use copy row minimum columns", () => { From f8c5a408d0561f6d55e7c6e95ac6353ab7a1afbb Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sat, 30 May 2026 15:32:31 +0400 Subject: [PATCH 11/15] fix: preserve literal copy search input --- .../src/cli/cmd/tui/component/vim/vim-handler.ts | 2 +- packages/opencode/test/cli/tui/vim-motions.test.ts | 10 ++++++++++ 2 files changed, 11 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 468ec7565a3d..84ade4442c4e 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 @@ -1829,7 +1829,7 @@ export function createVimHandler(input: { } if (input.state.isCopy()) { - const mapped = langmapped(event) + const mapped = input.copySearchActive?.() ? event : langmapped(event) return copy(mapped, normalizedKeyName(mapped)) } diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 90cf20a37702..120d8fae8c7c 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -8146,6 +8146,16 @@ describe("copy mode", () => { expect(ctx.copySearchActive()).toBe(false) }) + test("copy search input is not langmapped", () => { + const ctx = createHandler("abc", { mode: "copy", langmap: { д: "j" } }) + + ctx.handler.handleKey(createEvent("/").event) + ctx.handler.handleKey(createEvent("д").event) + + expect(ctx.copySearchAppends).toEqual(["д"]) + expect(ctx.copyMoves).toEqual([]) + }) + test("n and N repeat copy search", () => { const ctx = createHandler("abc", { mode: "copy" }) From 8f7f89f9317944b31b0b3f303229a1c10089ddd0 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sat, 30 May 2026 16:13:50 +0400 Subject: [PATCH 12/15] fix: restore copy search origin on failed incsearch --- .../cli/cmd/tui/routes/session/copy-mode.ts | 17 +++++++++-- .../opencode/test/cli/tui/vim-motions.test.ts | 28 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts index c31eaa4de997..9febffb83acb 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts +++ b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts @@ -830,6 +830,11 @@ export function createCopyMode(input: { return true } + function restoreSearchOrigin(search: CopySearch) { + sync(search.origin.idx) + setCol(search.origin.col) + } + function search(query: string, direction: CopySearchDirection, origin: CopySearchOrigin = state()) { const matches = currentSearchMatches(query) const match = pickSearchMatch(matches, direction, origin) @@ -847,8 +852,13 @@ export function createCopyMode(input: { const current = activeSearch() if (!current) return false setActiveSearch({ ...current, query }) - if (!query) return false - return search(query, current.direction, current.origin) + if (!query) { + restoreSearchOrigin(current) + return false + } + const found = search(query, current.direction, current.origin) + if (!found) restoreSearchOrigin(current) + return found } function appendSearch(value: string) { @@ -864,11 +874,14 @@ export function createCopyMode(input: { setActiveSearch(undefined) if (!current?.query) return true const found = currentSearchMatches(current.query).length > 0 + if (!found) restoreSearchOrigin(current) setLastSearch(found ? current : undefined) return found } function cancelSearch() { + const current = activeSearch() + if (current) restoreSearchOrigin(current) clearSearchState() } diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 120d8fae8c7c..ba48fb66de7c 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -7666,6 +7666,34 @@ describe("copy mode", () => { expect(cm.highlights().get("text-part")).toBeUndefined() }) + test("failed incremental search restores the original cursor", () => { + const cm = createRenderedCopyMode(["alpha", "beta alpha", "alpha"]) + + cm.prompt.searchStart("forward") + expect(cm.prompt.searchAppend("alpha")).toBe(true) + expect(cm.state().idx).toBe(1) + expect(cm.prompt.searchAppend("z")).toBe(false) + + expect(cm.state().idx).toBe(0) + expect(cm.state().col).toBe(7) + expect(cm.prompt.searchSubmit()).toBe(false) + expect(cm.state().idx).toBe(0) + expect(cm.state().col).toBe(7) + }) + + test("cancelled incremental search restores the original cursor", () => { + const cm = createRenderedCopyMode(["alpha", "beta alpha", "alpha"]) + + cm.prompt.searchStart("forward") + expect(cm.prompt.searchAppend("alpha")).toBe(true) + expect(cm.state().idx).toBe(1) + cm.prompt.searchCancel() + + expect(cm.state().idx).toBe(0) + expect(cm.state().col).toBe(7) + expect(cm.prompt.searchHighlighted()).toBe(false) + }) + test("search uses smartcase matching", () => { const cm = createRenderedCopyMode(["error", "Error"]) From f60c03d837c935e0a5ed43e26d2fd68430abbb8d Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sat, 30 May 2026 16:27:22 +0400 Subject: [PATCH 13/15] fix: preserve copy find targets for search keys --- .../cli/cmd/tui/component/vim/vim-handler.ts | 8 +++--- .../opencode/test/cli/tui/vim-motions.test.ts | 25 +++++++++++++++++++ 2 files changed, 29 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 84ade4442c4e..d9f758afe0a1 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 @@ -1489,28 +1489,28 @@ export function createVimHandler(input: { return false } - if (key === "/") { + if (pending === "" && key === "/") { clearCopyPending() input.copySearchStart?.("forward") event.preventDefault() return true } - if (key === "?") { + if (pending === "" && key === "?") { clearCopyPending() input.copySearchStart?.("backward") event.preventDefault() return true } - if (key === "n" && !event.shift) { + if (pending === "" && key === "n" && !event.shift) { clearCopyPending() input.copySearchNext?.() event.preventDefault() return true } - if (isShifted(event, "n")) { + if (pending === "" && isShifted(event, "n")) { clearCopyPending() input.copySearchPrevious?.() event.preventDefault() diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index ba48fb66de7c..f29f44270b4f 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -8782,6 +8782,31 @@ describe("copy mode", () => { expect(ctx.copyCol()).toBe(6) }) + test("copy mode find target takes precedence over search keys", () => { + const cases = [ + { command: "f", target: "/", text: "abc/def", col: 3 }, + { command: "t", target: "/", text: "abc/def", col: 2 }, + { command: "f", target: "?", text: "abc?def", col: 3 }, + { command: "f", target: "n", text: "banana", col: 2 }, + ] as const + + for (const item of cases) { + const ctx = createHandler("abc", { mode: "copy", copy: { text: item.text, col: 0 } }) + + ctx.handler.handleKey(createEvent(item.command).event) + expect(ctx.state.pending()).toBe(item.command) + + const target = createEvent(item.target) + expect(ctx.handler.handleKey(target.event)).toBe(true) + expect(target.prevented()).toBe(true) + expect(ctx.copyCol()).toBe(item.col) + expect(ctx.state.pending()).toBe("") + expect(ctx.copySearchCalls).toEqual([]) + expect(ctx.copySearchNexts()).toBe(0) + expect(ctx.copySearchPreviouses()).toBe(0) + } + }) + test("copy mode ctrl scroll keys still scroll", () => { const ctx = createHandler("abc", { mode: "copy" }) const keys: Array<[string, VimScroll]> = [ From 51ae2f5a14d6d236f1528b0680a3c9ade5d0ec93 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sat, 30 May 2026 16:38:20 +0400 Subject: [PATCH 14/15] fix: clear erased copy search highlights --- .../src/cli/cmd/tui/routes/session/copy-mode.ts | 12 ++++++++---- packages/opencode/test/cli/tui/vim-motions.test.ts | 13 +++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts index 9febffb83acb..2c7be0461237 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts +++ b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts @@ -835,11 +835,11 @@ export function createCopyMode(input: { setCol(search.origin.col) } - function search(query: string, direction: CopySearchDirection, origin: CopySearchOrigin = state()) { + function search(query: string, direction: CopySearchDirection, origin: CopySearchOrigin = state(), commit = true) { const matches = currentSearchMatches(query) const match = pickSearchMatch(matches, direction, origin) if (!match) return false - setLastSearch({ query, direction, origin }) + if (commit) setLastSearch({ query, direction, origin }) return moveToSearchMatch(match) } @@ -853,10 +853,11 @@ export function createCopyMode(input: { if (!current) return false setActiveSearch({ ...current, query }) if (!query) { + setLastSearch(undefined) restoreSearchOrigin(current) return false } - const found = search(query, current.direction, current.origin) + const found = search(query, current.direction, current.origin, false) if (!found) restoreSearchOrigin(current) return found } @@ -872,7 +873,10 @@ export function createCopyMode(input: { function submitSearch() { const current = activeSearch() setActiveSearch(undefined) - if (!current?.query) return true + if (!current?.query) { + setLastSearch(undefined) + return true + } const found = currentSearchMatches(current.query).length > 0 if (!found) restoreSearchOrigin(current) setLastSearch(found ? current : undefined) diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index f29f44270b4f..54837095b532 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -7681,6 +7681,19 @@ describe("copy mode", () => { expect(cm.state().col).toBe(7) }) + test("erasing incremental search before submit clears prefix highlights", () => { + const cm = createRenderedCopyMode(["sample", "example sample"]) + + cm.prompt.searchStart("forward") + Array.from("sample").forEach((char) => expect(cm.prompt.searchAppend(char)).toBe(true)) + Array.from("sample").forEach(() => cm.prompt.searchBackspace()) + expect(cm.prompt.searchSubmit()).toBe(true) + + expect(cm.prompt.searchHighlighted()).toBe(false) + expect(cm.highlights().get("text-part")).toBeUndefined() + expect(cm.prompt.searchNext()).toBe(false) + }) + test("cancelled incremental search restores the original cursor", () => { const cm = createRenderedCopyMode(["alpha", "beta alpha", "alpha"]) From f4e3f14409d1f9ec3927f7d4dae4e54f374548a4 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sat, 30 May 2026 16:42:43 +0400 Subject: [PATCH 15/15] feat: show copy search match count --- .../opencode/src/cli/cmd/tui/component/prompt/index.tsx | 8 +++++++- .../opencode/src/cli/cmd/tui/routes/session/copy-mode.ts | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index df01d5f7195f..7bbe04510dae 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -125,6 +125,7 @@ export type PromptProps = { searchClear: () => boolean searchActive: () => boolean searchHighlighted: () => boolean + searchMatchCount: () => number searchDisplay: () => string | undefined searchNext: () => boolean searchPrevious: () => boolean @@ -1258,7 +1259,12 @@ export function Prompt(props: PromptProps) { desc: "Submit search", group: "Copy mode", cmd: () => { - if (props.copy?.searchSubmit() === false) toast.show({ message: "Pattern not found", variant: "warning" }) + if (props.copy?.searchSubmit() === false) { + toast.show({ message: "Pattern not found", variant: "warning" }) + return true + } + const count = props.copy?.searchMatchCount() ?? 0 + if (count > 0) toast.show({ message: `${count} ${count === 1 ? "match" : "matches"}`, variant: "info" }) return true }, }, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts index 2c7be0461237..0fe29b28436b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts +++ b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts @@ -895,6 +895,12 @@ export function createCopyMode(input: { return true } + function searchMatchCount() { + const query = activeSearch()?.query ?? lastSearch()?.query + if (!query) return 0 + return currentSearchMatches(query).length + } + function repeatSearch(reverse = false) { const previous = lastSearch() if (!previous) return false @@ -1255,6 +1261,7 @@ export function createCopyMode(input: { searchClear: clearSearch, searchActive: () => activeSearch() !== undefined, searchHighlighted: () => activeSearch() !== undefined || lastSearch() !== undefined, + searchMatchCount, searchDisplay: () => { const search = activeSearch() if (!search) return undefined