diff --git a/CHANGESET.md b/CHANGESET.md
index 8afd0d91e935..67eac81d5328 100644
--- a/CHANGESET.md
+++ b/CHANGESET.md
@@ -212,6 +212,7 @@ Newest first. Each entry names the upstream range brought in.
### Features
+- **#348** Add a configurable `message_window` setting for each session's loaded transcript, applying it consistently to message fetch, hydration reconciliation and part cleanup, and live-update trimming; keep recall-backed search independent of the loaded window and document the default and memory trade-off (swxtchio/swx-opencode#65). _Fork-only._
- **#297** Show the selected Ctrl+r history entry's complete original prompt in a wrapped, page-scrollable preview while
keeping all other entries as one-line summaries. ENTER still appends the selected original prompt to the existing
composer draft (swxtchio/swx-opencode#296). _Fork-only._
diff --git a/packages/opencode/test/cli/serve/serve-questions-sync.test.tsx b/packages/opencode/test/cli/serve/serve-questions-sync.test.tsx
index 4ae843f7adf4..30f869fccf38 100644
--- a/packages/opencode/test/cli/serve/serve-questions-sync.test.tsx
+++ b/packages/opencode/test/cli/serve/serve-questions-sync.test.tsx
@@ -8,6 +8,7 @@ import path from "node:path"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import { createOpencodeClient, type GlobalEvent } from "@opencode-ai/sdk/v2"
+import { TuiConfig } from "@opencode-ai/tui/config"
import { ArgsProvider } from "@opencode-ai/tui/context/args"
import { ExitProvider } from "@opencode-ai/tui/context/exit"
import { KVProvider } from "@opencode-ai/tui/context/kv"
@@ -145,21 +146,23 @@ const attach = Effect.fn("attach")(function* (url: string, directory: string) {
>
-
-
-
-
-
- {}}>
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ {}}>
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts
index 4f9c875c406b..92b01cd7cea3 100644
--- a/packages/opencode/test/config/tui.test.ts
+++ b/packages/opencode/test/config/tui.test.ts
@@ -157,6 +157,20 @@ it.instance("loads the prompt enhancement model from the dedicated tui config",
),
)
+it.instance("loads the message window from the dedicated tui config", () =>
+ withCleanState(
+ Effect.gen(function* () {
+ const fs = yield* FSUtil.Service
+ const test = yield* TestInstance
+
+ expect((yield* getTuiConfig(test.directory)).message_window).toBe(100)
+ yield* fs.writeJson(path.join(test.directory, "tui.json"), { message_window: 175 })
+
+ expect((yield* getTuiConfig(test.directory)).message_window).toBe(175)
+ }),
+ ),
+)
+
it.instance("resolves attention config defaults and overrides", () =>
withCleanState(
Effect.gen(function* () {
diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx
index 138bc0221fb5..4345792d925d 100644
--- a/packages/tui/src/config/index.tsx
+++ b/packages/tui/src/config/index.tsx
@@ -22,6 +22,10 @@ export const LeaderTimeoutDefault = 2000
export const LeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({
description: "Leader key timeout in milliseconds",
})
+export const MessageWindowDefault = 100
+export const MessageWindow = Schema.Int.check(Schema.isGreaterThan(0)).annotate({
+ description: "Maximum number of messages retained in each session's loaded transcript",
+})
export const ScrollSpeed = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))
export const ScrollAcceleration = Schema.Struct({
@@ -67,6 +71,7 @@ export const Info = Schema.Struct({
keybinds: Schema.optional(TuiKeybind.KeybindOverrides),
plugin: Schema.optional(Schema.Array(PluginSpec)),
plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
+ message_window: Schema.optional(MessageWindow),
leader_timeout: Schema.optional(LeaderTimeout),
attention: Schema.optional(Attention),
prompt: Schema.optional(Prompt),
@@ -79,7 +84,10 @@ export const Info = Schema.Struct({
})
export type Info = Schema.Schema.Type
-export type Resolved = Omit & {
+export type Resolved = Omit<
+ Info,
+ "attention" | "keybinds" | "leader_timeout" | "mouse" | "cursor" | "message_window"
+> & {
attention: {
enabled: boolean
notifications: boolean
@@ -90,6 +98,7 @@ export type Resolved = Omit {
const startup = useTuiStartup()
+ const tuiConfig = useTuiConfig()
const kv = useKV()
const permission = usePermission()
const [store, setStore] = createStore<{
@@ -376,7 +378,7 @@ export const {
}),
)
const updated = store.message[event.properties.info.sessionID]
- if (updated.length > 100) {
+ if (updated.length > tuiConfig.message_window) {
const oldest = updated[0]
batch(() => {
setStore(
@@ -669,7 +671,7 @@ export const {
const task = (async () => {
const [session, messages, todo, diff, queue] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
- sdk.client.session.messages({ sessionID, limit: 100 }),
+ sdk.client.session.messages({ sessionID, limit: tuiConfig.message_window }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
sdk.client.session.queue.list({ sessionID }),
@@ -697,8 +699,8 @@ export const {
),
)
infos.sort(compareMessage)
- const removed = infos.slice(0, -100)
- const visible = infos.slice(-100)
+ const removed = infos.slice(0, -tuiConfig.message_window)
+ const visible = infos.slice(-tuiConfig.message_window)
const visibleIDs = new Set(visible.map((message) => message.id))
for (const message of messages.data ?? []) {
if (!visibleIDs.has(message.info.id)) {
diff --git a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx
index cb83e2ae0d3e..121ec51816eb 100644
--- a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx
+++ b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx
@@ -3,6 +3,7 @@ import { testRender } from "@opentui/solid"
import type { JSX } from "@opentui/solid"
import { onMount } from "solid-js"
import { ArgsProvider } from "../../../../src/context/args"
+import { TuiConfigProvider } from "../../../../src/config"
import { KVProvider, useKV } from "../../../../src/context/kv"
import { ProjectProvider, useProject } from "../../../../src/context/project"
import { SDKProvider } from "../../../../src/context/sdk"
@@ -11,6 +12,7 @@ import { PermissionProvider } from "../../../../src/context/permission"
import { ExitProvider } from "../../../../src/context/exit"
import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk"
import { TestTuiContexts } from "../../../fixture/tui-environment"
+import { createTuiResolvedConfig } from "../../../fixture/tui-runtime"
export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk"
export async function wait(fn: () => boolean, timeout = 2000) {
@@ -28,6 +30,7 @@ export async function mount(
state?: string,
waitForReady = true,
children?: () => JSX.Element,
+ tuiConfig = createTuiResolvedConfig(),
) {
const calls = createFetch(override)
const events = createEventSource()
@@ -56,22 +59,24 @@ export async function mount(
const app = await testRender(() => (
-
-
-
-
-
- {}}>
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ {}}>
+
+
+
+
+
+
+
+
+
+
+
))
diff --git a/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx b/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx
index de7d4eb2fafe..1179449eaa53 100644
--- a/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx
+++ b/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx
@@ -2,6 +2,7 @@
import { expect, test } from "bun:test"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { tmpdir } from "../../../fixture/fixture"
+import { createTuiResolvedConfig } from "../../../fixture/tui-runtime"
import { json, mount, wait } from "./sync-fixture"
const sessionID = "ses_hydration_race"
@@ -246,6 +247,182 @@ test("live messages merged during hydration retain the 100 message window", asyn
}
})
+test("configured message window reconciles live updates during hydration", async () => {
+ await using tmp = await tmpdir()
+ await Bun.write(`${tmp.path}/kv.json`, "{}")
+
+ const messageWindow = 135
+ let resolveMessages!: (response: Response) => void
+ const messages = new Promise((resolve) => {
+ resolveMessages = resolve
+ })
+ let requested = false
+ let requestedLimit = 0
+ const { app, emit, sync } = await mount(
+ (url) => {
+ if (url.pathname === `/session/${sessionID}`) return json(session)
+ if (url.pathname === `/session/${sessionID}/message`) {
+ requested = true
+ requestedLimit = Number(url.searchParams.get("limit"))
+ return messages
+ }
+ if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
+ return undefined
+ },
+ tmp.path,
+ true,
+ undefined,
+ createTuiResolvedConfig({ message_window: messageWindow }),
+ )
+
+ try {
+ const hydrate = sync.session.sync(sessionID)
+ await wait(() => requested)
+ const live = {
+ ...assistant,
+ id: "msg_z_live",
+ }
+ emit(global({ id: "evt_live", type: "message.updated", properties: { sessionID, info: live } }))
+ emit(
+ global({
+ id: "evt_live_part",
+ type: "message.part.updated",
+ properties: {
+ sessionID,
+ time: 2,
+ part: { id: "prt_msg_z_live", sessionID, messageID: live.id, type: "text", text: "live part" },
+ },
+ }),
+ )
+ await wait(() => sync.data.part[live.id]?.[0]?.type === "text")
+
+ const history = Array.from({ length: messageWindow }, (_, index) => {
+ const id = `msg_${String(index).padStart(3, "0")}`
+ return {
+ info: { ...assistant, id },
+ parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text: id }],
+ }
+ })
+ resolveMessages(json(history.slice(-requestedLimit)))
+ await hydrate
+
+ expect(requestedLimit).toBe(messageWindow)
+ expect(sync.data.message[sessionID].map((message) => message.id)).toEqual([
+ ...history.slice(1).map((message) => message.info.id),
+ live.id,
+ ])
+ expect(sync.data.part[history[0]!.info.id]).toBeUndefined()
+ expect(sync.data.part[history[1]!.info.id]?.[0]).toMatchObject({ text: history[1]!.info.id })
+ expect(sync.data.part[live.id]?.[0]).toMatchObject({ text: "live part" })
+
+ const newest = {
+ ...assistant,
+ id: "msg_zz_live",
+ }
+ emit(global({ id: "evt_newest", type: "message.updated", properties: { sessionID, info: newest } }))
+ emit(
+ global({
+ id: "evt_newest_part",
+ type: "message.part.updated",
+ properties: {
+ sessionID,
+ time: 3,
+ part: { id: "prt_msg_zz_live", sessionID, messageID: newest.id, type: "text", text: "newest part" },
+ },
+ }),
+ )
+ await wait(() => sync.data.part[newest.id]?.[0]?.type === "text")
+
+ expect(sync.data.message[sessionID]).toHaveLength(messageWindow)
+ expect(sync.data.message[sessionID].map((message) => message.id)).toEqual([
+ ...history.slice(2).map((message) => message.info.id),
+ live.id,
+ newest.id,
+ ])
+ expect(sync.data.part[history[1]!.info.id]).toBeUndefined()
+ expect(sync.data.part[newest.id]?.[0]).toMatchObject({ text: "newest part" })
+ } finally {
+ app.renderer.destroy()
+ }
+})
+
+test("live growth keeps messages beyond the old cap inside a larger configured window", async () => {
+ await using tmp = await tmpdir()
+ await Bun.write(`${tmp.path}/kv.json`, "{}")
+
+ const messageWindow = 130
+ const initial = Array.from({ length: 90 }, (_, index) => {
+ const id = `msg_${String(index).padStart(3, "0")}`
+ return {
+ info: { ...assistant, id },
+ parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text: id }],
+ }
+ })
+ const live = Array.from({ length: 20 }, (_, index) => {
+ const id = `msg_${String(initial.length + index).padStart(3, "0")}`
+ return {
+ info: { ...assistant, id },
+ part: { id: `prt_${id}`, sessionID, messageID: id, type: "text" as const, text: id },
+ }
+ })
+ let requestedLimit = 0
+ const { app, emit, sync } = await mount(
+ (url) => {
+ if (url.pathname === `/session/${sessionID}`) return json(session)
+ if (url.pathname === `/session/${sessionID}/message`) {
+ requestedLimit = Number(url.searchParams.get("limit"))
+ return json(initial.slice(-requestedLimit))
+ }
+ if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
+ return undefined
+ },
+ tmp.path,
+ true,
+ undefined,
+ createTuiResolvedConfig({ message_window: messageWindow }),
+ )
+
+ try {
+ await sync.session.sync(sessionID)
+ expect(requestedLimit).toBe(messageWindow)
+ expect(sync.data.message[sessionID]).toHaveLength(initial.length)
+
+ live.forEach((message) => {
+ emit(
+ global({
+ id: `evt_${message.info.id}`,
+ type: "message.updated",
+ properties: { sessionID, info: message.info },
+ }),
+ )
+ emit(
+ global({
+ id: `evt_${message.info.id}_part`,
+ type: "message.part.updated",
+ properties: { sessionID, time: 2, part: message.part },
+ }),
+ )
+ })
+ const oldestPastDefault = initial[9]!
+ const newest = live.at(-1)!
+ await wait(
+ () =>
+ sync.data.message[sessionID]?.length === initial.length + live.length &&
+ sync.data.part[newest.info.id]?.[0]?.type === "text",
+ )
+
+ expect(sync.data.message[sessionID].map((message) => message.id)).toEqual([
+ ...initial.map((message) => message.info.id),
+ ...live.map((message) => message.info.id),
+ ])
+ expect(sync.data.message[sessionID].some((message) => message.id === oldestPastDefault.info.id)).toBe(true)
+ expect(sync.data.part[oldestPastDefault.info.id]?.[0]).toMatchObject({ text: oldestPastDefault.info.id })
+ expect(sync.data.part[newest.info.id]?.[0]).toMatchObject({ text: newest.info.id })
+ } finally {
+ app.renderer.destroy()
+ }
+})
+
test("a message removed during hydration does not regain stale parts", async () => {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
diff --git a/packages/tui/test/config.test.tsx b/packages/tui/test/config.test.tsx
index d93ddb53b857..aec26f6f64a7 100644
--- a/packages/tui/test/config.test.tsx
+++ b/packages/tui/test/config.test.tsx
@@ -60,6 +60,7 @@ test("resolves host-neutral defaults", () => {
sounds: {},
})
expect(config.leader_timeout).toBe(LeaderTimeoutDefault)
+ expect(config.message_window).toBe(100)
expect(config.mouse).toBe(true)
expect(config.keybinds.has("terminal.suspend")).toBe(true)
expect(config.keybinds.has("session.list")).toBe(true)
@@ -67,6 +68,15 @@ test("resolves host-neutral defaults", () => {
expect(config.prompt_enhance_model).toBeUndefined()
})
+test("validates and resolves the loaded message window", () => {
+ expect(decodeInfo({ message_window: 150 })).toMatchObject({ message_window: 150 })
+ expect(() => decodeInfo({ message_window: 0 })).toThrow()
+ expect(() => decodeInfo({ message_window: 1.5 })).toThrow()
+
+ expect(resolve({}, { terminalSuspend: true }).message_window).toBe(100)
+ expect(resolve({ message_window: 150 }, { terminalSuspend: true }).message_window).toBe(150)
+})
+
test("resolves overrides without mutating input", () => {
const input: TuiConfigInfo = {
theme: "custom",
diff --git a/packages/tui/test/routes/session/queue-review.test.tsx b/packages/tui/test/routes/session/queue-review.test.tsx
index 54e6e6d8066b..86030c1a74ec 100644
--- a/packages/tui/test/routes/session/queue-review.test.tsx
+++ b/packages/tui/test/routes/session/queue-review.test.tsx
@@ -140,6 +140,7 @@ async function startSession(
}
const requests: string[] = []
const paths: string[] = []
+ const messageLimits: number[] = []
const executions: string[] = []
const selections: unknown[] = []
const enhancements: EnhancementRequest[] = []
@@ -221,11 +222,14 @@ async function startSession(
} as Session)
const messageSessionID = url.pathname.match(/^\/session\/(ses_[^/]+)\/message$/)?.[1]
if (messageSessionID) {
- if (url.searchParams.get("limit") === "0") historyReads.push(messageSessionID)
- if (url.searchParams.get("limit") === "0") {
+ const limit = url.searchParams.get("limit")
+ if (limit === "0") historyReads.push(messageSessionID)
+ if (limit === "0") {
return json(options.historyMessagesBySession?.[messageSessionID] ?? [])
}
- return json(options.messagesBySession?.[messageSessionID] ?? options.messages ?? [transcript])
+ if (limit) messageLimits.push(Number(limit))
+ const messages = options.messagesBySession?.[messageSessionID] ?? options.messages ?? [transcript]
+ return json(limit ? messages.slice(-Number(limit)) : messages)
}
if (url.pathname === `/session/${activeSessionID}/queue/release`) return new Response(null, { status: 204 })
if (url.pathname.startsWith(`/session/${activeSessionID}/queue`)) return json(queue.items)
@@ -306,6 +310,7 @@ async function startSession(
renderer: setup,
requests,
paths,
+ messageLimits,
historyReads,
executions,
selections,
@@ -1854,6 +1859,54 @@ test("history from a new-session submission is owned by the created session", as
}
}, 20_000)
+test("configured message window keeps older transcript text scroll-reachable after reload", async () => {
+ await using state = await tmpdir()
+ const messageWindow = 130
+ const messages = Array.from({ length: 145 }, (_, index) => {
+ const id = `msg_window_${String(index).padStart(3, "0")}`
+ return {
+ info: { ...transcript.info, id, time: { created: index + 1 } },
+ parts: [
+ {
+ ...transcript.parts[0],
+ id: `prt_${id}`,
+ messageID: id,
+ text: `loaded-window-${String(index).padStart(3, "0")}`,
+ },
+ ],
+ }
+ })
+ const options = {
+ state: state.path,
+ queue: [],
+ messagesBySession: { [sessionID]: messages },
+ tuiConfig: createTuiResolvedConfig({ plugin_enabled: {}, message_window: messageWindow }),
+ }
+ const checkScroll = async (app: Awaited>) => {
+ await until(async () => (await screen(app)).join("\n").includes("loaded-window-144"), "the latest message")
+ app.dispatch("session.first")
+ await until(async () => (await screen(app)).join("\n").includes("loaded-window-015"), "the oldest loaded message")
+ const frame = (await screen(app)).join("\n")
+ expect(app.messageLimits).toContain(messageWindow)
+ expect(frame).toContain("loaded-window-015")
+ expect(frame).not.toContain("loaded-window-014")
+ }
+
+ const app = await startSession(options)
+ try {
+ await checkScroll(app)
+ } finally {
+ await app.stop()
+ }
+
+ const reloaded = await startSession(options)
+ try {
+ await checkScroll(reloaded)
+ } finally {
+ await reloaded.stop()
+ }
+}, 30_000)
+
test("search reaches unrendered recall history and ENTER appends original multiline text without sending", async () => {
await using state = await tmpdir()
const original = "older-unrendered-history-token\noriginal second line"
@@ -1880,16 +1933,21 @@ test("search reaches unrendered recall history and ENTER appends original multil
seeded.prompts.forEach((prompt) => prompt.answer(json(transcript)))
await seeded.stop()
}
+ const recalled = promptMessage(seeded.prompts[0]!, sessionID, 0)
const app = await startSession({
state: state.path,
queue: [],
+ messagesBySession: { [sessionID]: [recalled, transcript] },
+ historyMessagesBySession: { [sessionID]: [recalled, transcript] },
serverConfig: { machine_message_markers: { hold: [{ type: "prefix", value: "CUSTOM-HOLD:" }] } },
+ tuiConfig: createTuiResolvedConfig({ plugin_enabled: {}, message_window: 1 }),
})
try {
await until(
async () => (await screen(app)).join("\n").includes("already in the transcript"),
"only the newer loaded transcript",
)
+ expect(app.messageLimits).toContain(1)
expect((await screen(app)).join("\n")).not.toContain("older-unrendered-history-token")
app.renderer.mockInput.pressArrow("up")
await app.renderer.renderOnce()
diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx
index 856ef392c748..07599a45fa57 100644
--- a/packages/web/src/content/docs/tui.mdx
+++ b/packages/web/src/content/docs/tui.mdx
@@ -395,6 +395,7 @@ This is separate from `opencode.json`, which configures server/runtime behavior.
- `theme` - Sets your UI theme. [Learn more](/docs/themes).
- `keybinds` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds).
+- `message_window` - Controls how many recent messages are kept in each session's loaded transcript. It defaults to `100`. A larger value lets you scroll farther back and uses more memory because more messages and their parts stay loaded; durable session history is unaffected.
- `leader_timeout` - Controls how long OpenCode waits after the leader key. Defaults to `2000`.
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**