Rhys Sullivan 3 месяцев назад
Родитель
Сommit
48898fda07

+ 12 - 9
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 { Home } from "@tui/routes/home"
 import { Session } from "@tui/routes/session"
 import { Session } from "@tui/routes/session"
 import { PromptHistoryProvider } from "./component/prompt/history"
 import { PromptHistoryProvider } from "./component/prompt/history"
+import { PromptStashProvider } from "./component/prompt/stash"
 import { DialogAlert } from "./ui/dialog-alert"
 import { DialogAlert } from "./ui/dialog-alert"
 import { ToastProvider, useToast } from "./ui/toast"
 import { ToastProvider, useToast } from "./ui/toast"
 import { ExitProvider, useExit } from "./context/exit"
 import { ExitProvider, useExit } from "./context/exit"
@@ -120,15 +121,17 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise<voi
                           <ThemeProvider mode={mode}>
                           <ThemeProvider mode={mode}>
                             <LocalProvider>
                             <LocalProvider>
                               <KeybindProvider>
                               <KeybindProvider>
-                                <DialogProvider>
-                                  <CommandProvider>
-                                    <PromptHistoryProvider>
-                                      <PromptRefProvider>
-                                        <App />
-                                      </PromptRefProvider>
-                                    </PromptHistoryProvider>
-                                  </CommandProvider>
-                                </DialogProvider>
+                                <PromptStashProvider>
+                                  <DialogProvider>
+                                    <CommandProvider>
+                                      <PromptHistoryProvider>
+                                        <PromptRefProvider>
+                                          <App />
+                                        </PromptRefProvider>
+                                      </PromptHistoryProvider>
+                                    </CommandProvider>
+                                  </DialogProvider>
+                                </PromptStashProvider>
                               </KeybindProvider>
                               </KeybindProvider>
                             </LocalProvider>
                             </LocalProvider>
                           </ThemeProvider>
                           </ThemeProvider>

+ 86 - 0
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)
+          },
+        },
+      ]}
+    />
+  )
+}

+ 91 - 31
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 { useKeybind } from "@tui/context/keybind"
 import { Keybind } from "@/util/keybind"
 import { Keybind } from "@/util/keybind"
 import { usePromptHistory, type PromptInfo } from "./history"
 import { usePromptHistory, type PromptInfo } from "./history"
+import { usePromptStash } from "./stash"
+import { DialogStash } from "../dialog-stash"
 import { type AutocompleteRef, Autocomplete } from "./autocomplete"
 import { type AutocompleteRef, Autocomplete } from "./autocomplete"
 import { useCommandDialog } from "../dialog-command"
 import { useCommandDialog } from "../dialog-command"
 import { useRenderer, useTerminalDimensions } from "@opentui/solid"
 import { useRenderer, useTerminalDimensions } from "@opentui/solid"
@@ -118,6 +120,7 @@ export function Prompt(props: PromptProps) {
   const toast = useToast()
   const toast = useToast()
   const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
   const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
   const history = usePromptHistory()
   const history = usePromptHistory()
+  const stash = usePromptStash()
   const command = useCommandDialog()
   const command = useCommandDialog()
   const renderer = useRenderer()
   const renderer = useRenderer()
   const dimensions = useTerminalDimensions()
   const dimensions = useTerminalDimensions()
@@ -151,6 +154,39 @@ export function Prompt(props: PromptProps) {
   const pasteStyleId = syntax().getStyleId("extmark.paste")!
   const pasteStyleId = syntax().getStyleId("extmark.paste")!
   let promptPartTypeId: number
   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(() => {
   command.register(() => {
     return [
     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(() => {
   createEffect(() => {
     input.focus()
     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?.({
   props.ref?.({
     get focused() {
     get focused() {
       return input.focused
       return input.focused

+ 101 - 0
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(() => {})
+      },
+    }
+  },
+})