diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index f63f6cb1a8a..5105ee3c639 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -24,6 +24,7 @@ import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" import { Session } from "@tui/routes/session" import { PromptHistoryProvider } from "./component/prompt/history" +import { PromptStashProvider } from "./component/prompt/stash" import { DialogAlert } from "./ui/dialog-alert" import { ToastProvider, useToast } from "./ui/toast" import { ExitProvider, useExit } from "./context/exit" @@ -120,15 +121,17 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise - - - - - - - - - + + + + + + + + + + + diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx new file mode 100644 index 00000000000..29f2d78dca9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx @@ -0,0 +1,86 @@ +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect } from "@tui/ui/dialog-select" +import { createMemo, createSignal } from "solid-js" +import { Locale } from "@/util/locale" +import { Keybind } from "@/util/keybind" +import { useTheme } from "../context/theme" +import { usePromptStash, type StashEntry } from "./prompt/stash" + +function getRelativeTime(timestamp: number): string { + const now = Date.now() + const diff = now - timestamp + const seconds = Math.floor(diff / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + + if (seconds < 60) return "just now" + if (minutes < 60) return `${minutes}m ago` + if (hours < 24) return `${hours}h ago` + if (days < 7) return `${days}d ago` + return Locale.datetime(timestamp) +} + +function getStashPreview(input: string, maxLength: number = 50): string { + const firstLine = input.split("\n")[0].trim() + return Locale.truncate(firstLine, maxLength) +} + +export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { + const dialog = useDialog() + const stash = usePromptStash() + const { theme } = useTheme() + + const [toDelete, setToDelete] = createSignal() + + const options = createMemo(() => { + const entries = stash.list() + // Show most recent first + return entries + .map((entry, index) => { + const isDeleting = toDelete() === index + const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1 + return { + title: isDeleting ? "Press ctrl+d again to confirm" : getStashPreview(entry.input), + bg: isDeleting ? theme.error : undefined, + value: index, + description: getRelativeTime(entry.timestamp), + footer: lineCount > 1 ? `~${lineCount} lines` : undefined, + } + }) + .toReversed() + }) + + return ( + { + setToDelete(undefined) + }} + onSelect={(option) => { + const entries = stash.list() + const entry = entries[option.value] + if (entry) { + stash.remove(option.value) + props.onSelect(entry) + } + dialog.clear() + }} + keybind={[ + { + keybind: Keybind.parse("ctrl+d")[0], + title: "delete", + onTrigger: (option) => { + if (toDelete() === option.value) { + stash.remove(option.value) + setToDelete(undefined) + return + } + setToDelete(option.value) + }, + }, + ]} + /> + ) +} 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 47940d0e234..10779a5e53a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -12,6 +12,8 @@ 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 { usePromptStash } from "./stash" +import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useCommandDialog } from "../dialog-command" import { useRenderer, useTerminalDimensions } from "@opentui/solid" @@ -118,6 +120,7 @@ export function Prompt(props: PromptProps) { const toast = useToast() const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) const history = usePromptHistory() + const stash = usePromptStash() const command = useCommandDialog() const renderer = useRenderer() const dimensions = useTerminalDimensions() @@ -151,6 +154,39 @@ export function Prompt(props: PromptProps) { const pasteStyleId = syntax().getStyleId("extmark.paste")! let promptPartTypeId: number + + sdk.event.on(TuiEvent.PromptAppend.type, (evt) => { + input.insertText(evt.properties.text) + setTimeout(() => { + input.getLayoutNode().markDirty() + input.gotoBufferEnd() + renderer.requestRender() + }, 0) + }) + + createEffect(() => { + if (props.disabled) input.cursorColor = theme.backgroundElement + if (!props.disabled) input.cursorColor = theme.text + }) + + const [store, setStore] = createStore<{ + prompt: PromptInfo + mode: "normal" | "shell" + extmarkToPartIndex: Map + interrupt: number + placeholder: number + }>({ + placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), + prompt: { + input: "", + parts: [], + }, + mode: "normal", + extmarkToPartIndex: new Map(), + interrupt: 0, + }) + + command.register(() => { return [ { @@ -311,37 +347,6 @@ export function Prompt(props: PromptProps) { ] }) - sdk.event.on(TuiEvent.PromptAppend.type, (evt) => { - input.insertText(evt.properties.text) - setTimeout(() => { - input.getLayoutNode().markDirty() - input.gotoBufferEnd() - renderer.requestRender() - }, 0) - }) - - createEffect(() => { - if (props.disabled) input.cursorColor = theme.backgroundElement - if (!props.disabled) input.cursorColor = theme.text - }) - - const [store, setStore] = createStore<{ - prompt: PromptInfo - mode: "normal" | "shell" - extmarkToPartIndex: Map - interrupt: number - placeholder: number - }>({ - placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), - prompt: { - input: "", - parts: [], - }, - mode: "normal", - extmarkToPartIndex: new Map(), - interrupt: 0, - }) - createEffect(() => { input.focus() }) @@ -428,6 +433,61 @@ export function Prompt(props: PromptProps) { ) } + command.register(() => [ + { + title: "Stash prompt", + value: "prompt.stash", + category: "Prompt", + disabled: !store.prompt.input, + onSelect: (dialog) => { + if (!store.prompt.input) return + stash.push({ + input: store.prompt.input, + parts: store.prompt.parts, + }) + input.extmarks.clear() + input.clear() + setStore("prompt", { input: "", parts: [] }) + setStore("extmarkToPartIndex", new Map()) + dialog.clear() + }, + }, + { + title: "Stash pop", + value: "prompt.stash.pop", + category: "Prompt", + disabled: stash.list().length === 0, + onSelect: (dialog) => { + const entry = stash.pop() + if (entry) { + input.setText(entry.input) + setStore("prompt", { input: entry.input, parts: entry.parts }) + restoreExtmarksFromParts(entry.parts) + input.gotoBufferEnd() + } + dialog.clear() + }, + }, + { + title: "Stash list", + value: "prompt.stash.list", + category: "Prompt", + disabled: stash.list().length === 0, + onSelect: (dialog) => { + dialog.replace(() => ( + { + input.setText(entry.input) + setStore("prompt", { input: entry.input, parts: entry.parts }) + restoreExtmarksFromParts(entry.parts) + input.gotoBufferEnd() + }} + /> + )) + }, + }, + ]) + props.ref?.({ get focused() { return input.focused diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/stash.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/stash.tsx new file mode 100644 index 00000000000..fd1cba86be0 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/stash.tsx @@ -0,0 +1,101 @@ +import path from "path" +import { Global } from "@/global" +import { onMount } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { clone } from "remeda" +import { createSimpleContext } from "../../context/helper" +import { appendFile, writeFile } from "fs/promises" +import type { PromptInfo } from "./history" + +export type StashEntry = { + input: string + parts: PromptInfo["parts"] + timestamp: number +} + +const MAX_STASH_ENTRIES = 50 + +export const { use: usePromptStash, provider: PromptStashProvider } = createSimpleContext({ + name: "PromptStash", + init: () => { + const stashFile = Bun.file(path.join(Global.Path.state, "prompt-stash.jsonl")) + onMount(async () => { + const text = await stashFile.text().catch(() => "") + const lines = text + .split("\n") + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line) + } catch { + return null + } + }) + .filter((line): line is StashEntry => line !== null) + .slice(-MAX_STASH_ENTRIES) + + setStore("entries", lines) + + // 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(stashFile.name!, content).catch(() => {}) + } + }) + + const [store, setStore] = createStore({ + entries: [] as StashEntry[], + }) + + return { + list() { + return store.entries + }, + push(entry: Omit) { + const stash = clone({ ...entry, timestamp: Date.now() }) + let trimmed = false + setStore( + produce((draft) => { + draft.entries.push(stash) + if (draft.entries.length > MAX_STASH_ENTRIES) { + draft.entries = draft.entries.slice(-MAX_STASH_ENTRIES) + trimmed = true + } + }), + ) + + if (trimmed) { + const content = store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" + writeFile(stashFile.name!, content).catch(() => {}) + return + } + + appendFile(stashFile.name!, JSON.stringify(stash) + "\n").catch(() => {}) + }, + pop() { + if (store.entries.length === 0) return undefined + const entry = store.entries[store.entries.length - 1] + setStore( + produce((draft) => { + draft.entries.pop() + }), + ) + const content = + store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : "" + writeFile(stashFile.name!, content).catch(() => {}) + return entry + }, + remove(index: number) { + if (index < 0 || index >= store.entries.length) return + setStore( + produce((draft) => { + draft.entries.splice(index, 1) + }), + ) + const content = + store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : "" + writeFile(stashFile.name!, content).catch(() => {}) + }, + } + }, +})