mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-22 02:33:29 +00:00
fix(tui): scope prompt history by session (#43977)
This commit is contained in:
parent
97d3cd0b3a
commit
fa1b4ef7ec
4 changed files with 99 additions and 36 deletions
|
|
@ -1023,7 +1023,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)
|
||||
|
|
@ -1062,7 +1062,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)
|
||||
|
|
@ -1181,7 +1181,6 @@ export function Prompt(props: PromptProps) {
|
|||
// snapshot unless the user has started typing something new.
|
||||
const currentMode = store.mode
|
||||
const entry = { ...store.prompt, mode: currentMode }
|
||||
history.append(entry)
|
||||
resetComposer()
|
||||
props.onSubmit?.()
|
||||
const restoreEntry = () => {
|
||||
|
|
@ -1256,6 +1255,7 @@ export function Prompt(props: PromptProps) {
|
|||
}
|
||||
|
||||
const target = sessionID
|
||||
history.append(target, entry)
|
||||
const dispatch = (send: () => Promise<unknown>) => {
|
||||
const setup = newSession
|
||||
if (setup) void setup.gate.then(send).catch(setup.recover)
|
||||
|
|
@ -1557,7 +1557,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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
@ -71,27 +83,28 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
|||
writeText(historyPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
index: 0,
|
||||
history: [] as PromptInfo[],
|
||||
})
|
||||
const [store, setStore] = createStore({ 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 +115,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(() => {})
|
||||
|
|
|
|||
|
|
@ -9,8 +9,53 @@ import { tmpdir } from "../fixture/fixture"
|
|||
|
||||
test("down rejects at the newest history item with an empty prompt", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const state = path.join(tmp.path, "state")
|
||||
const setup = await renderHistory(tmp.path)
|
||||
try {
|
||||
setup.history.append("session-a", { 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("")
|
||||
} finally {
|
||||
setup.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps independent prompt history and cursors for each session", 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: [] })
|
||||
|
||||
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("")
|
||||
} finally {
|
||||
setup.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps legacy unscoped history on the home composer", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const legacy = JSON.stringify({ text: "legacy", files: [], agents: [], pasted: [] }) + "\n"
|
||||
const setup = await renderHistory(tmp.path, legacy)
|
||||
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("")
|
||||
} finally {
|
||||
setup.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
async function renderHistory(root: string, persisted?: string) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
if (persisted) await Bun.write(path.join(state, "prompt-history.jsonl"), persisted)
|
||||
let history: ReturnType<typeof usePromptHistory>
|
||||
|
||||
function Consumer() {
|
||||
|
|
@ -19,20 +64,20 @@ test("down rejects at the newest history item with an empty prompt", async () =>
|
|||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TuiPathsProvider value={{ cwd: tmp.path, home: tmp.path, state, worktree: tmp.path }}>
|
||||
<TuiPathsProvider value={{ cwd: root, home: root, state, worktree: root }}>
|
||||
<PromptHistoryProvider>
|
||||
<Consumer />
|
||||
</PromptHistoryProvider>
|
||||
</TuiPathsProvider>
|
||||
))
|
||||
try {
|
||||
await app.renderOnce()
|
||||
history!.append({ text: "previous", files: [], agents: [], pasted: [] })
|
||||
await app.renderOnce()
|
||||
return { app, history: history! }
|
||||
}
|
||||
|
||||
expect(history!.move(1, "")).toBeUndefined()
|
||||
expect(history!.move(-1, "")?.text).toBe("previous")
|
||||
expect(history!.move(1, "previous")?.text).toBe("")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
async function waitForHistory(history: ReturnType<typeof usePromptHistory>) {
|
||||
for (const _ of Array.from({ length: 100 })) {
|
||||
const item = history.move(undefined, -1, "")
|
||||
if (item) return item
|
||||
await Bun.sleep(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue