diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index c48c751739ce..dc8ab9e6b337 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -877,7 +877,7 @@ export function Prompt(props: PromptProps) { return false } - const item = history.move(-1, input.plainText) + const item = history.move(-1, input.plainText, props.sessionID) if (!item) return false input.setText(item.input) setStore("prompt", item) @@ -913,7 +913,7 @@ export function Prompt(props: PromptProps) { return false } - const item = history.move(1, input.plainText) + const item = history.move(1, input.plainText, props.sessionID) if (!item) return false input.setText(item.input) setStore("prompt", item) @@ -1119,10 +1119,13 @@ export function Prompt(props: PromptProps) { }) if (editorParts.length > 0) editor.markSelectionSent() } - history.append({ - ...store.prompt, - mode: currentMode, - }) + history.append( + { + ...store.prompt, + mode: currentMode, + }, + sessionID, + ) input.extmarks.clear() setStore("prompt", { input: "", @@ -1271,10 +1274,13 @@ export function Prompt(props: PromptProps) { function clearPrompt() { if (store.prompt.input.trim().length >= DRAFT_RETENTION_MIN_CHARS || store.prompt.parts.length > 0) { - history.append({ - ...store.prompt, - mode: store.mode, - }) + history.append( + { + ...store.prompt, + mode: store.mode, + }, + props.sessionID, + ) } input.clear() input.extmarks.clear() diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index bde0bfb0718a..ec85b1d6e295 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -56,7 +56,13 @@ export const Prompt = Schema.Struct({ max_width: Schema.optional(Schema.Union([PromptSize, Schema.Literal("auto")])).annotate({ description: "Home prompt max width: a positive integer for a fixed cap, or 'auto' to scale with terminal width", }), -}).annotate({ description: "Prompt size settings" }) + history_scope: Schema.optional(Schema.Literals(["global", "session"])).annotate({ + description: [ + "Which prompts up and down recall in a session: 'global' (default) walks the prompts submitted anywhere,", + "'session' only the ones submitted in that session. The home screen always walks every session's", + ].join(" "), + }), +}).annotate({ description: "Prompt settings" }) export const Info = Schema.Struct({ $schema: Schema.optional(Schema.String), diff --git a/packages/tui/src/prompt/history.tsx b/packages/tui/src/prompt/history.tsx index e795d11da48b..fbc846cc3f63 100644 --- a/packages/tui/src/prompt/history.tsx +++ b/packages/tui/src/prompt/history.tsx @@ -4,6 +4,7 @@ import { createStore, produce, unwrap } from "solid-js/store" import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "../context/helper" import { useTuiPaths } from "../context/runtime" +import { useTuiConfig } from "../config" import { appendText, readText, writeText } from "../util/persistence" export type PromptInfo = { @@ -24,87 +25,141 @@ export type PromptInfo = { )[] } +// A history entry remembers the session it was written in. Entries written before sessions were recorded, and +// drafts cleared on the home screen, have none. +export type PromptHistoryEntry = PromptInfo & { sessionID?: string } + +export type PromptHistoryScope = "global" | "session" +export const PromptHistoryScopeDefault: PromptHistoryScope = "global" + export const MAX_HISTORY_ENTRIES = 50 +// With the session scope, how many of the most recently used sessions keep their entries. +export const MAX_HISTORY_SESSIONS = 50 + +export function parsePromptHistory(text: string, scope: PromptHistoryScope = PromptHistoryScopeDefault) { + return retainPromptHistory( + text + .split("\n") + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line) as PromptHistoryEntry + } catch { + return undefined + } + }) + .filter((line): line is PromptHistoryEntry => line !== undefined), + scope, + ) +} -export function parsePromptHistory(text: string) { - return text - .split("\n") - .filter(Boolean) - .map((line) => { - try { - return JSON.parse(line) as PromptInfo - } catch { - return undefined - } +/** + * The entries the history file keeps. The global scope keeps the newest MAX_HISTORY_ENTRIES of all of them. The + * session scope keeps the newest MAX_HISTORY_ENTRIES of each of the MAX_HISTORY_SESSIONS most recently used sessions, + * entries without a session counting as one more, so a busy session cannot push a quieter one's history out. + */ +export function retainPromptHistory(entries: PromptHistoryEntry[], scope: PromptHistoryScope) { + if (scope === "global") return entries.slice(-MAX_HISTORY_ENTRIES) + const counts = new Map() + return entries + .toReversed() + .filter((entry) => { + const key = entry.sessionID ?? "" + const count = counts.get(key) ?? (counts.size < MAX_HISTORY_SESSIONS ? 0 : undefined) + if (count === undefined || count >= MAX_HISTORY_ENTRIES) return false + counts.set(key, count + 1) + return true }) - .filter((line): line is PromptInfo => line !== undefined) - .slice(-MAX_HISTORY_ENTRIES) + .toReversed() } -export function isDuplicateEntry(previous: PromptInfo | undefined, next: PromptInfo): boolean { +/** + * The entries up and down walk through. The session scope shows a session only the prompts written in it; the home + * screen, which has no session yet, shows every session's, as the global scope does everywhere. + */ +export function promptHistoryView(entries: PromptHistoryEntry[], scope: PromptHistoryScope, sessionID?: string) { + if (scope === "session" && sessionID) + return entries.filter((entry) => entry.sessionID === sessionID).slice(-MAX_HISTORY_ENTRIES) + return entries.slice(-MAX_HISTORY_ENTRIES) +} + +export function isDuplicateEntry(previous: PromptHistoryEntry | undefined, next: PromptHistoryEntry): boolean { if (!previous) return false - return JSON.stringify(previous) === JSON.stringify(next) + return JSON.stringify({ ...previous, sessionID: undefined }) === JSON.stringify({ ...next, sessionID: undefined }) } +const serialize = (entries: PromptHistoryEntry[]) => entries.map((line) => JSON.stringify(line)).join("\n") + "\n" + export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({ name: "PromptHistory", init: () => { const paths = useTuiPaths() + const tuiConfig = useTuiConfig() + const scope = () => tuiConfig.prompt?.history_scope ?? PromptHistoryScopeDefault const historyPath = path.join(paths.state, "prompt-history.jsonl") onMount(async () => { - const lines = parsePromptHistory(await readText(historyPath).catch(() => "")) + const lines = parsePromptHistory(await readText(historyPath).catch(() => ""), scope()) 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(() => {}) + if (lines.length > 0) writeText(historyPath, serialize(lines)).catch(() => {}) }) const [store, setStore] = createStore({ index: 0, - history: [] as PromptInfo[], + // The session the index walks the history of; another one starts over from the newest entry. + sessionID: undefined as string | undefined, + history: [] as PromptHistoryEntry[], }) return { - move(direction: 1 | -1, input: string) { - if (!store.history.length) return undefined - const current = store.history.at(store.index) + move(direction: 1 | -1, input: string, sessionID?: string) { + if (store.sessionID !== sessionID) setStore({ index: 0, sessionID }) + const history = promptHistoryView(store.history, scope(), sessionID) + if (!history.length) return undefined + const current = history.at(store.index) if (!current) return undefined if (current.input !== input && input.length) return setStore( produce((draft) => { const next = store.index + direction - if (Math.abs(next) > store.history.length) return + if (Math.abs(next) > history.length) return if (next > 0) return draft.index = next }), ) if (store.index === 0) return { input: "", parts: [] } - return store.history.at(store.index) + const { sessionID: _, ...item } = history.at(store.index)! + return item as PromptInfo }, - append(item: PromptInfo) { - const entry = structuredClone(unwrap(item)) - if (isDuplicateEntry(store.history.at(-1), entry)) { - setStore("index", 0) + append(item: PromptInfo, sessionID?: string) { + // sessionID is set even when undefined, so an entry recalled from another session does not keep that one's. + const entry: PromptHistoryEntry = { ...structuredClone(unwrap(item)), sessionID } + if (isDuplicateEntry(promptHistoryView(store.history, scope(), sessionID).at(-1), entry)) { + setStore({ index: 0, sessionID }) 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) + const retained = retainPromptHistory(draft.history, scope()) + if (retained.length !== draft.history.length) { + draft.history = retained trimmed = true } draft.index = 0 + draft.sessionID = sessionID }), ) - if (trimmed) { - writeText(historyPath, store.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {}) - return - } - appendText(historyPath, JSON.stringify(entry) + "\n").catch(() => {}) + // Other TUIs append to the same file, so a trim rereads it rather than writing this one's view over theirs. + appendText(historyPath, JSON.stringify(entry) + "\n") + .then(async () => { + if (!trimmed) return + await writeText(historyPath, serialize(parsePromptHistory(await readText(historyPath), scope()))) + }) + .catch(() => {}) }, } }, diff --git a/packages/tui/test/prompt/history.test.ts b/packages/tui/test/prompt/history.test.ts index ebd3660d4055..6a5263552b25 100644 --- a/packages/tui/test/prompt/history.test.ts +++ b/packages/tui/test/prompt/history.test.ts @@ -1,5 +1,14 @@ import { describe, expect, test } from "bun:test" -import { isDuplicateEntry, MAX_HISTORY_ENTRIES, parsePromptHistory, type PromptInfo } from "../../src/prompt/history" +import { + isDuplicateEntry, + MAX_HISTORY_ENTRIES, + MAX_HISTORY_SESSIONS, + parsePromptHistory, + promptHistoryView, + retainPromptHistory, + type PromptHistoryEntry, + type PromptInfo, +} from "../../src/prompt/history" const entry = (input: string, parts: PromptInfo["parts"] = []): PromptInfo => ({ input, parts }) @@ -36,4 +45,59 @@ describe("prompt history", () => { ]) expect(isDuplicateEntry(a, b)).toBe(false) }) + + test("dedupes the same prompt sent in another session", () => { + expect(isDuplicateEntry({ ...entry("hello"), sessionID: "ses_a" }, { ...entry("hello"), sessionID: "ses_b" })).toBe( + true, + ) + }) +}) + +describe("prompt history scope", () => { + const tagged = (input: string, sessionID?: string, mode?: PromptInfo["mode"]): PromptHistoryEntry => ({ + ...entry(input), + ...(mode ? { mode } : {}), + ...(sessionID ? { sessionID } : {}), + }) + const history = [tagged("old"), tagged("a1", "ses_a"), tagged("b1", "ses_b"), tagged("ls", "ses_a", "shell")] + + test("global walks every session's prompts", () => { + expect(promptHistoryView(history, "global", "ses_a").map((item) => item.input)).toEqual(["old", "a1", "b1", "ls"]) + }) + + test("session walks only the prompts sent in that session, shell mode included", () => { + expect(promptHistoryView(history, "session", "ses_a")).toEqual([tagged("a1", "ses_a"), tagged("ls", "ses_a", "shell")]) + expect(promptHistoryView(history, "session", "ses_b").map((item) => item.input)).toEqual(["b1"]) + expect(promptHistoryView(history, "session", "ses_new")).toEqual([]) + }) + + test("session on the home screen walks every session's prompts", () => { + expect(promptHistoryView(history, "session", undefined).map((item) => item.input)).toEqual(["old", "a1", "b1", "ls"]) + }) + + test("global keeps the newest entries of all sessions", () => { + const entries = Array.from({ length: MAX_HISTORY_ENTRIES + 5 }, (_, index) => tagged(String(index), "ses_a")) + expect(retainPromptHistory([tagged("quiet", "ses_b"), ...entries], "global")).toEqual(entries.slice(5)) + }) + + test("session keeps each session's newest entries, so a busy one cannot push a quiet one out", () => { + const busy = Array.from({ length: MAX_HISTORY_ENTRIES + 5 }, (_, index) => tagged(String(index), "ses_a")) + const result = retainPromptHistory([tagged("quiet", "ses_b"), ...busy], "session") + expect(result[0]).toEqual(tagged("quiet", "ses_b")) + expect(result.slice(1)).toEqual(busy.slice(5)) + }) + + test("session keeps only the most recently used sessions", () => { + const entries = Array.from({ length: MAX_HISTORY_SESSIONS + 2 }, (_, index) => tagged("p", `ses_${index}`)) + const result = retainPromptHistory(entries, "session") + expect(result).toEqual(entries.slice(2)) + }) + + test("parses with the scope's retention", () => { + const text = Array.from({ length: MAX_HISTORY_ENTRIES + 1 }, (_, index) => + JSON.stringify(tagged(String(index), `ses_${index % 2}`)), + ).join("\n") + expect(parsePromptHistory(text, "global")).toHaveLength(MAX_HISTORY_ENTRIES) + expect(parsePromptHistory(text, "session")).toHaveLength(MAX_HISTORY_ENTRIES + 1) + }) }) diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 0351c46cde10..5ec10c8d2e31 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -412,9 +412,26 @@ This is separate from `opencode.json`, which configures server/runtime behavior. - `cursor` - Controls the terminal cursor in TUI input fields. `style` defaults to `"block"`, can be `"underline"`, `"line"`, or `"default"`; `blinking` defaults to `true`. When `style` is `"default"`, the terminal default cursor is restored, so `blinking` has no effect. - `mouse` - Enable or disable mouse capture in the TUI (default: `true`). When disabled, the terminal's native mouse selection/scrolling behavior is preserved. - `attention` - Configures TUI desktop notifications and sounds. Disabled by default. +- `prompt.history_scope` - Which prompts up and down recall in a session: `"global"` (default) or `"session"`. See [Prompt history](#prompt-history). Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path. +### Prompt history + +up on the first line of the prompt recalls the prompt submitted before, and down on the last line goes forward again. A shell command run with `!` comes back in shell mode, and a draft of 20 characters or more cleared with ctrl+c is kept as well. + +By default the history is global: every session walks the last 50 prompts submitted in any session on this machine, whichever OpenCode window sent them. With `prompt.history_scope` set to `"session"`, a session walks only the prompts submitted in that session, the last 50 of each. The home screen has no session yet, so there up still walks every session's prompts, and the prompt that starts a session becomes the first one in its history. + +```json title="tui.json" +{ + "prompt": { + "history_scope": "session" + } +} +``` + +Prompts submitted with an OpenCode version older than this setting were not recorded with their session, so no session recalls them; the home screen still does. The history lives in `prompt-history.jsonl` in the OpenCode state directory. + ### Attention The TUI can request attention for questions, permissions, session errors, and completed sessions. Enable it with `attention.enabled`; built-in events play sounds when triggered, and non-subagent events request desktop notifications only when the terminal is blurred.