Compare commits

...

3 Commits

Author SHA1 Message Date
R44VC0RP b92bbdeb57 refactor(tui): trim stash sync changes and regression tests 2026-09-03 12:02:08 +00:00
R44VC0RP 2385d48ae9 fix(tui): preserve stash selection during asynchronous updates 2026-09-03 11:37:54 +00:00
R44VC0RP d9c1eaac2c fix(tui): synchronize prompt stashes across terminals 2026-09-03 11:33:49 +00:00
6 changed files with 369 additions and 112 deletions
+25 -15
View File
@@ -1,10 +1,11 @@
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { createMemo, createSignal } from "solid-js"
import { createMemo, createSignal, onCleanup } from "solid-js"
import { Locale } from "../util/locale"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { usePromptStash, type StashEntry } from "../prompt/stash"
import { useToast } from "../ui/toast"
function getRelativeTime(timestamp: number): string {
const now = Date.now()
@@ -31,15 +32,20 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const stash = usePromptStash()
const theme = useTheme("elevated")
const shortcuts = Keymap.useShortcuts()
const toast = useToast()
const [toDelete, setToDelete] = createSignal<number>()
const [toDelete, setToDelete] = createSignal<string>()
let disposed = false
onCleanup(() => {
disposed = true
})
const options = createMemo(() => {
const entries = stash.list()
// Show most recent first
return entries
.map((entry, index) => {
const isDeleting = toDelete() === index
.map((entry) => {
const isDeleting = toDelete() === entry.id
const lineCount = (entry.prompt.text.match(/\n/g)?.length ?? 0) + 1
return {
title: isDeleting
@@ -47,7 +53,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
: getStashPreview(entry.prompt.text),
bg: isDeleting ? theme.background.action.destructive.focused : undefined,
fg: isDeleting ? theme.text.action.destructive.focused : undefined,
value: index,
value: entry.id,
description: getRelativeTime(entry.timestamp),
footer: lineCount > 1 ? `~${lineCount} lines` : undefined,
}
@@ -59,25 +65,29 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
<DialogSelect
title="Stash"
options={options()}
preserveSelection
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()
onSelect={async (option) => {
if (stash.pending()) return
await stash.remove(option.value).then(
(entry) => {
// A consumed entry still belongs to the caller after this dialog closes.
if (entry) props.onSelect(entry)
if (!disposed) dialog.clear()
},
(error) => toast.error(error),
)
}}
actions={[
{
command: "stash.delete",
title: "delete",
onTrigger: (option) => {
onTrigger: async (option) => {
if (stash.pending()) return
if (toDelete() === option.value) {
stash.remove(option.value)
await stash.remove(option.value).catch((error) => toast.error(error))
setToDelete(undefined)
return
}
+51 -40
View File
@@ -35,7 +35,7 @@ import { saveDraft, takeDraft } from "./draft-stash"
import { Skill } from "@opencode-ai/schema/skill"
import { computePromptTraits } from "../../prompt/traits"
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
import { usePromptStash } from "../../prompt/stash"
import { usePromptStash, type StashEntry } from "../../prompt/stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteOption, type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
@@ -209,6 +209,9 @@ export function Prompt(props: PromptProps) {
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
const history = usePromptHistory()
const stash = usePromptStash()
// A pop/list selection can wait on another terminal's write. Keep the
// composer read-only until restoration so it cannot overwrite newer input.
const disabled = () => props.disabled || stash.pending()
const keymap = Keymap.use()
const renderer = useRenderer()
const exit = useExit()
@@ -348,8 +351,8 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.disabled) input.cursorColor = theme.background.surface.offset
if (!props.disabled) input.cursorColor = theme.text.default
if (disabled()) input.cursorColor = theme.background.surface.offset
if (!disabled()) input.cursorColor = theme.text.default
if (config.cursor) input.cursorStyle = config.cursor
})
@@ -372,12 +375,13 @@ export function Prompt(props: PromptProps) {
function enqueuePaste(run: (changed: () => boolean) => Promise<void>) {
pasteQueue = pasteQueue
.then(async () => {
if (disposed || input.isDestroyed) return
if (disposed || input.isDestroyed || disabled()) return
const before = { sessionID: props.sessionID, mode: store.mode, text: input.plainText }
await run(
() =>
disposed ||
input.isDestroyed ||
disabled() ||
props.sessionID !== before.sessionID ||
store.mode !== before.mode ||
input.plainText !== before.text,
@@ -642,6 +646,9 @@ export function Prompt(props: PromptProps) {
bind: false,
palette: true as const,
...command,
enabled:
(!stash.pending() || name.startsWith("session.") || name === "prompt.images.view") &&
(!("enabled" in command) || command.enabled),
}) satisfies KeymapCommand,
),
)
@@ -733,7 +740,7 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.visible === false || props.disabled || dialog.stack.length > 0) {
if (props.visible === false || disabled() || dialog.stack.length > 0) {
if (input.focused) input.blur()
return
}
@@ -871,52 +878,56 @@ export function Prompt(props: PromptProps) {
)
}
function restoreStash(entry: StashEntry) {
if (disposed) {
saveDraft(stashSessionID, { prompt: entry.prompt, cursor: stringWidth(entry.prompt.text) })
return
}
ref.set(entry.prompt)
}
const stashCommands = createMemo(() =>
[
{
title: "Stash prompt",
name: "prompt.stash",
category: "Prompt",
enabled: !!store.prompt.text,
run: () => {
if (!store.prompt.text) return
stash.push({ prompt: store.prompt })
resetComposer()
enabled: !!store.prompt.text && !stash.pending(),
run: async () => {
if (!store.prompt.text || stash.pending()) return
const before = JSON.stringify(store.prompt)
dialog.clear()
await stash.push({ prompt: store.prompt }).then(
() => {
if (!disposed && JSON.stringify(store.prompt) === before) resetComposer()
},
(error) => toast.error(error),
)
},
},
{
title: "Stash pop",
name: "prompt.stash.pop",
category: "Prompt",
enabled: stash.list().length > 0,
run: () => {
const entry = stash.pop()
if (entry) {
input.setText(entry.prompt.text)
setStore("prompt", entry.prompt)
restoreExtmarksFromPrompt(entry.prompt)
input.gotoBufferEnd()
}
enabled: stash.list().length > 0 && !stash.pending(),
run: async () => {
if (stash.pending()) return
dialog.clear()
await stash.pop().then(
(entry) => {
if (entry) restoreStash(entry)
},
(error) => toast.error(error),
)
},
},
{
title: "Stash list",
name: "prompt.stash.list",
category: "Prompt",
enabled: stash.list().length > 0,
enabled: stash.list().length > 0 && !stash.pending(),
run: () => {
dialog.replace(() => (
<DialogStash
onSelect={(entry) => {
input.setText(entry.prompt.text)
setStore("prompt", entry.prompt)
restoreExtmarksFromPrompt(entry.prompt)
input.gotoBufferEnd()
}}
/>
))
dialog.replace(() => <DialogStash onSelect={restoreStash} />)
},
},
].map(
@@ -939,7 +950,7 @@ export function Prompt(props: PromptProps) {
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled,
enabled: inputTarget() !== undefined && !disabled(),
bindings: ["prompt.paste"],
}
})
@@ -947,7 +958,7 @@ export function Prompt(props: PromptProps) {
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
enabled: inputTarget() !== undefined && !disabled() && store.prompt.text !== "",
bindings: ["prompt.clear"],
}
})
@@ -959,7 +970,7 @@ export function Prompt(props: PromptProps) {
cursorVersion()
return (
inputTarget() !== undefined &&
!props.disabled &&
!disabled() &&
store.mode === "normal" &&
!auto()?.visible &&
input?.visualCursor.offset === 0
@@ -1016,7 +1027,7 @@ export function Prompt(props: PromptProps) {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
return inputTarget() !== undefined && !disabled() && !auto()?.visible && input !== undefined
})(),
commands: [
{
@@ -1052,7 +1063,7 @@ export function Prompt(props: PromptProps) {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
return inputTarget() !== undefined && !disabled() && !auto()?.visible && input !== undefined
})(),
commands: [
{
@@ -1110,7 +1121,7 @@ export function Prompt(props: PromptProps) {
setStore("prompt", "text", input.plainText)
syncExtmarksWithPromptParts()
}
if (props.disabled) return false
if (disabled()) return false
if (move.creating()) return false
if (auto()?.visible) return false
const trimmed = store.prompt.text.trim()
@@ -1769,7 +1780,7 @@ export function Prompt(props: PromptProps) {
}}
onCursorChange={() => setCursorVersion((value) => value + 1)}
onKeyDown={(e: { preventDefault(): void }) => {
if (props.disabled) {
if (disabled()) {
e.preventDefault()
return
}
@@ -1780,7 +1791,7 @@ export function Prompt(props: PromptProps) {
setTimeout(() => setTimeout(() => submit(), 0), 0)
}}
onPaste={(event: PasteEvent) => {
if (props.disabled) {
if (disabled()) {
event.preventDefault()
return
}
@@ -1821,7 +1832,7 @@ export function Prompt(props: PromptProps) {
}, 0)
}}
onMouseDown={(r: MouseEvent) => {
if (props.disabled || r.button !== 0) return
if (disabled() || r.button !== 0) return
r.target?.focus()
const extmark = input.extmarks
.getAtOffset(input.cursorOffset)
@@ -1831,7 +1842,7 @@ export function Prompt(props: PromptProps) {
r.stopPropagation()
}}
focusedBackgroundColor="transparent"
cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default}
cursorColor={disabled() ? theme.background.surface.offset : theme.text.default}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
+24 -13
View File
@@ -8,6 +8,8 @@ import { useTuiApp, useTuiPaths } from "./runtime"
type Options<Value extends object> = {
readonly initial: Value
/** Global stores are shared across release channels. Defaults to "channel". */
readonly scope?: "global" | "channel"
/** Reconcile key for arrays inside the stored value, preserving item identity across updates. Defaults to "id". */
readonly key?: string
}
@@ -48,16 +50,32 @@ function createStorage(root: string, channel: string) {
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
const memories = new Map<string, MemoryEntry<object>>()
const pending = new Set<Promise<void>>()
const directory = path.join(root, segment(channel), "tui")
const locks = path.join(root, segment(channel), "locks")
mkdirSync(directory, { recursive: true })
const location = path.join(root, segment(channel))
mkdirSync(path.join(location, "tui"), { recursive: true })
const watchers = new Map<string, ReturnType<typeof watch>>()
let timer: ReturnType<typeof setTimeout> | undefined
const storage: Storage = {
store<Value extends object>(key: string, options: Options<Value>) {
const base = options.scope === "global" ? root : location
const directory = path.join(base, "tui")
const file = path.join(directory, segment(key) + ".json")
const existing = entries.get(file)
if (existing) return existing.value as Entry<Value>
if (!watchers.has(directory)) {
mkdirSync(directory, { recursive: true })
watchers.set(
directory,
watch(directory, () => {
clearTimeout(timer)
// Atomic writes notify for the temporary file before its final rename, and some
// platforms coalesce the rename event. Reload after the event burst has settled.
timer = setTimeout(() => entries.forEach((entry) => entry.reload()), 50)
}),
)
}
const load = () => {
try {
return clone(JSON.parse(readFileSync(file, "utf8")) as Value)
@@ -78,7 +96,7 @@ function createStorage(root: string, channel: string) {
await writeJsonAtomic(file, next)
batch(() => setStore(merge(next)))
},
{ dir: locks },
{ dir: path.join(base, "locks") },
)
pending.add(operation)
operation.then(
@@ -110,18 +128,11 @@ function createStorage(root: string, channel: string) {
},
}
let reload: ReturnType<typeof setTimeout> | undefined
const watcher = watch(directory, () => {
clearTimeout(reload)
// Atomic writes notify for the temporary file before its final rename, and some
// platforms coalesce the rename event. Reload after the event burst has settled.
reload = setTimeout(() => entries.forEach((entry) => entry.reload()), 50)
})
return {
storage,
close: () => {
clearTimeout(reload)
watcher.close()
clearTimeout(timer)
watchers.forEach((watcher) => watcher.close())
},
}
}
+50 -44
View File
@@ -1,12 +1,14 @@
import path from "path"
import { onMount } from "solid-js"
import { createStore, produce, unwrap } from "solid-js/store"
import { readFileSync } from "fs"
import { createSignal } from "solid-js"
import { unwrap } from "solid-js/store"
import { createSimpleContext } from "../context/helper"
import { useTuiPaths } from "../context/runtime"
import { appendText, readText, writeText } from "../util/persistence"
import { useStorage } from "../context/storage"
import { parsePromptInfo, type PromptInfo } from "./history"
export type StashEntry = {
id: string
prompt: PromptInfo
timestamp: number
}
@@ -29,7 +31,7 @@ export function parsePromptStash(text: string) {
return undefined
}
})
.filter((line): line is StashEntry => line !== undefined)
.filter((line): line is Omit<StashEntry, "id"> => line !== undefined)
.slice(-MAX_STASH_ENTRIES)
}
@@ -37,56 +39,60 @@ export const { use: usePromptStash, provider: PromptStashProvider } = createSimp
name: "PromptStash",
init: () => {
const paths = useTuiPaths()
const stashPath = path.join(paths.state, "prompt-stash.jsonl")
onMount(async () => {
const lines = parsePromptStash(await readText(stashPath).catch(() => ""))
setStore("entries", lines)
if (lines.length > 0)
writeText(stashPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
const storage = useStorage()
const [store, update] = storage.store("prompt-stash", {
scope: "global",
initial: { migrated: false, entries: [] as StashEntry[] },
})
// Import under the same lock as mutations. The marker survives an empty stash,
// so another startup cannot restore prompts that were already consumed.
const ready = store.migrated
? Promise.resolve()
: update((draft) => {
if (draft.migrated) return
draft.migrated = true
try {
const text = readFileSync(path.join(paths.state, "prompt-stash.jsonl"), "utf8")
draft.entries = parsePromptStash(text).map((entry) => ({ ...entry, id: crypto.randomUUID() }))
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return
throw error
}
})
void ready.catch((error) => console.error("Failed to load prompt stash", error))
const [store, setStore] = createStore({ entries: [] as StashEntry[] })
const [pending, setPending] = createSignal(0)
function mutate(mutation: Parameters<typeof update>[0]) {
setPending((count) => count + 1)
return ready.then(() => update(mutation)).finally(() => setPending((count) => count - 1))
}
return {
pending: () => pending() > 0,
list() {
return store.entries
},
push(entry: Omit<StashEntry, "timestamp">) {
const stash = structuredClone(unwrap({ ...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) {
writeText(stashPath, store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
return
}
appendText(stashPath, JSON.stringify(stash) + "\n").catch(() => {})
async push(entry: Pick<StashEntry, "prompt">) {
const stash = structuredClone(unwrap({ ...entry, id: crypto.randomUUID(), timestamp: Date.now() }))
await mutate((draft) => {
draft.entries.push(stash)
draft.entries = draft.entries.slice(-MAX_STASH_ENTRIES)
})
},
pop() {
if (store.entries.length === 0) return undefined
const entry = store.entries[store.entries.length - 1]
setStore(produce((draft) => void draft.entries.pop()))
writeText(
stashPath,
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : "",
).catch(() => {})
async pop() {
let entry: StashEntry | undefined
await mutate((draft) => {
entry = draft.entries.pop()
})
return entry
},
remove(index: number) {
if (index < 0 || index >= store.entries.length) return
setStore(produce((draft) => void draft.entries.splice(index, 1)))
writeText(
stashPath,
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : "",
).catch(() => {})
async remove(id: string) {
let entry: StashEntry | undefined
await mutate((draft) => {
const index = draft.entries.findIndex((entry) => entry.id === id)
if (index !== -1) entry = draft.entries.splice(index, 1)[0]
})
return entry
},
}
},
@@ -0,0 +1,121 @@
/** @jsxImportSource @opentui/solid */
import { Flock } from "@opencode-ai/util/flock"
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import path from "node:path"
import { DialogStash } from "../../src/component/dialog-stash"
import { ConfigProvider } from "../../src/config"
import { Keymap } from "../../src/context/keymap"
import { TuiAppProvider } from "../../src/context/runtime"
import { StorageProvider, useStorage } from "../../src/context/storage"
import { ThemeProvider } from "../../src/context/theme"
import { PromptStashProvider, usePromptStash, type StashEntry } from "../../src/prompt/stash"
import { emptyPrompt } from "../../src/prompt/history"
import { DialogProvider, useDialog } from "../../src/ui/dialog"
import { DialogSelect } from "../../src/ui/dialog-select"
import { ToastProvider } from "../../src/ui/toast"
import { emptyThemeSource, tmpdir } from "../fixture/fixture"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
const prompt = (text: string) => ({ ...emptyPrompt(), text })
test("a dismissed stash restores its consumed entry without closing the replacement dialog", async () => {
await using tmp = await tmpdir()
using fixture = await renderStash(tmp.path)
await fixture.stashes[0].push({ prompt: prompt("saved prompt") })
fixture.open()
await fixture.app.waitForFrame((frame) => frame.includes("saved prompt"))
const lock = await Flock.acquire(path.join(tmp.path, "tui", "prompt-stash.json"), {
dir: path.join(tmp.path, "locks"),
})
try {
fixture.app.mockInput.pressEnter()
fixture.app.mockInput.pressEscape()
expect(fixture.dialogs[0].stack).toHaveLength(0)
fixture.dialogs[0].replace(() => (
<DialogSelect title="Replacement dialog" options={[{ title: "Other choice", value: "other" }]} />
))
await fixture.app.waitForFrame((frame) => frame.includes("Replacement dialog"))
expect(fixture.restored).toHaveLength(0)
} finally {
await lock.release()
}
await fixture.app.waitFor(() => fixture.restored.length === 1)
expect(fixture.restored[0].prompt.text).toBe("saved prompt")
expect(fixture.stashes[0].list()).toHaveLength(0)
expect(fixture.dialogs[0].stack).toHaveLength(1)
await fixture.app.waitForFrame((frame) => frame.includes("Replacement dialog"))
})
test("a live stash update preserves the selected prompt by ID", async () => {
await using tmp = await tmpdir()
using fixture = await renderStash(tmp.path)
await fixture.stashes[0].push({ prompt: prompt("first") })
await fixture.stashes[0].push({ prompt: prompt("second") })
fixture.open()
await fixture.app.waitForFrame((frame) => frame.includes("first") && frame.includes("second"))
fixture.app.mockInput.pressArrow("down")
await fixture.stashes[1].push({ prompt: prompt("third") })
await fixture.app.waitForFrame((frame) => frame.includes("third"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.restored.length === 1)
expect(fixture.restored[0].prompt.text).toBe("first")
expect(fixture.stashes[0].list().map((entry) => entry.prompt.text)).toEqual(["second", "third"])
expect(fixture.dialogs[0].stack).toHaveLength(0)
})
async function renderStash(root: string) {
const stashes: ReturnType<typeof usePromptStash>[] = []
const dialogs: ReturnType<typeof useDialog>[] = []
const storages: ReturnType<typeof useStorage>[] = []
const restored: StashEntry[] = []
function Consumer() {
stashes.push(usePromptStash())
dialogs.push(useDialog())
storages.push(useStorage())
return null
}
const app = await testRender(
() => (
<TestTuiContexts directory={root} paths={{ home: root, state: root, worktree: root }}>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
{["dev", "beta"].map((channel) => (
<TuiAppProvider value={{ name: "test", version: "0.0.0", channel }}>
<StorageProvider>
<PromptStashProvider>
<DialogProvider>
<Consumer />
</DialogProvider>
</PromptStashProvider>
</StorageProvider>
</TuiAppProvider>
))}
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 80, height: 24, kittyKeyboard: true },
)
app.renderer.start()
await app.waitFor(() => stashes.length === 2)
await Promise.all(storages.map((storage) => storage.flush()))
return {
app,
stashes,
dialogs,
restored,
open: () => dialogs[0].replace(() => <DialogStash onSelect={(entry) => restored.push(entry)} />),
[Symbol.dispose]: () => app.renderer.destroy(),
}
}
@@ -0,0 +1,98 @@
import { expect, test } from "bun:test"
import { createComponent, createRoot } from "solid-js"
import path from "node:path"
import { TuiAppProvider, TuiPathsProvider } from "../../src/context/runtime"
import { StorageProvider, useStorage } from "../../src/context/storage"
import { emptyPrompt } from "../../src/prompt/history"
import { PromptStashProvider, usePromptStash } from "../../src/prompt/stash"
import { tmpdir } from "../fixture/fixture"
const prompt = (text: string) => ({ prompt: { ...emptyPrompt(), text } })
test("concurrent pushes survive initialization and sync across running TUIs", async () => {
await using tmp = await tmpdir()
await using a = mount(tmp.path, "dev")
await using b = mount(tmp.path, "beta")
await Promise.all([a.stash.push(prompt("first")), b.stash.push(prompt("second"))])
await until(() => a.stash.list().length === 2 && b.stash.list().length === 2)
expect(
a.stash
.list()
.map((entry) => entry.prompt.text)
.sort(),
).toEqual(["first", "second"])
await a.stash.push(prompt("later"))
await until(() => b.stash.list().at(-1)?.prompt.text === "later")
expect((await b.stash.pop())?.prompt.text).toBe("later")
await until(() => a.stash.list().length === 2)
})
test("a stale selected ID cannot delete the neighboring prompt", async () => {
await using tmp = await tmpdir()
await using a = mount(tmp.path, "dev")
await using b = mount(tmp.path, "beta")
await a.stash.push(prompt("first"))
await a.stash.push(prompt("second"))
await until(() => b.stash.list().length === 2)
const id = b.stash.list()[0].id
expect((await a.stash.remove(id))?.prompt.text).toBe("first")
expect(await b.stash.remove(id)).toBeUndefined()
expect((await b.stash.pop())?.prompt.text).toBe("second")
})
test("legacy prompts import once, pop once, and stay consumed after restarting", async () => {
await using tmp = await tmpdir()
await Bun.write(
path.join(tmp.path, "prompt-stash.jsonl"),
JSON.stringify({ ...prompt("legacy"), timestamp: 1 }) + "\n",
)
{
await using a = mount(tmp.path, "dev")
await using b = mount(tmp.path, "beta")
await until(() => a.stash.list().length === 1 && b.stash.list().length === 1)
const entries = await Promise.all([a.stash.pop(), b.stash.pop()])
expect(entries.filter(Boolean).map((entry) => entry?.prompt.text)).toEqual(["legacy"])
}
await using restarted = mount(tmp.path, "dev")
expect(await restarted.stash.pop()).toBeUndefined()
})
function mount(root: string, channel: string) {
return createRoot((dispose) => {
let stash!: ReturnType<typeof usePromptStash>
let storage!: ReturnType<typeof useStorage>
function Consumer() {
stash = usePromptStash()
storage = useStorage()
return null
}
createComponent(TuiAppProvider, {
value: { name: "test", version: "0.0.0", channel },
get children() {
return (
<TuiPathsProvider value={{ cwd: path.join(root, channel), home: root, state: root, worktree: root }}>
<StorageProvider>
<PromptStashProvider>
<Consumer />
</PromptStashProvider>
</StorageProvider>
</TuiPathsProvider>
)
},
})
return {
stash,
async [Symbol.asyncDispose]() {
await storage.flush().finally(dispose)
},
}
})
}
async function until(predicate: () => boolean) {
for (const _ of Array.from({ length: 100 })) {
if (predicate()) return
await Bun.sleep(10)
}
expect(predicate()).toBe(true)
}