summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorRhys Sullivan <[email protected]>2025-12-23 15:58:00 +0000
committerGitHub <[email protected]>2025-12-23 09:58:00 -0600
commit48898fda076c532b37e61b9d56f8f1df80e568ad (patch)
tree5cda37a76846b69a550bc1d91aca621b37fe6a9c
parentc573732ddbfb3f53eefe34de45db420ad1978c19 (diff)
downloadopencode-48898fda076c532b37e61b9d56f8f1df80e568ad.tar.gz
opencode-48898fda076c532b37e61b9d56f8f1df80e568ad.zip
[feat]: prompt stashing (#6021)
-rw-r--r--packages/opencode/src/cli/cmd/tui/app.tsx21
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx86
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx122
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/stash.tsx101
4 files changed, 290 insertions, 40 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index f63f6cb1a..5105ee3c6 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<voi
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
- <DialogProvider>
- <CommandProvider>
- <PromptHistoryProvider>
- <PromptRefProvider>
- <App />
- </PromptRefProvider>
- </PromptHistoryProvider>
- </CommandProvider>
- </DialogProvider>
+ <PromptStashProvider>
+ <DialogProvider>
+ <CommandProvider>
+ <PromptHistoryProvider>
+ <PromptRefProvider>
+ <App />
+ </PromptRefProvider>
+ </PromptHistoryProvider>
+ </CommandProvider>
+ </DialogProvider>
+ </PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
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 000000000..29f2d78dc
--- /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<number>()
+
+ 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 (
+ <DialogSelect
+ title="Stash"
+ options={options()}
+ onMove={() => {
+ 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 47940d0e2..10779a5e5 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<number, number>
+ 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<number, number>
- 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(() => (
+ <DialogStash
+ onSelect={(entry) => {
+ 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 000000000..fd1cba86b
--- /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<StashEntry, "timestamp">) {
+ 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(() => {})
+ },
+ }
+ },
+})