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 e90503e9f52..a97402949b9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx @@ -61,6 +61,12 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create }) return { + get items() { + return store.history + }, + setIndex(index: number) { + setStore("index", index) + }, move(direction: 1 | -1, input: string) { if (!store.history.length) return undefined const current = store.history.at(store.index) 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 f819746d53c..1d2f4f92cd0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1,4 +1,4 @@ -import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding } from "@opentui/core" +import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding, type ParsedKey } from "@opentui/core" import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" import { useLocal } from "@tui/context/local" @@ -12,6 +12,7 @@ import { createStore, produce } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" import { Keybind } from "@/util/keybind" import { usePromptHistory, type PromptInfo } from "./history" +import { clone } from "remeda" import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" @@ -173,6 +174,14 @@ export function Prompt(props: PromptProps) { extmarkToPartIndex: Map interrupt: number placeholder: number + historySearch: { + active: boolean + query: string + matchIndex: number + originalPrompt: PromptInfo + originalMode: "normal" | "shell" + originalCursorOffset: number + } }>({ placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), prompt: { @@ -182,6 +191,14 @@ export function Prompt(props: PromptProps) { mode: "normal", extmarkToPartIndex: new Map(), interrupt: 0, + historySearch: { + active: false, + query: "", + matchIndex: 0, + originalPrompt: { input: "", parts: [] }, + originalMode: "normal", + originalCursorOffset: 0, + }, }) command.register(() => { @@ -635,6 +652,125 @@ export function Prompt(props: PromptProps) { } const exit = useExit() + const historySearchMatches = createMemo(() => { + if (!store.historySearch.active) return [] + const query = store.historySearch.query.toLowerCase() + if (!query) return history.items.slice().reverse() + return history.items.filter((item) => item.input.toLowerCase().includes(query)).reverse() + }) + + function enterHistorySearch() { + command.keybinds(false) + setStore("historySearch", { + active: true, + query: "", + matchIndex: 0, + originalPrompt: clone(store.prompt), + originalMode: store.mode, + originalCursorOffset: input.cursorOffset, + }) + } + + function exitHistorySearch(restore: boolean) { + command.keybinds(true) + if (restore) { + const original = store.historySearch.originalPrompt + input.setText(original.input) + setStore("prompt", { input: original.input, parts: original.parts }) + setStore("mode", store.historySearch.originalMode) + restoreExtmarksFromParts(original.parts) + input.cursorOffset = store.historySearch.originalCursorOffset + } else { + input.cursorOffset = input.plainText.length + } + setStore("historySearch", { + active: false, + query: "", + matchIndex: 0, + originalPrompt: { input: "", parts: [] }, + originalMode: "normal", + originalCursorOffset: 0, + }) + } + + function selectHistoryMatch(match: PromptInfo) { + input.setText(match.input) + setStore("prompt", { input: match.input, parts: match.parts }) + setStore("mode", match.mode ?? "normal") + restoreExtmarksFromParts(match.parts) + // Align history navigation cursor so Up/Down continues from this match + const matchIndexInHistory = history.items.findIndex((item) => item.input === match.input) + if (matchIndexInHistory !== -1) { + history.setIndex(-(history.items.length - matchIndexInHistory)) + } + exitHistorySearch(false) + } + + function updateHistorySearchPreview() { + const matches = historySearchMatches() + if (matches.length > 0) { + const match = matches[store.historySearch.matchIndex % matches.length] + if (match) { + input.setText(match.input) + setStore("prompt", { input: match.input, parts: match.parts }) + restoreExtmarksFromParts(match.parts) + input.cursorOffset = input.plainText.length + } + } + } + + function handleHistorySearchKey(e: ParsedKey & { preventDefault: () => void }) { + e.preventDefault() + if (e.name === "escape" || (e.ctrl && e.name === "g")) { + exitHistorySearch(true) + return true + } + if (e.name === "return") { + const matches = historySearchMatches() + if (matches.length > 0) { + const match = matches[store.historySearch.matchIndex % matches.length] + if (match) selectHistoryMatch(match) + } else { + exitHistorySearch(true) + } + return true + } + if (keybind.match("history_search", e) || (e.ctrl && e.name === "r") || e.name === "up") { + const matches = historySearchMatches() + if (matches.length > 1) { + setStore("historySearch", "matchIndex", (store.historySearch.matchIndex + 1) % matches.length) + updateHistorySearchPreview() + } + return true + } + if (e.name === "down") { + const matches = historySearchMatches() + if (store.historySearch.matchIndex === 0) { + exitHistorySearch(true) + } else if (matches.length > 1) { + setStore("historySearch", "matchIndex", store.historySearch.matchIndex - 1) + updateHistorySearchPreview() + } + return true + } + if (e.name === "backspace") { + if (store.historySearch.query.length > 0) { + setStore("historySearch", "query", store.historySearch.query.slice(0, -1)) + setStore("historySearch", "matchIndex", 0) + updateHistorySearchPreview() + } + return true + } + const char = e.name === "space" ? " " : e.name + if (char && char.length === 1 && !e.ctrl && !e.meta) { + setStore("historySearch", "query", store.historySearch.query + char) + setStore("historySearch", "matchIndex", 0) + updateHistorySearchPreview() + return true + } + return true + } + function pasteText(text: string, virtualText: string) { const currentOffset = input.visualCursor.offset const extmarkStart = currentOffset @@ -801,6 +937,10 @@ export function Prompt(props: PromptProps) { // through bracketed paste, so we need to intercept the keypress and // directly read from clipboard before the terminal handles it if (keybind.match("input_paste", e)) { + if (store.historySearch.active) { + e.preventDefault() + return + } const content = await Clipboard.read() if (content?.mime.startsWith("image/")) { e.preventDefault() @@ -813,7 +953,7 @@ export function Prompt(props: PromptProps) { } // If no image, let the default paste behavior continue } - if (keybind.match("input_clear", e) && store.prompt.input !== "") { + if (!store.historySearch.active && keybind.match("input_clear", e) && store.prompt.input !== "") { input.clear() input.extmarks.clear() setStore("prompt", { @@ -831,11 +971,15 @@ export function Prompt(props: PromptProps) { return } } - if (e.name === "!" && input.visualCursor.offset === 0) { + if (!store.historySearch.active && e.name === "!" && input.visualCursor.offset === 0) { setStore("mode", "shell") e.preventDefault() return } + if (store.historySearch.active) { + handleHistorySearchKey(e) + return + } if (store.mode === "shell") { if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") { setStore("mode", "normal") @@ -845,6 +989,14 @@ export function Prompt(props: PromptProps) { } if (store.mode === "normal") autocomplete.onKeyDown(e) if (!autocomplete.visible) { + if (keybind.match("history_search", e)) { + e.preventDefault() + if (history.items.length > 0) { + enterHistorySearch() + updateHistorySearchPreview() + } + return + } if ( (keybind.match("history_previous", e) && input.cursorOffset === 0) || (keybind.match("history_next", e) && input.cursorOffset === input.plainText.length) @@ -876,6 +1028,11 @@ export function Prompt(props: PromptProps) { return } + if (store.historySearch.active) { + event.preventDefault() + return + } + // Normalize line endings at the boundary // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste // Replace CRLF first, then any remaining CR @@ -1072,6 +1229,31 @@ export function Prompt(props: PromptProps) { + + + (reverse-i-search)`{store.historySearch.query} + ':{" "} + {(() => { + const matches = historySearchMatches() + const count = matches.length + if (count === 0) return no matches + return ( + + {store.historySearch.matchIndex + 1}/{count} + + ) + })()} + + + {keybind.print("history_search")} next + + + esc cancel + + + enter select + + {keybind.print("agent_cycle")} switch agent diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 807cd46fd26..2bb5d907531 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -569,6 +569,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("Reverse 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"), session_parent: z.string().optional().default("up").describe("Go to parent session"), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5c4cc69423d..a8c60a46ee9 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1125,6 +1125,10 @@ export type KeybindsConfig = { * Next history item */ history_next?: string + /** + * Reverse search history + */ + history_search?: string /** * Next child session */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 3903566b91e..70c27ac5e4a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7691,6 +7691,11 @@ "default": "down", "type": "string" }, + "history_search": { + "description": "Reverse search history", + "default": "ctrl+r", + "type": "string" + }, "session_child_cycle": { "description": "Next child session", "default": "right",