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`.**