From a631ce4f47986a306f15c1370562c0085cefc8b0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 1 Aug 2026 15:05:02 +0000 Subject: [PATCH] fix(tui): scope prompt state to sessions --- packages/tui/src/component/prompt/index.tsx | 54 +++++++++--- packages/tui/src/prompt/history.tsx | 88 +++++++++++-------- .../tui/test/prompt/history-provider.test.tsx | 12 ++- 3 files changed, 100 insertions(+), 54 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index fdbff6af76..ae1b4d3553 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -130,7 +130,7 @@ function formatEditorContext(selection: EditorSelection) { return `${ranges.join("\n")} This may or may not be relevant to the current task.\n` } -let stashed: { prompt: PromptInfo; cursor: number } | undefined +const drafts = new Map() function argumentSlash(input: string, commands: readonly KeymapCommand[]) { const head = parseSlashHead(input, /\s/) @@ -172,6 +172,20 @@ export function Prompt(props: PromptProps) { const exit = useExit() const dimensions = useTerminalDimensions() const theme = useTheme() + const draftKey = (sessionID?: string) => sessionID ?? "new" + const saveDraft = (sessionID?: string) => { + const key = draftKey(sessionID) + if ( + !store.prompt.text && + store.prompt.pasted.length === 0 && + (store.prompt.files?.length ?? 0) === 0 && + (store.prompt.agents?.length ?? 0) === 0 + ) { + drafts.delete(key) + return + } + drafts.set(key, { prompt: structuredClone(unwrap(store.prompt)), cursor: input.cursorOffset }) + } const { currentSyntax: syntax } = useThemes() const animationsEnabled = createMemo(() => config.animations ?? true) const list = createMemo(() => props.placeholders?.normal ?? []) @@ -561,6 +575,7 @@ export function Prompt(props: PromptProps) { input.extmarks.clear() setStore("prompt", emptyPrompt()) setStore("extmarkToPart", new Map()) + drafts.delete(draftKey(props.sessionID)) }, submit() { void submit() @@ -568,10 +583,10 @@ export function Prompt(props: PromptProps) { } onMount(() => { - const saved = stashed - stashed = undefined + void history.load(props.sessionID) + const saved = drafts.get(draftKey(props.sessionID)) if (store.prompt.text) return - if (saved && saved.prompt.text) { + if (saved) { input.setText(saved.prompt.text) setStore("prompt", saved.prompt) restoreExtmarksFromPrompt(saved.prompt) @@ -579,10 +594,25 @@ export function Prompt(props: PromptProps) { } }) + createEffect( + on( + () => props.sessionID, + (sessionID, previous) => { + saveDraft(previous) + const saved = drafts.get(draftKey(sessionID)) + input.clear() + input.extmarks.clear() + setStore("prompt", saved?.prompt ?? emptyPrompt()) + restoreExtmarksFromPrompt(saved?.prompt ?? emptyPrompt()) + input.cursorOffset = saved?.cursor ?? 0 + void history.load(sessionID) + }, + { defer: true }, + ), + ) + onCleanup(() => { - if (store.prompt.text) { - stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset } - } + saveDraft(props.sessionID) setInputTarget(undefined) props.ref?.(undefined) }) @@ -854,7 +884,7 @@ export function Prompt(props: PromptProps) { return } - const item = history.move(-1, input.plainText) + const item = history.move(props.sessionID, -1, input.plainText) if (!item) return false input.setText(item.text) setStore("prompt", item) @@ -893,7 +923,7 @@ export function Prompt(props: PromptProps) { return } - const item = history.move(1, input.plainText) + const item = history.move(props.sessionID, 1, input.plainText) if (!item) return false input.setText(item.text) setStore("prompt", item) @@ -1117,13 +1147,14 @@ export function Prompt(props: PromptProps) { } if (pendingEditorSelection) editor.markSelectionSent() } - history.append({ + history.append(sessionID, { ...store.prompt, mode: currentMode, }) input.extmarks.clear() setStore("prompt", emptyPrompt()) setStore("extmarkToPart", new Map()) + drafts.delete(draftKey(sessionID)) props.onSubmit?.() // temporary hack to make sure the message is sent @@ -1273,7 +1304,7 @@ export function Prompt(props: PromptProps) { (store.prompt.files?.length ?? 0) > 0 || (store.prompt.agents?.length ?? 0) > 0 ) { - history.append({ + history.append(props.sessionID, { ...store.prompt, mode: store.mode, }) @@ -1282,6 +1313,7 @@ export function Prompt(props: PromptProps) { input.extmarks.clear() setStore("prompt", emptyPrompt()) setStore("extmarkToPart", new Map()) + drafts.delete(draftKey(props.sessionID)) } const highlight = createMemo(() => { diff --git a/packages/tui/src/prompt/history.tsx b/packages/tui/src/prompt/history.tsx index 0778ecc16f..c7df0be5d5 100644 --- a/packages/tui/src/prompt/history.tsx +++ b/packages/tui/src/prompt/history.tsx @@ -1,6 +1,5 @@ import path from "path" -import { onMount } from "solid-js" -import { createStore, produce, unwrap } from "solid-js/store" +import { unwrap } from "solid-js/store" import type { SessionPromptInput } from "@opencode-ai/client" import type { Types } from "effect" import { createSimpleContext } from "../context/helper" @@ -61,56 +60,67 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create name: "PromptHistory", init: () => { const paths = useTuiPaths() - const historyPath = path.join(paths.state, "prompt-history.jsonl") - onMount(async () => { - const lines = parsePromptHistory(await readText(historyPath).catch(() => "")) - setStore("history", lines) - - // Rewrite valid retained entries to self-heal corruption and enforce the limit. - if (lines.length > 0) - writeText(historyPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {}) - }) - - const [store, setStore] = createStore({ - index: 0, - history: [] as PromptInfo[], - }) + const stores = new Map() + const loaded = new Set() + const key = (sessionID?: string) => sessionID ?? "new" + const historyPath = (sessionID?: string) => + path.join(paths.state, "prompt-history", encodeURIComponent(key(sessionID)) + ".jsonl") + const store = (sessionID?: string) => { + const id = key(sessionID) + const current = stores.get(id) + if (current) return current + const next = { index: 0, history: [] as PromptInfo[] } + stores.set(id, next) + return next + } return { - move(direction: 1 | -1, input: string) { - if (!store.history.length) return undefined - const current = store.history.at(store.index) + async load(sessionID?: string) { + const id = key(sessionID) + if (loaded.has(id)) return + loaded.add(id) + const lines = parsePromptHistory(await readText(historyPath(sessionID)).catch(() => "")) + const current = stores.get(id) + const history = [...lines, ...(current?.history ?? [])] + .filter((entry, index, entries) => !isDuplicateEntry(entries[index - 1], entry)) + .slice(-MAX_HISTORY_ENTRIES) + stores.set(id, { index: current?.index ?? 0, history }) + if (lines.length > 0) + writeText(historyPath(sessionID), history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch( + () => {}, + ) + }, + move(sessionID: string | undefined, direction: 1 | -1, input: string) { + const state = store(sessionID) + if (!state.history.length) return undefined + const current = state.history.at(state.index) if (!current) return undefined if (current.text !== input && input.length) return - const next = store.index + direction - if (Math.abs(next) > store.history.length || next > 0) return - setStore("index", next) + const next = state.index + direction + if (Math.abs(next) > state.history.length || next > 0) return + state.index = next if (next === 0) return emptyPrompt() - return store.history.at(next) + return state.history.at(next) }, - append(item: PromptInfo) { + append(sessionID: string | undefined, item: PromptInfo) { + const state = store(sessionID) const entry = structuredClone(unwrap(item)) - if (isDuplicateEntry(store.history.at(-1), entry)) { - setStore("index", 0) + if (isDuplicateEntry(state.history.at(-1), entry)) { + state.index = 0 return } - let trimmed = false - setStore( - produce((draft) => { - draft.history.push(entry) - if (draft.history.length > MAX_HISTORY_ENTRIES) { - draft.history = draft.history.slice(-MAX_HISTORY_ENTRIES) - trimmed = true - } - draft.index = 0 - }), - ) + state.history.push(entry) + const trimmed = state.history.length > MAX_HISTORY_ENTRIES + if (trimmed) state.history = state.history.slice(-MAX_HISTORY_ENTRIES) + state.index = 0 if (trimmed) { - writeText(historyPath, store.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {}) + writeText(historyPath(sessionID), state.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch( + () => {}, + ) return } - appendText(historyPath, JSON.stringify(entry) + "\n").catch(() => {}) + appendText(historyPath(sessionID), JSON.stringify(entry) + "\n").catch(() => {}) }, } }, diff --git a/packages/tui/test/prompt/history-provider.test.tsx b/packages/tui/test/prompt/history-provider.test.tsx index a49d45b932..880d016159 100644 --- a/packages/tui/test/prompt/history-provider.test.tsx +++ b/packages/tui/test/prompt/history-provider.test.tsx @@ -27,11 +27,15 @@ test("down rejects at the newest history item with an empty prompt", async () => )) try { await app.renderOnce() - history!.append({ text: "previous", files: [], agents: [], pasted: [] }) + history!.append("ses_one", { text: "previous", files: [], agents: [], pasted: [] }) - expect(history!.move(1, "")).toBeUndefined() - expect(history!.move(-1, "")?.text).toBe("previous") - expect(history!.move(1, "previous")?.text).toBe("") + expect(history!.move("ses_one", 1, "")).toBeUndefined() + expect(history!.move("ses_one", -1, "")?.text).toBe("previous") + expect(history!.move("ses_one", 1, "previous")?.text).toBe("") + + history!.append("ses_two", { text: "other", files: [], agents: [], pasted: [] }) + expect(history!.move("ses_two", -1, "")?.text).toBe("other") + expect(history!.move("ses_one", -1, "")?.text).toBe("previous") } finally { app.renderer.destroy() }