Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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: "",
Expand Down Expand Up @@ -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()
Expand Down
8 changes: 7 additions & 1 deletion packages/tui/src/config/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
123 changes: 89 additions & 34 deletions packages/tui/src/prompt/history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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<string, number>()
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(() => {})
},
}
},
Expand Down
66 changes: 65 additions & 1 deletion packages/tui/test/prompt/history.test.ts
Original file line number Diff line number Diff line change
@@ -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 })

Expand Down Expand Up @@ -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)
})
})
17 changes: 17 additions & 0 deletions packages/web/src/content/docs/tui.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>up</kbd> and <kbd>down</kbd> 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

<kbd>up</kbd> on the first line of the prompt recalls the prompt submitted before, and <kbd>down</kbd> 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 <kbd>ctrl+c</kbd> 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 <kbd>up</kbd> 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.
Expand Down
Loading