fix(tui): scope prompt state to sessions

This commit is contained in:
Kit Langton 2026-08-01 15:05:02 +00:00
commit a631ce4f47
3 changed files with 100 additions and 54 deletions

View file

@ -130,7 +130,7 @@ function formatEditorContext(selection: EditorSelection) {
return `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\n` return `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\n`
} }
let stashed: { prompt: PromptInfo; cursor: number } | undefined const drafts = new Map<string, { prompt: PromptInfo; cursor: number }>()
function argumentSlash(input: string, commands: readonly KeymapCommand[]) { function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
const head = parseSlashHead(input, /\s/) const head = parseSlashHead(input, /\s/)
@ -172,6 +172,20 @@ export function Prompt(props: PromptProps) {
const exit = useExit() const exit = useExit()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const theme = useTheme() 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 { currentSyntax: syntax } = useThemes()
const animationsEnabled = createMemo(() => config.animations ?? true) const animationsEnabled = createMemo(() => config.animations ?? true)
const list = createMemo(() => props.placeholders?.normal ?? []) const list = createMemo(() => props.placeholders?.normal ?? [])
@ -561,6 +575,7 @@ export function Prompt(props: PromptProps) {
input.extmarks.clear() input.extmarks.clear()
setStore("prompt", emptyPrompt()) setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map()) setStore("extmarkToPart", new Map())
drafts.delete(draftKey(props.sessionID))
}, },
submit() { submit() {
void submit() void submit()
@ -568,10 +583,10 @@ export function Prompt(props: PromptProps) {
} }
onMount(() => { onMount(() => {
const saved = stashed void history.load(props.sessionID)
stashed = undefined const saved = drafts.get(draftKey(props.sessionID))
if (store.prompt.text) return if (store.prompt.text) return
if (saved && saved.prompt.text) { if (saved) {
input.setText(saved.prompt.text) input.setText(saved.prompt.text)
setStore("prompt", saved.prompt) setStore("prompt", saved.prompt)
restoreExtmarksFromPrompt(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(() => { onCleanup(() => {
if (store.prompt.text) { saveDraft(props.sessionID)
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
setInputTarget(undefined) setInputTarget(undefined)
props.ref?.(undefined) props.ref?.(undefined)
}) })
@ -854,7 +884,7 @@ export function Prompt(props: PromptProps) {
return return
} }
const item = history.move(-1, input.plainText) const item = history.move(props.sessionID, -1, input.plainText)
if (!item) return false if (!item) return false
input.setText(item.text) input.setText(item.text)
setStore("prompt", item) setStore("prompt", item)
@ -893,7 +923,7 @@ export function Prompt(props: PromptProps) {
return return
} }
const item = history.move(1, input.plainText) const item = history.move(props.sessionID, 1, input.plainText)
if (!item) return false if (!item) return false
input.setText(item.text) input.setText(item.text)
setStore("prompt", item) setStore("prompt", item)
@ -1117,13 +1147,14 @@ export function Prompt(props: PromptProps) {
} }
if (pendingEditorSelection) editor.markSelectionSent() if (pendingEditorSelection) editor.markSelectionSent()
} }
history.append({ history.append(sessionID, {
...store.prompt, ...store.prompt,
mode: currentMode, mode: currentMode,
}) })
input.extmarks.clear() input.extmarks.clear()
setStore("prompt", emptyPrompt()) setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map()) setStore("extmarkToPart", new Map())
drafts.delete(draftKey(sessionID))
props.onSubmit?.() props.onSubmit?.()
// temporary hack to make sure the message is sent // 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.files?.length ?? 0) > 0 ||
(store.prompt.agents?.length ?? 0) > 0 (store.prompt.agents?.length ?? 0) > 0
) { ) {
history.append({ history.append(props.sessionID, {
...store.prompt, ...store.prompt,
mode: store.mode, mode: store.mode,
}) })
@ -1282,6 +1313,7 @@ export function Prompt(props: PromptProps) {
input.extmarks.clear() input.extmarks.clear()
setStore("prompt", emptyPrompt()) setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map()) setStore("extmarkToPart", new Map())
drafts.delete(draftKey(props.sessionID))
} }
const highlight = createMemo(() => { const highlight = createMemo(() => {

View file

@ -1,6 +1,5 @@
import path from "path" import path from "path"
import { onMount } from "solid-js" import { unwrap } from "solid-js/store"
import { createStore, produce, unwrap } from "solid-js/store"
import type { SessionPromptInput } from "@opencode-ai/client" import type { SessionPromptInput } from "@opencode-ai/client"
import type { Types } from "effect" import type { Types } from "effect"
import { createSimpleContext } from "../context/helper" import { createSimpleContext } from "../context/helper"
@ -61,56 +60,67 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
name: "PromptHistory", name: "PromptHistory",
init: () => { init: () => {
const paths = useTuiPaths() const paths = useTuiPaths()
const historyPath = path.join(paths.state, "prompt-history.jsonl") const stores = new Map<string, { index: number; history: PromptInfo[] }>()
onMount(async () => { const loaded = new Set<string>()
const lines = parsePromptHistory(await readText(historyPath).catch(() => "")) const key = (sessionID?: string) => sessionID ?? "new"
setStore("history", lines) const historyPath = (sessionID?: string) =>
path.join(paths.state, "prompt-history", encodeURIComponent(key(sessionID)) + ".jsonl")
// Rewrite valid retained entries to self-heal corruption and enforce the limit. const store = (sessionID?: string) => {
if (lines.length > 0) const id = key(sessionID)
writeText(historyPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {}) const current = stores.get(id)
}) if (current) return current
const next = { index: 0, history: [] as PromptInfo[] }
const [store, setStore] = createStore({ stores.set(id, next)
index: 0, return next
history: [] as PromptInfo[], }
})
return { return {
move(direction: 1 | -1, input: string) { async load(sessionID?: string) {
if (!store.history.length) return undefined const id = key(sessionID)
const current = store.history.at(store.index) 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) return undefined
if (current.text !== input && input.length) return if (current.text !== input && input.length) return
const next = store.index + direction const next = state.index + direction
if (Math.abs(next) > store.history.length || next > 0) return if (Math.abs(next) > state.history.length || next > 0) return
setStore("index", next) state.index = next
if (next === 0) return emptyPrompt() 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)) const entry = structuredClone(unwrap(item))
if (isDuplicateEntry(store.history.at(-1), entry)) { if (isDuplicateEntry(state.history.at(-1), entry)) {
setStore("index", 0) state.index = 0
return return
} }
let trimmed = false state.history.push(entry)
setStore( const trimmed = state.history.length > MAX_HISTORY_ENTRIES
produce((draft) => { if (trimmed) state.history = state.history.slice(-MAX_HISTORY_ENTRIES)
draft.history.push(entry) state.index = 0
if (draft.history.length > MAX_HISTORY_ENTRIES) {
draft.history = draft.history.slice(-MAX_HISTORY_ENTRIES)
trimmed = true
}
draft.index = 0
}),
)
if (trimmed) { 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 return
} }
appendText(historyPath, JSON.stringify(entry) + "\n").catch(() => {}) appendText(historyPath(sessionID), JSON.stringify(entry) + "\n").catch(() => {})
}, },
} }
}, },

View file

@ -27,11 +27,15 @@ test("down rejects at the newest history item with an empty prompt", async () =>
)) ))
try { try {
await app.renderOnce() 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("ses_one", 1, "")).toBeUndefined()
expect(history!.move(-1, "")?.text).toBe("previous") expect(history!.move("ses_one", -1, "")?.text).toBe("previous")
expect(history!.move(1, "previous")?.text).toBe("") 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 { } finally {
app.renderer.destroy() app.renderer.destroy()
} }