From e0cdf1ed0926d90486af3fc45cf7558529e05fed Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:53:16 -0400 Subject: [PATCH] fix(tui): restore global prompt history (#44416) Co-authored-by: thdxr --- packages/tui/src/component/prompt/index.tsx | 8 ++-- packages/tui/src/prompt/history.tsx | 46 ++++++------------- .../tui/test/prompt/history-provider.test.tsx | 28 ++++++----- packages/tui/test/prompt/history.test.ts | 11 ++--- 4 files changed, 35 insertions(+), 58 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index b172344db3..62b8986e97 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1028,7 +1028,7 @@ export function Prompt(props: PromptProps) { return } - const item = history.move(props.sessionID, -1, input.plainText) + const item = history.move(-1, input.plainText) if (!item) return false input.setText(item.text) setStore("prompt", item) @@ -1067,7 +1067,7 @@ export function Prompt(props: PromptProps) { return } - const item = history.move(props.sessionID, 1, input.plainText) + const item = history.move(1, input.plainText) if (!item) return false input.setText(item.text) setStore("prompt", item) @@ -1260,7 +1260,7 @@ export function Prompt(props: PromptProps) { } const target = sessionID - history.append(target, entry) + history.append(entry) const dispatch = (send: () => Promise) => { const setup = newSession if (setup) void setup.gate.then(send).catch(setup.recover) @@ -1562,7 +1562,7 @@ export function Prompt(props: PromptProps) { (store.prompt.files?.length ?? 0) > 0 || (store.prompt.agents?.length ?? 0) > 0 ) { - history.append(props.sessionID, { + history.append({ ...store.prompt, mode: store.mode, }) diff --git a/packages/tui/src/prompt/history.tsx b/packages/tui/src/prompt/history.tsx index 856f84e0c7..4f6d3eda7d 100644 --- a/packages/tui/src/prompt/history.tsx +++ b/packages/tui/src/prompt/history.tsx @@ -26,11 +26,6 @@ export type PromptPartRef = { index: number } -type PromptHistoryEntry = { - sessionID: string | undefined - prompt: PromptInfo -} - export const emptyPrompt = (): PromptInfo => ({ text: "", files: [], agents: [], skills: [], pasted: [] }) export const MAX_HISTORY_ENTRIES = 50 @@ -41,19 +36,12 @@ export function parsePromptHistory(text: string) { .filter(Boolean) .map((line) => { try { - const value: unknown = JSON.parse(line) - const input = value && typeof value === "object" ? (value as Record) : undefined - const prompt = parsePromptInfo(input?.prompt ?? value) - if (!prompt) return - return { - sessionID: typeof input?.sessionID === "string" ? input.sessionID : undefined, - prompt, - } + return parsePromptInfo(JSON.parse(line)) } catch { return undefined } }) - .filter((line): line is PromptHistoryEntry => line !== undefined) + .filter((line): line is PromptInfo => line !== undefined) .slice(-MAX_HISTORY_ENTRIES) } @@ -83,28 +71,24 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create writeText(historyPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {}) }) - const [store, setStore] = createStore({ history: [] as PromptHistoryEntry[] }) - const indices = new Map() + const [store, setStore] = createStore({ index: 0, history: [] as PromptInfo[] }) return { - move(sessionID: string | undefined, direction: 1 | -1, input: string) { - const items = store.history.filter((entry) => entry.sessionID === sessionID) - if (!items.length) return undefined - const index = indices.get(sessionID) ?? 0 - const current = items.at(index)?.prompt + move(direction: 1 | -1, input: string) { + if (!store.history.length) return undefined + const current = store.history.at(store.index) if (!current) return undefined if (current.text !== input && input.length) return - const next = index + direction - if (Math.abs(next) > items.length || next > 0) return - indices.set(sessionID, next) + const next = store.index + direction + if (Math.abs(next) > store.history.length || next > 0) return + setStore("index", next) if (next === 0) return emptyPrompt() - return items.at(next)?.prompt + return store.history.at(next) }, - append(sessionID: string | undefined, item: PromptInfo) { - const entry = { sessionID, prompt: structuredClone(unwrap(item)) } - const previous = store.history.findLast((item) => item.sessionID === sessionID) - if (isDuplicateEntry(previous?.prompt, entry.prompt)) { - indices.set(sessionID, 0) + append(item: PromptInfo) { + const entry = structuredClone(unwrap(item)) + if (isDuplicateEntry(store.history.at(-1), entry)) { + setStore("index", 0) return } let trimmed = false @@ -115,9 +99,9 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create draft.history = draft.history.slice(-MAX_HISTORY_ENTRIES) trimmed = true } + draft.index = 0 }), ) - indices.set(sessionID, 0) if (trimmed) { writeText(historyPath, store.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {}) diff --git a/packages/tui/test/prompt/history-provider.test.tsx b/packages/tui/test/prompt/history-provider.test.tsx index bf568168f6..5ff0afda08 100644 --- a/packages/tui/test/prompt/history-provider.test.tsx +++ b/packages/tui/test/prompt/history-provider.test.tsx @@ -11,28 +11,27 @@ test("down rejects at the newest history item with an empty prompt", async () => await using tmp = await tmpdir() const setup = await renderHistory(tmp.path) try { - setup.history.append("session-a", { text: "previous", files: [], agents: [], pasted: [] }) + setup.history.append({ text: "previous", files: [], agents: [], pasted: [] }) - expect(setup.history.move("session-a", 1, "")).toBeUndefined() - expect(setup.history.move("session-a", -1, "")?.text).toBe("previous") - expect(setup.history.move("session-a", 1, "previous")?.text).toBe("") + expect(setup.history.move(1, "")).toBeUndefined() + expect(setup.history.move(-1, "")?.text).toBe("previous") + expect(setup.history.move(1, "previous")?.text).toBe("") } finally { setup.app.renderer.destroy() } }) -test("keeps independent prompt history and cursors for each session", async () => { +test("shares prompt history across sessions and the home composer", async () => { await using tmp = await tmpdir() const setup = await renderHistory(tmp.path) try { - setup.history.append("session-a", { text: "a-one", files: [], agents: [], pasted: [] }) - setup.history.append("session-b", { text: "b-one", files: [], agents: [], pasted: [] }) - setup.history.append("session-a", { text: "a-two", files: [], agents: [], pasted: [] }) + setup.history.append({ text: "a-one", files: [], agents: [], pasted: [] }) + setup.history.append({ text: "b-one", files: [], agents: [], pasted: [] }) + setup.history.append({ text: "a-two", files: [], agents: [], pasted: [] }) - expect(setup.history.move("session-a", -1, "")?.text).toBe("a-two") - expect(setup.history.move("session-b", -1, "")?.text).toBe("b-one") - expect(setup.history.move("session-a", -1, "a-two")?.text).toBe("a-one") - expect(setup.history.move("session-b", 1, "b-one")?.text).toBe("") + expect(setup.history.move(-1, "")?.text).toBe("a-two") + expect(setup.history.move(-1, "a-two")?.text).toBe("b-one") + expect(setup.history.move(-1, "b-one")?.text).toBe("a-one") } finally { setup.app.renderer.destroy() } @@ -45,8 +44,7 @@ test("keeps legacy unscoped history on the home composer", async () => { try { expect((await waitForHistory(setup.history))?.text).toBe("legacy") - expect(setup.history.move("session-a", -1, "")).toBeUndefined() - expect(setup.history.move(undefined, 1, "legacy")?.text).toBe("") + expect(setup.history.move(1, "legacy")?.text).toBe("") } finally { setup.app.renderer.destroy() } @@ -76,7 +74,7 @@ async function renderHistory(root: string, persisted?: string) { async function waitForHistory(history: ReturnType) { for (const _ of Array.from({ length: 100 })) { - const item = history.move(undefined, -1, "") + const item = history.move(-1, "") if (item) return item await Bun.sleep(1) } diff --git a/packages/tui/test/prompt/history.test.ts b/packages/tui/test/prompt/history.test.ts index 70e7f606ff..a505ccac92 100644 --- a/packages/tui/test/prompt/history.test.ts +++ b/packages/tui/test/prompt/history.test.ts @@ -11,8 +11,8 @@ const entry = (text: string, files: PromptInfo["files"] = []): PromptInfo => ({ describe("prompt history", () => { test("recovers valid JSONL entries around corruption", () => { expect(parsePromptHistory(`${JSON.stringify(entry("one"))}\nnot-json\n${JSON.stringify(entry("two"))}\n`)).toEqual([ - { sessionID: undefined, prompt: entry("one") }, - { sessionID: undefined, prompt: entry("two") }, + entry("one"), + entry("two"), ]) }) @@ -26,7 +26,7 @@ describe("prompt history", () => { ).join("\n") const result = parsePromptHistory(input) expect(result).toHaveLength(MAX_HISTORY_ENTRIES) - expect(result[0]?.prompt.text).toBe("5") + expect(result[0]?.text).toBe("5") }) test("dedupes only identical consecutive entries", () => { @@ -56,11 +56,6 @@ describe("prompt history", () => { }, ]) - expect(parsePromptHistory(JSON.stringify(value))).toEqual([{ sessionID: undefined, prompt: value }]) - }) - - test("preserves the session scope", () => { - const value = { sessionID: "session-a", prompt: entry("hello") } expect(parsePromptHistory(JSON.stringify(value))).toEqual([value]) }) })