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()
}