Compare commits

...

1 Commits

Author SHA1 Message Date
kitlangton 801bcf041f fix(tui): scope prompt history by session 2026-08-20 23:27:49 +00:00
4 changed files with 77 additions and 27 deletions
+4 -4
View File
@@ -977,7 +977,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)
@@ -1016,7 +1016,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)
@@ -1288,7 +1288,7 @@ export function Prompt(props: PromptProps) {
})
if (pendingEditorSelection) editor.markSelectionSent()
}
history.append({
history.append(sessionID, {
...store.prompt,
mode: currentMode,
})
@@ -1463,7 +1463,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,
})
+31 -16
View File
@@ -26,6 +26,11 @@ 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
@@ -36,12 +41,19 @@ export function parsePromptHistory(text: string) {
.filter(Boolean)
.map((line) => {
try {
return parsePromptInfo(JSON.parse(line))
const value: unknown = JSON.parse(line)
const input = value && typeof value === "object" ? (value as Record<string, unknown>) : undefined
const prompt = parsePromptInfo(input?.prompt ?? value)
if (!prompt) return
return {
sessionID: typeof input?.sessionID === "string" ? input.sessionID : undefined,
prompt,
}
} catch {
return undefined
}
})
.filter((line): line is PromptInfo => line !== undefined)
.filter((line): line is PromptHistoryEntry => line !== undefined)
.slice(-MAX_HISTORY_ENTRIES)
}
@@ -72,26 +84,29 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
})
const [store, setStore] = createStore({
index: 0,
history: [] as PromptInfo[],
history: [] as PromptHistoryEntry[],
})
const indices = new Map<string | undefined, number>()
return {
move(direction: 1 | -1, input: string) {
if (!store.history.length) return undefined
const current = store.history.at(store.index)
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
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 = index + direction
if (Math.abs(next) > items.length || next > 0) return
indices.set(sessionID, next)
if (next === 0) return emptyPrompt()
return store.history.at(next)
return items.at(next)?.prompt
},
append(item: PromptInfo) {
const entry = structuredClone(unwrap(item))
if (isDuplicateEntry(store.history.at(-1), entry)) {
setStore("index", 0)
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)
return
}
let trimmed = false
@@ -102,9 +117,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(() => {})
@@ -27,11 +27,41 @@ 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("session-a", { 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("session-a", 1, "")).toBeUndefined()
expect(history!.move("session-a", -1, "")?.text).toBe("previous")
expect(history!.move("session-a", 1, "previous")?.text).toBe("")
} finally {
app.renderer.destroy()
}
})
test("scopes prompt history to the current session", async () => {
await using tmp = await tmpdir()
const state = path.join(tmp.path, "state")
await mkdir(state, { recursive: true })
let history: ReturnType<typeof usePromptHistory>
function Consumer() {
history = usePromptHistory()
return <box />
}
const app = await testRender(() => (
<TuiPathsProvider value={{ cwd: tmp.path, home: tmp.path, state, worktree: tmp.path }}>
<PromptHistoryProvider>
<Consumer />
</PromptHistoryProvider>
</TuiPathsProvider>
))
try {
await app.renderOnce()
history!.append("session-a", { text: "from a", files: [], agents: [], pasted: [] })
history!.append("session-b", { text: "from b", files: [], agents: [], pasted: [] })
expect(history!.move("session-a", -1, "")?.text).toBe("from a")
expect(history!.move("session-b", -1, "")?.text).toBe("from b")
} finally {
app.renderer.destroy()
}
+8 -3
View File
@@ -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([
entry("one"),
entry("two"),
{ sessionID: undefined, prompt: entry("one") },
{ sessionID: undefined, prompt: entry("two") },
])
})
@@ -26,7 +26,7 @@ describe("prompt history", () => {
).join("\n")
const result = parsePromptHistory(input)
expect(result).toHaveLength(MAX_HISTORY_ENTRIES)
expect(result[0]?.text).toBe("5")
expect(result[0]?.prompt.text).toBe("5")
})
test("dedupes only identical consecutive entries", () => {
@@ -56,6 +56,11 @@ 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])
})
})