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..69274ca438a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx @@ -3,6 +3,7 @@ import { Global } from "@/global" import { onMount } from "solid-js" import { createStore, produce } from "solid-js/store" import { clone } from "remeda" +import * as fuzzysort from "fuzzysort" import { createSimpleContext } from "../../context/helper" import { appendFile, writeFile } from "fs/promises" import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2" @@ -14,14 +15,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 +52,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(() => { }) } }) @@ -97,11 +98,18 @@ 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(() => { }) + }, + search(query: string): PromptInfo[] { + const items = store.history.slice().reverse() + if (!query.trim()) return items + return fuzzysort + .go(query, items, { key: "input" }) + .map((r) => r.obj) as PromptInfo[] }, } }, 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 8576dd5763a..52f3e2da26f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -32,6 +32,7 @@ import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" +import { DialogHistorySearch } from "../../ui/dialog-history-search" export type PromptProps = { sessionID?: string @@ -529,9 +530,9 @@ export function Prompt(props: PromptProps) { const sessionID = props.sessionID ? props.sessionID : await (async () => { - const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id) - return sessionID - })() + const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id) + return sessionID + })() const messageID = Identifier.ascending("message") let inputText = store.prompt.input @@ -620,7 +621,7 @@ export function Prompt(props: PromptProps) { })), ], }) - .catch(() => {}) + .catch(() => { }) } history.append({ ...store.prompt, @@ -885,6 +886,23 @@ export function Prompt(props: PromptProps) { if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0 if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1) input.cursorOffset = input.plainText.length + + if (keybind.match("history_search", e)) { + e.preventDefault() + dialog.replace(() => ( + history.search(query)} + onSelect={(item) => { + input.setText(item.input) + setStore("prompt", item) + setStore("mode", item.mode ?? "normal") + restoreExtmarksFromParts(item.parts) + input.cursorOffset = input.plainText.length + }} + /> + )) + return + } } }} onSubmit={submit} @@ -914,7 +932,7 @@ export function Prompt(props: PromptProps) { // Handle SVG as raw text content, not as base64 image if (file.type === "image/svg+xml") { event.preventDefault() - const content = await file.text().catch(() => {}) + const content = await file.text().catch(() => { }) if (content) { pasteText(content, `[SVG: ${file.name ?? "image"}]`) return @@ -925,7 +943,7 @@ export function Prompt(props: PromptProps) { const content = await file .arrayBuffer() .then((buffer) => Buffer.from(buffer).toString("base64")) - .catch(() => {}) + .catch(() => { }) if (content) { await pasteImage({ filename: file.name, @@ -935,7 +953,7 @@ export function Prompt(props: PromptProps) { return } } - } catch {} + } catch { } } const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 @@ -1010,13 +1028,13 @@ export function Prompt(props: PromptProps) { customBorderChars={ theme.backgroundElement.a !== 0 ? { - ...EmptyBorder, - horizontal: "▀", - } + ...EmptyBorder, + horizontal: "▀", + } : { - ...EmptyBorder, - horizontal: " ", - } + ...EmptyBorder, + horizontal: " ", + } } /> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-history-search.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-history-search.tsx new file mode 100644 index 00000000000..9108ee71a5c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-history-search.tsx @@ -0,0 +1,144 @@ +import { TextAttributes } from "@opentui/core" +import { useTheme, selectedForeground } from "@tui/context/theme" +import { createMemo, For, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { useDialog } from "@tui/ui/dialog" +import type { PromptInfo } from "@tui/component/prompt/history" +import { Locale } from "@/util/locale" + +export interface DialogHistorySearchProps { + onSelect: (item: PromptInfo) => void + searchHistory: (query: string) => PromptInfo[] +} + +export function DialogHistorySearch(props: DialogHistorySearchProps) { + const dialog = useDialog() + const { theme } = useTheme() + const [store, setStore] = createStore({ + selected: 0, + filter: "", + }) + + const filtered = createMemo(() => props.searchHistory(store.filter)) + + const dimensions = useTerminalDimensions() + const height = createMemo(() => Math.min(filtered().length, Math.floor(dimensions().height / 2) - 6)) + + const selected = createMemo(() => filtered()[store.selected]) + + function move(direction: number) { + if (filtered().length === 0) return + let next = store.selected + direction + if (next < 0) next = filtered().length - 1 + if (next >= filtered().length) next = 0 + setStore("selected", next) + } + + useKeyboard((evt) => { + if (evt.name === "up" || (evt.ctrl && evt.name === "p") || (evt.ctrl && evt.name === "r")) move(-1) + if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1) + if (evt.name === "pageup") move(-10) + if (evt.name === "pagedown") move(10) + + if (evt.name === "return") { + const item = selected() + if (item) { + evt.preventDefault() + evt.stopPropagation() + dialog.clear() + props.onSelect(item) + } + } + }) + + return ( + + + + + Search History + + esc + + + { + setStore("filter", e) + setStore("selected", 0) + }} + focusedBackgroundColor={theme.backgroundPanel} + cursorColor={theme.primary} + focusedTextColor={theme.textMuted} + ref={(r) => { + setTimeout(() => { + if (!r || r.isDestroyed) return + r.focus() + }, 1) + }} + placeholder="(reverse-i-search)" + /> + + + 0} + fallback={ + + No history found + + } + > + + + {(item, index) => { + const active = createMemo(() => index() === store.selected) + const fg = selectedForeground(theme) + return ( + { + dialog.clear() + props.onSelect(item) + }} + onMouseOver={() => setStore("selected", index())} + > + + {Locale.truncate(item.input.replace(/\n/g, " "), 70)} + + + ) + }} + + + + + + + Select{" "} + + Enter + + + + Next{" "} + + Ctrl+R + + + + ) +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6dd0592d51e..a99e13a8db9 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -122,12 +122,12 @@ export namespace Config { // Only scan project .opencode/ directories when project discovery is enabled ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? await Array.fromAsync( - Filesystem.up({ - targets: [".opencode"], - start: Instance.directory, - stop: Instance.worktree, - }), - ) + Filesystem.up({ + targets: [".opencode"], + start: Instance.directory, + stop: Instance.worktree, + }), + ) : []), // Always scan ~/.opencode/ (user home directory) ...(await Array.fromAsync( @@ -771,7 +771,7 @@ export namespace Config { session_list: z.string().optional().default("l").describe("List all sessions"), session_timeline: z.string().optional().default("g").describe("Show session timeline"), session_fork: z.string().optional().default("none").describe("Fork session from message"), - session_rename: z.string().optional().default("ctrl+r").describe("Rename session"), + session_rename: z.string().optional().default("R").describe("Rename session"), session_delete: z.string().optional().default("ctrl+d").describe("Delete session"), stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"), model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"), @@ -901,6 +901,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 prompt 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"), @@ -1214,7 +1215,7 @@ export namespace Config { await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2)) await fs.unlink(legacy) }) - .catch(() => {}) + .catch(() => { }) } return result @@ -1305,7 +1306,7 @@ export namespace Config { parsed.data.$schema = "https://opencode.ai/config.json" // Write the $schema to the original text to preserve variables like {env:VAR} const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') - await Bun.write(configFilepath, updated).catch(() => {}) + await Bun.write(configFilepath, updated).catch(() => { }) } const data = parsed.data if (data.plugin) { @@ -1313,7 +1314,7 @@ export namespace Config { const plugin = data.plugin[i] try { data.plugin[i] = import.meta.resolve!(plugin, configFilepath) - } catch (err) {} + } catch (err) { } } } return data diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 81df4784414..cf1049afcc4 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -525,17 +525,17 @@ export type EventPermissionReplied = { export type SessionStatus = | { - type: "idle" - } + type: "idle" + } | { - type: "retry" - attempt: number - message: string - next: number - } + type: "retry" + attempt: number + message: string + next: number + } | { - type: "busy" - } + type: "busy" + } export type EventSessionStatus = { type: "session.status" @@ -676,23 +676,23 @@ export type EventTuiCommandExecute = { type: "tui.command.execute" properties: { command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string } } @@ -1285,6 +1285,10 @@ export type KeybindsConfig = { * Next history item */ history_next?: string + /** + * Search prompt history + */ + history_search?: string /** * Next child session */ @@ -1356,26 +1360,26 @@ export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConf export type PermissionConfig = | { - __originalKeys?: Array - read?: PermissionRuleConfig - edit?: PermissionRuleConfig - glob?: PermissionRuleConfig - grep?: PermissionRuleConfig - list?: PermissionRuleConfig - bash?: PermissionRuleConfig - task?: PermissionRuleConfig - external_directory?: PermissionRuleConfig - todowrite?: PermissionActionConfig - todoread?: PermissionActionConfig - question?: PermissionActionConfig - webfetch?: PermissionActionConfig - websearch?: PermissionActionConfig - codesearch?: PermissionActionConfig - lsp?: PermissionRuleConfig - doom_loop?: PermissionActionConfig - skill?: PermissionRuleConfig - [key: string]: PermissionRuleConfig | Array | PermissionActionConfig | undefined - } + __originalKeys?: Array + read?: PermissionRuleConfig + edit?: PermissionRuleConfig + glob?: PermissionRuleConfig + grep?: PermissionRuleConfig + list?: PermissionRuleConfig + bash?: PermissionRuleConfig + task?: PermissionRuleConfig + external_directory?: PermissionRuleConfig + todowrite?: PermissionActionConfig + todoread?: PermissionActionConfig + question?: PermissionActionConfig + webfetch?: PermissionActionConfig + websearch?: PermissionActionConfig + codesearch?: PermissionActionConfig + lsp?: PermissionRuleConfig + doom_loop?: PermissionActionConfig + skill?: PermissionRuleConfig + [key: string]: PermissionRuleConfig | Array | PermissionActionConfig | undefined + } | PermissionActionConfig export type AgentConfig = { @@ -1420,30 +1424,30 @@ export type AgentConfig = { maxSteps?: number permission?: PermissionConfig [key: string]: - | unknown - | string - | number - | { - [key: string]: boolean - } - | boolean - | "subagent" - | "primary" - | "all" - | { - [key: string]: unknown - } - | string - | "primary" - | "secondary" - | "accent" - | "success" - | "warning" - | "error" - | "info" - | number - | PermissionConfig - | undefined + | unknown + | string + | number + | { + [key: string]: boolean + } + | boolean + | "subagent" + | "primary" + | "all" + | { + [key: string]: unknown + } + | string + | "primary" + | "secondary" + | "accent" + | "success" + | "warning" + | "error" + | "info" + | number + | PermissionConfig + | undefined } export type ProviderConfig = { @@ -1463,10 +1467,10 @@ export type ProviderConfig = { temperature?: boolean tool_call?: boolean interleaved?: - | true - | { - field: "reasoning_content" | "reasoning_details" - } + | true + | { + field: "reasoning_content" | "reasoning_details" + } cost?: { input: number output: number @@ -1740,43 +1744,43 @@ export type Config = { */ mcp?: { [key: string]: - | McpLocalConfig - | McpRemoteConfig - | { - enabled: boolean - } + | McpLocalConfig + | McpRemoteConfig + | { + enabled: boolean + } } formatter?: - | false - | { - [key: string]: { - disabled?: boolean - command?: Array - environment?: { - [key: string]: string - } - extensions?: Array - } + | false + | { + [key: string]: { + disabled?: boolean + command?: Array + environment?: { + [key: string]: string } + extensions?: Array + } + } lsp?: - | false + | false + | { + [key: string]: | { - [key: string]: - | { - disabled: true - } - | { - command: Array - extensions?: Array - disabled?: boolean - env?: { - [key: string]: string - } - initialization?: { - [key: string]: unknown - } - } + disabled: true + } + | { + command: Array + extensions?: Array + disabled?: boolean + env?: { + [key: string]: string } + initialization?: { + [key: string]: unknown + } + } + } /** * Additional instruction files or patterns to include */ @@ -1894,10 +1898,10 @@ export type Model = { pdf: boolean } interleaved: - | boolean - | { - field: "reasoning_content" | "reasoning_details" - } + | boolean + | { + field: "reasoning_content" | "reasoning_details" + } } cost: { input: number @@ -3976,10 +3980,10 @@ export type ProviderListResponses = { temperature: boolean tool_call: boolean interleaved?: - | true - | { - field: "reasoning_content" | "reasoning_details" - } + | true + | { + field: "reasoning_content" | "reasoning_details" + } cost?: { input: number output: number