From fe7be3f71350e6b387a850b00cf6df45964a3bdd Mon Sep 17 00:00:00 2001 From: isomo Date: Fri, 19 Dec 2025 10:48:47 +0800 Subject: [PATCH 1/3] feat(tui): add ctrl+r history search with fuzzy filtering --- .../tui/component/dialog-history-search.tsx | 37 +++++++++++++++++++ .../cli/cmd/tui/component/prompt/history.tsx | 28 ++++++++------ .../cli/cmd/tui/component/prompt/index.tsx | 19 ++++++++++ packages/opencode/src/config/config.ts | 1 + packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++ packages/sdk/openapi.json | 5 +++ 6 files changed, 83 insertions(+), 11 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-history-search.tsx diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-history-search.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-history-search.tsx new file mode 100644 index 000000000..07423261b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-history-search.tsx @@ -0,0 +1,37 @@ +import { createMemo } from "solid-js" +import { DialogSelect } from "@tui/ui/dialog-select" +import { useDialog } from "@tui/ui/dialog" +import type { PromptInfo } from "./prompt/history" + +export interface DialogHistorySearchProps { + items: PromptInfo[] + onSelect: (item: PromptInfo) => void +} + +export function DialogHistorySearch(props: DialogHistorySearchProps) { + const dialog = useDialog() + + const options = createMemo(() => { + const seen = new Set() + return props.items + .slice() + .reverse() + .filter((item) => { + if (!item.input.trim().length) return false + if (seen.has(item.input)) return false + seen.add(item.input) + return true + }) + .map((item) => ({ + title: item.input, + value: item, + description: undefined, + onSelect: () => { + props.onSelect(item) + dialog.clear() + }, + })) + }) + + return +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx index e90503e9f..6813845a1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx @@ -14,18 +14,18 @@ export type PromptInfo = { | Omit | Omit | (Omit & { - source?: { - text: { - start: number - end: number - value: string - } + source?: { + text: { + start: number + end: number + value: string } - }) + } + }) )[] } -const MAX_HISTORY_ENTRIES = 50 +const MAX_HISTORY_ENTRIES = 100 export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({ name: "PromptHistory", @@ -51,7 +51,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create // Rewrite file with only valid entries to self-heal corruption if (lines.length > 0) { const content = lines.map((line) => JSON.stringify(line)).join("\n") + "\n" - writeFile(historyFile.name!, content).catch(() => {}) + writeFile(historyFile.name!, content).catch(() => { }) } }) @@ -61,6 +61,9 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create }) return { + get items() { + return store.history + }, move(direction: 1 | -1, input: string) { if (!store.history.length) return undefined const current = store.history.at(store.index) @@ -97,11 +100,14 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create if (trimmed) { const content = store.history.map((line) => JSON.stringify(line)).join("\n") + "\n" - writeFile(historyFile.name!, content).catch(() => {}) + writeFile(historyFile.name!, content).catch(() => { }) return } - appendFile(historyFile.name!, JSON.stringify(entry) + "\n").catch(() => {}) + appendFile(historyFile.name!, JSON.stringify(entry) + "\n").catch(() => { }) + }, + reset() { + setStore("index", 0) }, } }, 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 99a90ab46..3769fe779 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -27,6 +27,7 @@ import { useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" +import { DialogHistorySearch } from "../dialog-history-search" export type PromptProps = { sessionID?: string @@ -763,6 +764,24 @@ export function Prompt(props: PromptProps) { } if (store.mode === "normal") autocomplete.onKeyDown(e) if (!autocomplete.visible) { + if (keybind.match("history_search", e)) { + e.preventDefault() + const historyItems = history.items + dialog.replace(() => ( + { + input.setText(item.input) + setStore("prompt", item) + setStore("mode", item.mode ?? "normal") + restoreExtmarksFromParts(item.parts) + history.reset() + }} + /> + )) + return + } + if ( (keybind.match("history_previous", e) && input.cursorOffset === 0) || (keybind.match("history_next", e) && input.cursorOffset === input.plainText.length) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a01cc832a..f25c02d49 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -558,6 +558,7 @@ export namespace Config { .describe("Delete word backward in input"), history_previous: z.string().optional().default("up").describe("Previous history item"), history_next: z.string().optional().default("down").describe("Next history item"), + history_search: z.string().optional().default("ctrl+r").describe("Search history"), session_child_cycle: z.string().optional().default("right").describe("Next child session"), session_child_cycle_reverse: z.string().optional().default("left").describe("Previous child session"), terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c96530737..036994b6e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1106,6 +1106,10 @@ export type KeybindsConfig = { * Next history item */ history_next?: string + /** + * Search history + */ + history_search?: string /** * Next child session */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 09c7ea8e9..9a657e00f 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7407,6 +7407,11 @@ "default": "down", "type": "string" }, + "history_search": { + "description": "Search history", + "default": "ctrl+r", + "type": "string" + }, "session_child_cycle": { "description": "Next child session", "default": "right", From dd21dd7ae0b6ea0c7a8c1d1080588c18fb8033e8 Mon Sep 17 00:00:00 2001 From: isomo Date: Fri, 19 Dec 2025 15:37:28 +0800 Subject: [PATCH 2/3] chore: generate --- .../cli/cmd/tui/component/prompt/history.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx index 6813845a1..36e90b950 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx @@ -14,14 +14,14 @@ export type PromptInfo = { | Omit | Omit | (Omit & { - source?: { - text: { - start: number - end: number - value: string + source?: { + text: { + start: number + end: number + value: string + } } - } - }) + }) )[] } @@ -51,7 +51,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create // Rewrite file with only valid entries to self-heal corruption if (lines.length > 0) { const content = lines.map((line) => JSON.stringify(line)).join("\n") + "\n" - writeFile(historyFile.name!, content).catch(() => { }) + writeFile(historyFile.name!, content).catch(() => {}) } }) @@ -100,11 +100,11 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create if (trimmed) { const content = store.history.map((line) => JSON.stringify(line)).join("\n") + "\n" - writeFile(historyFile.name!, content).catch(() => { }) + writeFile(historyFile.name!, content).catch(() => {}) return } - appendFile(historyFile.name!, JSON.stringify(entry) + "\n").catch(() => { }) + appendFile(historyFile.name!, JSON.stringify(entry) + "\n").catch(() => {}) }, reset() { setStore("index", 0) From 38b567a2f1bab4e3549da861e32b6e6f2e8fab41 Mon Sep 17 00:00:00 2001 From: isomo Date: Sat, 20 Dec 2025 09:30:41 +0800 Subject: [PATCH 3/3] fix: set cursor offset for multi-line history items --- packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx | 1 + 1 file changed, 1 insertion(+) 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 3769fe779..cd4890044 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -775,6 +775,7 @@ export function Prompt(props: PromptProps) { setStore("prompt", item) setStore("mode", item.mode ?? "normal") restoreExtmarksFromParts(item.parts) + input.cursorOffset = input.plainText.length history.reset() }} />