-
Notifications
You must be signed in to change notification settings - Fork 6.4k
[feat]: prompt stashing #6021
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[feat]: prompt stashing #6021
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| }, | ||
| }, | ||
| ]} | ||
| /> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Consider using
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Eh this matches pattern that history.tsx uses, so dw about it |
||
| 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(() => {}) | ||
| }, | ||
| } | ||
| }, | ||
| }) | ||
Uh oh!
There was an error while loading. Please reload this page.