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 d49dd5c7b699..193c3dc2e472 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx @@ -26,6 +26,30 @@ export type PromptInfo = { } const MAX_HISTORY_ENTRIES = 50 +type SearchInfo = { + term: string + pos: number + value: string +} + +export function search(list: PromptInfo[], input: string, last?: SearchInfo) { + const keep = last && (input === last.value || input === last.term) + const term = keep ? last.term : input + const from = keep ? last.pos - 1 : list.length - 1 + for (let i = from; i >= 0; i--) { + const item = list[i] + if (!item) continue + if (term && !item.input.includes(term)) continue + return { + item, + next: { + term, + pos: i, + value: item.input, + } satisfies SearchInfo, + } + } +} export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({ name: "PromptHistory", @@ -58,6 +82,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create const [store, setStore] = createStore({ index: 0, history: [] as PromptInfo[], + search: undefined as SearchInfo | undefined, }) return { @@ -68,6 +93,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create if (current.input !== input && input.length) return setStore( produce((draft) => { + draft.search = undefined const next = store.index + direction if (Math.abs(next) > store.history.length) return if (next > 0) return @@ -78,9 +104,20 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create return { input: "", parts: [], - } + } return store.history.at(store.index) }, + search(input: string) { + const found = search(store.history, input, store.search) + if (!found) return + setStore( + produce((draft) => { + draft.index = 0 + draft.search = found.next + }), + ) + return found.item + }, append(item: PromptInfo) { const entry = structuredClone(unwrap(item)) let trimmed = false @@ -92,6 +129,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create trimmed = true } draft.index = 0 + draft.search = undefined }), ) 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 087742a979d7..45f039ab095e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -968,6 +968,18 @@ export function Prompt(props: PromptProps) { } if (store.mode === "normal") autocomplete.onKeyDown(e) if (!autocomplete.visible) { + if (keybind.match("history_search", e)) { + const item = history.search(input.plainText) + e.preventDefault() + if (!item) return + input.setText(item.input) + setStore("prompt", item) + setStore("mode", item.mode ?? "normal") + restoreExtmarksFromParts(item.parts) + input.gotoBufferEnd() + 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 b11ae83192ce..79bc8231eed3 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -750,6 +750,7 @@ export namespace Config { .optional() .default("ctrl+w,ctrl+backspace,alt+backspace") .describe("Delete word backward in input"), + history_search: z.string().optional().default("ctrl+r").describe("Search prompt history"), history_previous: z.string().optional().default("up").describe("Previous history item"), history_next: z.string().optional().default("down").describe("Next history item"), session_child_first: z.string().optional().default("down").describe("Go to first child session"), diff --git a/packages/opencode/test/cli/cmd/tui/prompt-history.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-history.test.ts new file mode 100644 index 000000000000..7857c0de46ab --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/prompt-history.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test" +import { search, type PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history" + +function item(input: string): PromptInfo { + return { + input, + parts: [], + } +} + +describe("prompt history", () => { + test("search finds the latest matching prompt", () => { + const list = [item("fix lint"), item("fix tests"), item("review docs")] + + const found = search(list, "fix") + + expect(found?.item.input).toBe("fix tests") + expect(found?.next.term).toBe("fix") + }) + + test("search walks backward through matching prompts", () => { + const list = [item("fix lint"), item("fix tests"), item("review docs"), item("fix docs")] + + const a = search(list, "fix") + const b = search(list, "fix docs", a?.next) + const c = search(list, "fix tests", b?.next) + + expect(a?.item.input).toBe("fix docs") + expect(b?.item.input).toBe("fix tests") + expect(c?.item.input).toBe("fix lint") + }) + + test("search falls back to all prompts when the query is empty", () => { + const list = [item("fix lint"), item("fix tests")] + + const found = search(list, "") + + expect(found?.item.input).toBe("fix tests") + expect(found?.next.term).toBe("") + }) +})