diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 5105ee3c639..80019e8ca5e 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -122,15 +122,15 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise - - - + + + - - - + + + diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-prompt-history.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-prompt-history.tsx new file mode 100644 index 00000000000..3681e953223 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-prompt-history.tsx @@ -0,0 +1,108 @@ +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select" +import { createMemo, createSignal, onMount } from "solid-js" +import { usePromptHistory, type PromptInfo } from "./prompt/history" + +function formatPrompt(info: PromptInfo, maxLength?: number): string { + const parts = info.parts + .map((part) => { + if (part.type === "text") return part.text + if (part.type === "file") return `@${part.filename || "file"}` + if (part.type === "agent") return `/${part.name}` + return "" + }) + .filter(Boolean) + .join(" ") + + const text = (parts || info.input).trim().replace(/\n+/g, " ") + if (maxLength && text.length > maxLength) { + return text.slice(0, maxLength) + "…" + } + return text +} + +type HistoryOption = DialogSelectOption & { + historyInfo: PromptInfo +} + +const TITLE_CHAR_LIMIT = 61 +const DESCRIPTION_CHAR_LIMIT = 200 + +export function DialogPromptHistory(props: { onSelect: (info: PromptInfo) => void }) { + const dialog = useDialog() + const history = usePromptHistory() + const [activeValue, setActiveValue] = createSignal(0) + let selectRef: DialogSelectRef | undefined + + const options = createMemo(() => { + const today = new Date().toDateString() + const yesterday = new Date(Date.now() - 86400000).toDateString() + const weekAgo = Date.now() - 7 * 86400000 + const currentActiveValue = activeValue() + + // Reverse history to show most recent first + return [...history.history].reverse().map((info, index) => { + const timestamp = Date.now() - index * 60000 // Approximate timestamp + const date = new Date(timestamp) + const dateStr = date.toDateString() + + let category: string + if (dateStr === today) { + category = "Today" + } else if (dateStr === yesterday) { + category = "Yesterday" + } else if (timestamp > weekAgo) { + category = "This Week" + } else { + category = "Older" + } + + const fullPromptText = formatPrompt(info) + const modeIndicator = info.mode === "shell" ? "$ " : "" + const isActive = currentActiveValue === index + + const title = modeIndicator + fullPromptText + + // For active item with long text, set description to trigger full display (no truncation) + // The description content doesn't matter since title won't be truncated when description exists + const needsExpansion = isActive && fullPromptText.length > TITLE_CHAR_LIMIT + const description = needsExpansion ? " " : undefined + + return { + value: index, + title, + description, + category, + footer: info.mode === "shell" ? "shell" : undefined, + historyInfo: info, + } as HistoryOption + }) + }) + + onMount(() => { + dialog.setSize("large") + }) + + return ( + (selectRef = r)} + onMove={(option) => { + setActiveValue(option.value as number) + }} + onFilter={() => { + // When filter changes, first filtered item becomes active + // Get the first filtered item's value + const firstFiltered = selectRef?.filtered[0] + if (firstFiltered) { + setActiveValue(firstFiltered.value) + } + }} + onSelect={(option) => { + props.onSelect((option as HistoryOption).historyInfo) + dialog.clear() + }} + /> + ) +} 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..b5fc77103b5 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,9 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create }) return { + get history() { + return store.history + }, 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 18384f65e01..86c92579a43 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -28,6 +28,7 @@ import { createColors, createFrames } from "../../ui/spinner.ts" import { useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogAlert } from "../../ui/dialog-alert" +import { DialogPromptHistory } from "../dialog-prompt-history" import { useToast } from "../../ui/toast" export type PromptProps = { @@ -484,6 +485,25 @@ export function Prompt(props: PromptProps) { )) }, }, + { + title: "Show prompt history", + value: "prompt.history.list", + category: "Prompt", + keybind: "prompt_history_list", + onSelect: (dialog) => { + dialog.replace(() => ( + { + input.setText(item.input) + setStore("prompt", item) + setStore("mode", item.mode ?? "normal") + restoreExtmarksFromParts(item.parts) + input.cursorOffset = input.plainText.length + }} + /> + )) + }, + }, ]) props.ref?.({ @@ -614,6 +634,7 @@ export function Prompt(props: PromptProps) { } history.append({ ...store.prompt, + input: store.prompt.input.trim().replace(/\n+/g, " "), mode: currentMode, }) input.extmarks.clear() @@ -846,6 +867,23 @@ export function Prompt(props: PromptProps) { } if (store.mode === "normal") autocomplete.onKeyDown(e) if (!autocomplete.visible) { + // Show prompt history modal + if (keybind.match("prompt_history_list", e)) { + dialog.replace(() => ( + { + input.setText(item.input) + setStore("prompt", item) + setStore("mode", item.mode ?? "normal") + restoreExtmarksFromParts(item.parts) + input.cursorOffset = input.plainText.length + }} + /> + )) + e.preventDefault() + 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/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index ff9745b90fe..f39e4f60af9 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -315,7 +315,7 @@ function Option(props: { overflow="hidden" paddingLeft={3} > - {Locale.truncate(props.title, 61)} + {props.description ? props.title : Locale.truncate(props.title, 61)} {props.description} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index cb93c0ebf2a..18f1e61337f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -561,6 +561,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"), + prompt_history_list: z.string().optional().default("none").describe("Show prompt history modal"), 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 1372765e3f8..ddb705c95e4 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1114,6 +1114,10 @@ export type KeybindsConfig = { * Next history item */ history_next?: string + /** + * Show prompt history modal + */ + prompt_history_list?: string /** * Next child session */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 588b130b996..554d7dfb667 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7623,6 +7623,11 @@ "default": "down", "type": "string" }, + "prompt_history_list": { + "description": "Show prompt history modal", + "default": "none", + "type": "string" + }, "session_child_cycle": { "description": "Next child session", "default": "right",