From 7661b165d6294e3eaaab2a66fd9738595f223360 Mon Sep 17 00:00:00 2001 From: Nowaker Date: Tue, 6 Oct 2026 20:06:28 -0500 Subject: [PATCH 1/2] feat(tui): show and load the messages a long session hides The TUI keeps the newest 100 messages of a session loaded and drops the oldest one for every new message past that, silently. Scrolling to the top of a long session lands somewhere in the middle of the work, the first prompt is the first thing to go, and nothing brings older messages back. Wherever messages are hidden the transcript now shows a divider: a horizontal rule with the hidden count centered on it, and below it "load 50 above", "load 50 below" and "load all" (just "load all N" for a gap of 50 or fewer). The actions are clickable and are palette commands ("Load 50 hidden messages above/below the divider", "Load all hidden messages", /load-hidden) with keybinds messages_hidden_above, messages_hidden_below and messages_hidden_all, unbound by default. "Above" fills the gap from its older edge, "below" from its newer edge. Loading grows the window by what it loaded so new output doesn't immediately drop it again, and "load all" stops dropping messages for that session. When the view is scrolled away from the bottom, the block at the top of the viewport is put back on the same row after a load. tui.json gains transcript.max_messages (default 100, the old constant) and transcript.keep_first_prompt (default true), which keeps the session's first prompt above the divider when older messages are hidden. The sync store tracks each session's gap as head (messages before it), count (hidden) and limit (newest messages kept). A new message past the limit hides the oldest message after the head, and an update to a message inside the gap is ignored instead of reinserted. GET /session/:id/message pages backwards only, so it gains after (a cursor or message id; newer messages, oldest first), order=asc (page from the oldest message), message ids for before as well as cursors, and X-Total-Count on paged responses. The TUI anchors on message ids it already holds, because a cursor goes stale once the message it points at has been dropped. The first prompt is requested only when the newest page doesn't cover the whole session. --- .../routes/instance/httpapi/groups/session.ts | 10 +- .../instance/httpapi/handlers/session.ts | 40 ++- packages/opencode/src/session/message-v2.ts | 62 +++- .../test/server/session-messages.test.ts | 35 +++ .../test/session/messages-pagination.test.ts | 48 +++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 4 + packages/sdk/js/src/v2/gen/types.gen.ts | 2 + packages/tui/src/config/index.tsx | 12 + packages/tui/src/config/keybind.ts | 6 + packages/tui/src/context/sync.tsx | 150 +++++++-- packages/tui/src/routes/session/index.tsx | 295 ++++++++++++------ .../tui/test/cli/cmd/tui/sync-fixture.tsx | 16 +- .../cli/cmd/tui/sync-hidden-messages.test.tsx | 213 +++++++++++++ packages/tui/test/config.test.tsx | 10 + packages/web/src/content/docs/keybinds.mdx | 3 + packages/web/src/content/docs/tui.mdx | 2 + 16 files changed, 777 insertions(+), 131 deletions(-) create mode 100644 packages/tui/test/cli/cmd/tui/sync-hidden-messages.test.tsx diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 959a303dc964..ed2d728b42a3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -43,7 +43,15 @@ export const DiffQuery = Schema.Struct({ export const MessagesQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), - before: Schema.optional(Schema.String), + before: Schema.optional(Schema.String).annotate({ + description: "Return messages older than this cursor (X-Next-Cursor of a previous page) or message ID", + }), + after: Schema.optional(Schema.String).annotate({ + description: "Return messages newer than this cursor (X-Next-Cursor of a previous page) or message ID", + }), + order: Schema.optional(Schema.Literals(["asc", "desc"])).annotate({ + description: "Without before or after: 'desc' (default) pages from the newest message, 'asc' from the oldest", + }), }) export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info) export const UpdatePayload = Schema.Struct({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 662585020a64..a7c6b71c1a93 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -19,7 +19,7 @@ import { NamedError } from "@opencode-ai/core/util/error" import { Cause, Effect, Option, Schema, Scope } from "effect" import * as Stream from "effect/Stream" import { InstanceState } from "@/effect/instance-state" -import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { @@ -107,11 +107,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } query: typeof MessagesQuery.Type }) { - if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({}) - if (ctx.query.before) { - const before = ctx.query.before + const anchor = ctx.query.after ?? ctx.query.before + if (ctx.query.before && ctx.query.after) return yield* new HttpApiError.BadRequest({}) + if (ctx.query.after && ctx.query.order === "desc") return yield* new HttpApiError.BadRequest({}) + if (ctx.query.before && ctx.query.order === "asc") return yield* new HttpApiError.BadRequest({}) + if (anchor && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({}) + if (anchor && !anchor.startsWith("msg")) { yield* Effect.try({ - try: () => MessageV2.cursor.decode(before), + try: () => MessageV2.cursor.decode(anchor), catch: () => new HttpApiError.BadRequest({}), }) } @@ -125,21 +128,42 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", sessionID: ctx.params.sessionID, limit: ctx.query.limit, before: ctx.query.before, + after: ctx.query.after, + order: ctx.query.order, }), ) - if (!page.cursor) return page.items + const total = yield* MessageV2.total(ctx.params.sessionID) + if (!page.cursor) { + // Returning the items, not a jsonUnsafe response, keeps them going + // through the success schema, so a corrupt stored row is still a 400. + yield* HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed( + response.status >= 400 + ? response + : HttpServerResponse.setHeaders(response, { + "Access-Control-Expose-Headers": "X-Total-Count", + "X-Total-Count": total.toString(), + }), + ), + ) + return page.items + } const request = yield* HttpServerRequest.HttpServerRequest // toURL() honors the Host + x-forwarded-proto headers, so the Link // header echoes the real origin instead of a hard-coded localhost. const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost")) + const direction = ctx.query.after !== undefined || ctx.query.order === "asc" ? "after" : "before" url.searchParams.set("limit", ctx.query.limit.toString()) - url.searchParams.set("before", page.cursor) + url.searchParams.delete("order") + url.searchParams.delete(direction === "after" ? "before" : "after") + url.searchParams.set(direction, page.cursor) return HttpServerResponse.jsonUnsafe(page.items, { headers: { - "Access-Control-Expose-Headers": "Link, X-Next-Cursor", + "Access-Control-Expose-Headers": "Link, X-Next-Cursor, X-Total-Count", Link: `<${url.toString()}>; rel="next"`, "X-Next-Cursor": page.cursor, + "X-Total-Count": total.toString(), }, }) }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 75f2d89379d7..ec32a625b7f1 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -26,6 +26,9 @@ import { desc } from "drizzle-orm" import { eq } from "drizzle-orm" import { inArray } from "drizzle-orm" import { lt } from "drizzle-orm" +import { gt } from "drizzle-orm" +import { asc } from "drizzle-orm" +import { count } from "drizzle-orm" import { or } from "drizzle-orm" import { MessageTable, PartTable, SessionTable } from "@opencode-ai/core/session/sql" import { ProviderError } from "@/provider/error" @@ -67,6 +70,7 @@ const Cursor = Schema.Struct({ type Cursor = typeof Cursor.Type const decodeCursor = Schema.decodeUnknownSync(Cursor) +const decodeMessageID = Schema.decodeUnknownSync(MessageID) export const cursor = { encode(input: Cursor) { @@ -95,6 +99,9 @@ const part = (row: typeof PartTable.$inferSelect) => const older = (row: Cursor) => or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id))) +const newer = (row: Cursor) => + or(gt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), gt(MessageTable.id, row.id))) + function hydrate(db: Database.Interface["db"], rows: (typeof MessageTable.$inferSelect)[]) { const ids = rows.map((row) => row.id) const partByMessage = new Map() @@ -435,21 +442,32 @@ export function toModelMessages( return Effect.runPromise(toModelMessagesEffect(input, model, options)) } +// Pages through a session's messages. Without an anchor the page is the newest +// `limit` messages, or the oldest with `order: "asc"`. `before` and `after` take +// a cursor from a previous page or a message ID, and return the messages strictly +// older or newer than it. Items are always in chronological order; `cursor` +// continues in the same direction. export const page = Effect.fn("MessageV2.page")(function* (input: { sessionID: SessionID limit: number before?: string + after?: string + order?: "asc" | "desc" }) { const { db } = yield* Database.Service - const before = input.before ? cursor.decode(input.before) : undefined - const where = before - ? and(eq(MessageTable.session_id, input.sessionID), older(before)) - : eq(MessageTable.session_id, input.sessionID) + const ascending = input.after !== undefined || input.order === "asc" + const anchorValue = input.after ?? input.before + const anchor = anchorValue ? yield* resolveAnchor(db, input.sessionID, anchorValue) : undefined + const session = eq(MessageTable.session_id, input.sessionID) const rows = yield* db .select() .from(MessageTable) - .where(where) - .orderBy(desc(MessageTable.time_created), desc(MessageTable.id)) + .where(anchor ? and(session, ascending ? newer(anchor) : older(anchor)) : session) + .orderBy( + ...(ascending + ? [asc(MessageTable.time_created), asc(MessageTable.id)] + : [desc(MessageTable.time_created), desc(MessageTable.id)]), + ) .limit(input.limit + 1) .all() .pipe(Effect.orDie) @@ -461,16 +479,12 @@ export const page = Effect.fn("MessageV2.page")(function* (input: { .get() .pipe(Effect.orDie) if (!row) return yield* new NotFoundError({ message: `Session not found: ${input.sessionID}` }) - return { - items: [] as WithParts[], - more: false, - } } const more = rows.length > input.limit const slice = more ? rows.slice(0, input.limit) : rows const items = yield* hydrate(db, slice) - items.reverse() + if (!ascending) items.reverse() const tail = slice.at(-1) return { items, @@ -479,6 +493,32 @@ export const page = Effect.fn("MessageV2.page")(function* (input: { } }) +export const total = Effect.fn("MessageV2.total")(function* (sessionID: SessionID) { + const { db } = yield* Database.Service + const row = yield* db + .select({ value: count() }) + .from(MessageTable) + .where(eq(MessageTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + return row?.value ?? 0 +}) + +function resolveAnchor(db: Database.Interface["db"], sessionID: SessionID, value: string) { + return Effect.gen(function* () { + if (!value.startsWith("msg")) return cursor.decode(value) + const id = decodeMessageID(value) + const row = yield* db + .select({ time: MessageTable.time_created }) + .from(MessageTable) + .where(and(eq(MessageTable.session_id, sessionID), eq(MessageTable.id, id))) + .get() + .pipe(Effect.orDie) + if (!row) return yield* new NotFoundError({ message: `Message not found: ${value}` }) + return { id, time: row.time } + }) +} + export function stream(sessionID: SessionID) { const size = 50 return Effect.gen(function* () { diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index c47913bde5ab..792072882741 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -110,6 +110,41 @@ describe("session messages endpoint", () => { { git: true }, ) + it.instance( + "pages forward from the oldest message and reports the total", + withoutWatcher( + Effect.gen(function* () { + const session = yield* sessionScoped + const ids = yield* fill(session.id, 5) + + const a = yield* request(`/session/${session.id}/message?limit=2&order=asc`) + expect(a.status).toBe(200) + expect((yield* json(a)).map((item) => item.info.id)).toEqual(ids.slice(0, 2)) + expect(a.headers["x-total-count"]).toBe("5") + expect(a.headers["link"]).toContain("after=") + + const b = yield* request( + `/session/${session.id}/message?limit=2&after=${encodeURIComponent(a.headers["x-next-cursor"]!)}`, + ) + expect((yield* json(b)).map((item) => item.info.id)).toEqual(ids.slice(2, 4)) + + const last = yield* request(`/session/${session.id}/message?limit=2&after=${ids[3]}`) + expect((yield* json(last)).map((item) => item.info.id)).toEqual(ids.slice(4)) + expect(last.headers["x-next-cursor"]).toBeUndefined() + expect(last.headers["x-total-count"]).toBe("5") + + const older = yield* request(`/session/${session.id}/message?limit=2&before=${ids[3]}`) + expect((yield* json(older)).map((item) => item.info.id)).toEqual(ids.slice(1, 3)) + + const conflict = yield* request(`/session/${session.id}/message?limit=2&before=${ids[3]}&after=${ids[1]}`) + expect(conflict.status).toBe(400) + const mismatch = yield* request(`/session/${session.id}/message?limit=2&order=asc&before=${ids[3]}`) + expect(mismatch.status).toBe(400) + }), + ), + { git: true }, + ) + it.instance( "keeps full-history responses when limit is omitted", withoutWatcher( diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index b67c982ebcd3..03443ddf7412 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -164,6 +164,54 @@ describe("MessageV2.page", () => { ), ) + it.instance("pages forward from the oldest message", () => + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 5) + + const a = yield* MessageV2.page({ sessionID, limit: 2, order: "asc" }) + expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) + expect(a.more).toBe(true) + + const b = yield* MessageV2.page({ sessionID, limit: 2, after: a.cursor! }) + expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(2, 4)) + + const c = yield* MessageV2.page({ sessionID, limit: 2, after: b.cursor! }) + expect(c.items.map((item) => item.info.id)).toEqual(ids.slice(4)) + expect(c.more).toBe(false) + expect(c.cursor).toBeUndefined() + }), + ), + ) + + it.instance("anchors before and after a message ID", () => + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 6) + + const older = yield* MessageV2.page({ sessionID, limit: 2, before: ids[3] }) + expect(older.items.map((item) => item.info.id)).toEqual(ids.slice(1, 3)) + expect(older.more).toBe(true) + + const newer = yield* MessageV2.page({ sessionID, limit: 2, after: ids[3] }) + expect(newer.items.map((item) => item.info.id)).toEqual(ids.slice(4, 6)) + expect(newer.more).toBe(false) + + expect(yield* MessageV2.total(sessionID)).toBe(6) + }), + ), + ) + + it.instance("fails with NotFoundError for an anchor message outside the session", () => + withSession(({ sessionID }) => + Effect.gen(function* () { + yield* fill(sessionID, 1) + const error = yield* Effect.flip(MessageV2.page({ sessionID, limit: 2, before: "msg_missing" })) + expect(error).toBeInstanceOf(NotFoundError) + }), + ), + ) + it.instance("returns items in chronological order within a page", () => withSession(({ sessionID }) => Effect.gen(function* () { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index a2bcd4252c6d..48eb279106a6 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -3710,6 +3710,8 @@ export class Session2 extends HeyApiClient { workspace?: string limit?: number before?: string + after?: string + order?: "asc" | "desc" }, options?: Options, ) { @@ -3723,6 +3725,8 @@ export class Session2 extends HeyApiClient { { in: "query", key: "workspace" }, { in: "query", key: "limit" }, { in: "query", key: "before" }, + { in: "query", key: "after" }, + { in: "query", key: "order" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 7c04dac47346..bc23a4d521e2 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -9765,6 +9765,8 @@ export type SessionMessagesData = { workspace?: string limit?: number before?: string + after?: string + order?: "asc" | "desc" } url: "/session/{sessionID}/message" } diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index bde0bfb0718a..0014d9e9fd52 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -39,6 +39,17 @@ export const Cursor = Schema.Struct({ }), }).annotate({ description: "Terminal cursor settings" }) +export const TranscriptMaxMessagesDefault = 100 +export const Transcript = Schema.Struct({ + max_messages: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))).annotate({ + description: + "Most recent messages kept loaded per session; older ones are hidden behind a divider that loads them back (default: 100)", + }), + keep_first_prompt: Schema.optional(Schema.Boolean).annotate({ + description: "Keep the session's first prompt at the top when older messages are hidden (default: true)", + }), +}).annotate({ description: "Session transcript loading" }) + export const AttentionSounds = Schema.Record(AttentionSoundName, Schema.optionalKey(Schema.String)) export type AttentionSoundPaths = Schema.Schema.Type export const Attention = Schema.Struct({ @@ -71,6 +82,7 @@ export const Info = Schema.Struct({ scroll_acceleration: Schema.optional(ScrollAcceleration), diff_style: Schema.optional(DiffStyle), cursor: Schema.optional(Cursor), + transcript: Schema.optional(Transcript), mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }), }) export type Info = Schema.Schema.Type diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 5dd7e4b5aafe..148799f55a0b 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -143,6 +143,9 @@ export const Definitions = { messages_next: keybind("none", "Navigate to next message"), messages_previous: keybind("none", "Navigate to previous message"), messages_last_user: keybind("none", "Navigate to last user message"), + messages_hidden_above: keybind("none", "Load hidden messages above the divider"), + messages_hidden_below: keybind("none", "Load hidden messages below the divider"), + messages_hidden_all: keybind("none", "Load all hidden messages"), messages_copy: keybind("y", "Copy message"), messages_undo: keybind("u", "Undo message"), messages_redo: keybind("r", "Redo message"), @@ -348,6 +351,9 @@ export const CommandMap = { messages_next: "session.message.next", messages_previous: "session.message.previous", messages_last_user: "session.messages_last_user", + messages_hidden_above: "session.hidden.above", + messages_hidden_below: "session.hidden.below", + messages_hidden_all: "session.hidden.all", messages_copy: "messages.copy", messages_undo: "session.undo", messages_redo: "session.redo", diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 71e050d11e68..0f754bfddb04 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -32,12 +32,26 @@ import { batch, onMount } from "solid-js" import path from "path" import { useKV } from "./kv" import { usePermission } from "./permission" +import { TuiConfig, useTuiConfig } from "../config" const emptyConsoleState: ConsoleState = { consoleManagedProviders: [], switchableOrgCount: 0, } +export const HIDDEN_PAGE_SIZE = 50 + +// Messages a session keeps unloaded. The loaded list is `head` messages (the +// first prompt, plus any loaded right after it), a gap of `count` hidden +// messages, then the newest messages, at most `limit` of them. +export type HiddenMessages = { + head: number + count: number + limit: number + loading?: HiddenEdge +} +export type HiddenEdge = "above" | "below" | "all" + function search(items: T[], target: string, key: (item: T) => string) { let left = 0 let right = items.length - 1 @@ -102,6 +116,9 @@ export const { part: { [messageID: string]: Part[] } + hidden: { + [sessionID: string]: HiddenMessages + } lsp: LspStatus[] mcp: { [key: string]: McpStatus @@ -136,6 +153,7 @@ export const { todo: {}, message: {}, part: {}, + hidden: {}, lsp: [], mcp: {}, mcp_resource: {}, @@ -146,6 +164,11 @@ export const { const event = useEvent() const project = useProject() const sdk = useSDK() + const config = useTuiConfig() + const maxMessages = () => config.transcript?.max_messages ?? TuiConfig.TranscriptMaxMessagesDefault + const keepFirstPrompt = () => config.transcript?.keep_first_prompt ?? true + const hiddenOf = (sessionID: string): HiddenMessages => + store.hidden[sessionID] ?? { head: 0, count: 0, limit: maxMessages() } const fullSyncedSessions = new Set() const syncingSessions = new Map>() @@ -330,22 +353,30 @@ export const { setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info)) break } + const sessionID = event.properties.info.sessionID + const hidden = hiddenOf(sessionID) + // An update to a message inside the gap must not pull it back into view. + if (hidden.count > 0 && result.index === hidden.head) break setStore( "message", - event.properties.info.sessionID, + sessionID, produce((draft) => { draft.splice(result.index, 0, event.properties.info) }), ) - const updated = store.message[event.properties.info.sessionID] - if (updated.length > 100) { - const oldest = updated[0] + const updated = store.message[sessionID] + const head = + hidden.count === 0 && hidden.head === 0 && keepFirstPrompt() && updated[0]?.role === "user" + ? 1 + : hidden.head + if (updated.length - head > hidden.limit) { + const oldest = updated[head] batch(() => { setStore( "message", - event.properties.info.sessionID, + sessionID, produce((draft) => { - draft.shift() + draft.splice(head, 1) }), ) setStore( @@ -354,6 +385,7 @@ export const { delete draft[oldest.id] }), ) + setStore("hidden", sessionID, { head, count: hidden.count + 1, limit: hidden.limit }) }) } break @@ -362,14 +394,23 @@ export const { touchMessage(event.properties.sessionID, event.properties.messageID) const messages = store.message[event.properties.sessionID] const index = messages.findIndex((message) => message.id === event.properties.messageID) + const hidden = store.hidden[event.properties.sessionID] if (index !== -1) { - setStore( - "message", - event.properties.sessionID, - produce((draft) => { - draft.splice(index, 1) - }), - ) + batch(() => { + setStore( + "message", + event.properties.sessionID, + produce((draft) => { + draft.splice(index, 1) + }), + ) + if (hidden && index < hidden.head) setStore("hidden", event.properties.sessionID, "head", hidden.head - 1) + }) + break + } + if (hidden && hidden.count > 0) { + const count = hidden.count - 1 + setStore("hidden", event.properties.sessionID, { head: count > 0 ? hidden.head : 0, count }) } break } @@ -598,12 +639,26 @@ export const { const tracker = { messages: new Set(), parts: new Set() } hydratingSessions.set(sessionID, tracker) const task = (async () => { + const limit = maxMessages() const [session, messages, todo, diff] = await Promise.all([ sdk.client.session.get({ sessionID }, { throwOnError: true }), - sdk.client.session.messages({ sessionID, limit: 100 }), + sdk.client.session.messages({ sessionID, limit }), sdk.client.session.todo({ sessionID }), sdk.client.session.diff({ sessionID }), ]) + const total = Number(messages.response?.headers.get("x-total-count") ?? Number.NaN) + const cropped = total > (messages.data?.length ?? 0) + const first = + cropped && keepFirstPrompt() + ? await sdk.client.session.messages({ sessionID, limit: 1, order: "asc" }) + : undefined + const oldest = first?.data?.[0] + const pinned = + oldest?.info.role === "user" && + !(messages.data ?? []).some((message) => message.info.id === oldest.info.id) + ? oldest + : undefined + const loaded = [...(pinned ? [pinned] : []), ...(messages.data ?? [])] setStore( produce((draft) => { const match = search(draft.session, sessionID, (s) => s.id) @@ -611,7 +666,7 @@ export const { if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] const currentMessages = draft.message[sessionID] ?? [] - const infos = (messages.data ?? []).flatMap((message) => { + const infos = loaded.flatMap((message) => { if (!tracker.messages.has(message.info.id)) return [message.info] const current = currentMessages.find((item) => item.id === message.info.id) return current ? [current] : [] @@ -622,10 +677,17 @@ export const { ), ) infos.sort(compareMessage) - const removed = infos.slice(0, -100) - const visible = infos.slice(-100) + const head = pinned && infos[0]?.id === pinned.info.id ? 1 : 0 + const tail = infos.slice(head) + const removed = tail.slice(0, -limit) + const visible = [...infos.slice(0, head), ...tail.slice(-limit)] const visibleIDs = new Set(visible.map((message) => message.id)) - for (const message of messages.data ?? []) { + const servedIDs = new Set(loaded.map((message) => message.info.id)) + const count = Number.isFinite(total) + ? Math.max(0, total - visible.filter((message) => servedIDs.has(message.id)).length) + : 0 + draft.hidden[sessionID] = { head: count > 0 ? head : 0, count, limit } + for (const message of loaded) { if (!visibleIDs.has(message.info.id)) { delete draft.part[message.info.id] continue @@ -665,6 +727,58 @@ export const { syncingSessions.set(sessionID, task) return task }, + hidden(sessionID: string) { + return store.hidden[sessionID] + }, + // Loads hidden messages into the gap: "above" the oldest ones, right after + // the head; "below" the newest ones, right before the recent messages. + async load(sessionID: string, edge: HiddenEdge) { + const hidden = store.hidden[sessionID] + if (!hidden || hidden.count === 0 || hidden.loading) return + const messages = store.message[sessionID] ?? [] + const before = messages[hidden.head]?.id + const after = hidden.head > 0 ? messages[hidden.head - 1]?.id : undefined + setStore("hidden", sessionID, "loading", edge) + const result = await ( + edge === "all" + ? sdk.client.session.messages({ sessionID }) + : edge === "below" + ? sdk.client.session.messages({ sessionID, limit: HIDDEN_PAGE_SIZE, before }) + : after + ? sdk.client.session.messages({ sessionID, limit: HIDDEN_PAGE_SIZE, after }) + : sdk.client.session.messages({ sessionID, limit: HIDDEN_PAGE_SIZE, order: "asc" }) + ).finally(() => setStore("hidden", sessionID, "loading", undefined)) + if (!result.data) return + const total = Number(result.response?.headers.get("x-total-count") ?? Number.NaN) + setStore( + produce((draft) => { + const list = draft.message[sessionID] ?? [] + const known = new Set(list.map((message) => message.id)) + const fresh = result.data.filter((message) => !known.has(message.info.id)) + for (const message of fresh) { + list.splice(search(list, messageKey(message.info), messageKey).index, 0, message.info) + draft.part[message.info.id] = message.parts + } + draft.message[sessionID] = list + const current = draft.hidden[sessionID] ?? hidden + const head = edge === "above" ? current.head + fresh.length : current.head + const count = + edge === "all" + ? 0 + : Number.isFinite(total) + ? Math.max(0, total - list.length) + : Math.max(0, current.count - fresh.length) + draft.hidden[sessionID] = { + head: count > 0 ? head : 0, + count, + limit: + edge === "all" + ? Number.POSITIVE_INFINITY + : Math.max(current.limit, list.length - (count > 0 ? head : 0)), + } + }), + ) + }, }, bootstrap, } diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 2b54f21671b3..52189a4a113c 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -18,7 +18,7 @@ import path from "node:path" import { mkdir, writeFile } from "node:fs/promises" import { useRoute, useRouteData } from "../../context/route" import { useProject } from "../../context/project" -import { useSync } from "../../context/sync" +import { HIDDEN_PAGE_SIZE, useSync, type HiddenEdge, type HiddenMessages } from "../../context/sync" import { useEvent } from "../../context/event" import { SplitBorder } from "../../ui/border" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" @@ -131,6 +131,9 @@ const sessionBindingCommands = [ "session.first", "session.last", "session.messages_last_user", + "session.hidden.above", + "session.hidden.below", + "session.hidden.all", "session.message.next", "session.message.previous", "messages.copy", @@ -420,6 +423,32 @@ export function Session() { dialog.clear() } + const hidden = createMemo(() => { + const value = sync.session.hidden(route.sessionID) + return value && value.count > 0 ? value : undefined + }) + + // Loading into the gap above the viewport would slide the transcript under a + // fixed scrollTop, so the first visible block is put back on the row it was + // on. The scroll position is set, not adjusted, so another layout listener + // correcting the same change cannot make it move twice. + async function loadHidden(edge: HiddenEdge) { + dialog.clear() + if (!hidden()) return + const atBottom = scroll.scrollTop >= scroll.scrollHeight - scroll.viewport.height - 1 + const anchor = atBottom + ? undefined + : scroll.getChildren().find((child) => child.y + child.height > scroll.viewport.y) + const row = anchor ? anchor.y - scroll.viewport.y : 0 + const before = messages().length + await sync.session.load(route.sessionID, edge) + if (!anchor || messages().length === before) return + renderer.root.once("layout-changed", () => { + if (scroll.isDestroyed || anchor.isDestroyed) return + scroll.scrollTo(anchor.getLayoutNode().getComputedLayout().top - row) + }) + } + function toBottom() { setTimeout(() => { if (!scroll || scroll.isDestroyed) return @@ -859,6 +888,30 @@ export function Session() { } }, }, + { + title: `Load ${HIDDEN_PAGE_SIZE} hidden messages above the divider`, + value: "session.hidden.above", + category: "Session", + enabled: hidden() !== undefined, + run: () => void loadHidden("above"), + }, + { + title: `Load ${HIDDEN_PAGE_SIZE} hidden messages below the divider`, + value: "session.hidden.below", + category: "Session", + enabled: hidden() !== undefined, + run: () => void loadHidden("below"), + }, + { + title: "Load all hidden messages", + value: "session.hidden.all", + category: "Session", + enabled: hidden() !== undefined, + slash: { + name: "load-hidden", + }, + run: () => void loadHidden("all"), + }, { title: "Next message", value: "session.message.next", @@ -1198,98 +1251,105 @@ export function Session() { {(message, index) => ( - - - {(function () { - const redoShortcut = useCommandShortcut("session.redo") - const [hover, setHover] = createSignal(false) - const dialog = useDialog() - - const handleUnrevert = async () => { - const confirmed = await DialogConfirm.show( - dialog, - "Confirm Redo", - "Are you sure you want to restore the reverted messages?", - ) - if (confirmed) { - keymap.dispatchCommand("session.redo") + <> + + {(value) => ( + + + + {(function () { + const redoShortcut = useCommandShortcut("session.redo") + const [hover, setHover] = createSignal(false) + const dialog = useDialog() + + const handleUnrevert = async () => { + const confirmed = await DialogConfirm.show( + dialog, + "Confirm Redo", + "Are you sure you want to restore the reverted messages?", + ) + if (confirmed) { + keymap.dispatchCommand("session.redo") + } } - } - - return ( - setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={handleUnrevert} - marginTop={1} - flexShrink={0} - border={["left"]} - customBorderChars={SplitBorder.customBorderChars} - borderColor={theme.backgroundPanel} - > + + return ( setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={handleUnrevert} + marginTop={1} + flexShrink={0} + border={["left"]} + customBorderChars={SplitBorder.customBorderChars} + borderColor={theme.backgroundPanel} > - {revert()!.reverted.length} message reverted - - {redoShortcut()} or /redo to restore - - - - - {(file) => ( - - {file.filename} - 0}> - +{file.additions} - - 0}> - -{file.deletions} - - - )} - - - + + {revert()!.reverted.length} message reverted + + {redoShortcut()} or /redo to restore + + + + + {(file) => ( + + {file.filename} + 0}> + +{file.additions} + + 0}> + -{file.deletions} + + + )} + + + + - - ) - })()} - - = revertMessageIndex()} - > - <> - - - { - if (renderer.getSelection()?.getSelectedText()) return - dialog.replace(() => ( - prompt?.set(promptInfo)} - /> - )) - }} - message={message as UserMessage} - parts={sync.data.part[message.id] ?? []} - pending={pending()} - /> - - - - - + ) + })()} + + = revertMessageIndex()} + > + <> + + + { + if (renderer.getSelection()?.getSelectedText()) return + dialog.replace(() => ( + prompt?.set(promptInfo)} + /> + )) + }} + message={message as UserMessage} + parts={sync.data.part[message.id] ?? []} + pending={pending()} + /> + + + + + + )} @@ -1361,6 +1421,63 @@ export function Session() { ) } +// Marks where messages are not loaded and offers to load them, centered on a +// horizontal rule. "above" fills the gap from its older edge, "below" from its +// newer edge; a gap no bigger than one page only offers to load all of it. +function HiddenMessagesDivider(props: { hidden: HiddenMessages; first: boolean; onLoad: (edge: HiddenEdge) => void }) { + const ctx = use() + const { theme } = useTheme() + const renderer = useRenderer() + const label = createMemo(() => { + const count = props.hidden.count + return ` ${count.toLocaleString("en-US")} ${count === 1 ? "message" : "messages"} hidden ` + }) + const rule = createMemo(() => { + const space = Math.max(2, ctx.width - label().length) + return ["─".repeat(Math.floor(space / 2)), "─".repeat(space - Math.floor(space / 2))] + }) + const actions = createMemo((): { edge: HiddenEdge; label: string }[] => + props.hidden.count > HIDDEN_PAGE_SIZE + ? [ + { edge: "above", label: `load ${HIDDEN_PAGE_SIZE} above` }, + { edge: "below", label: `load ${HIDDEN_PAGE_SIZE} below` }, + { edge: "all", label: "load all" }, + ] + : [{ edge: "all", label: `load all ${props.hidden.count}` }], + ) + return ( + + + {rule()[0]} + {label()} + {rule()[1]} + + + loading…}> + + {(action) => { + const [hover, setHover] = createSignal(false) + return ( + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + props.onLoad(action.edge) + }} + > + {action.label} + + ) + }} + + + + + ) +} + function UserMessage(props: { message: UserMessage parts: Part[] diff --git a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx index f81c4401a61a..c625bd853dc4 100644 --- a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx @@ -10,6 +10,8 @@ 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" +import { TuiConfigProvider } from "../../../../src/config" export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk" export async function wait(fn: () => boolean, timeout = 2000) { @@ -22,7 +24,11 @@ export async function wait(fn: () => boolean, timeout = 2000) { type Ctx = { kv: ReturnType; project: ReturnType; sync: ReturnType } -export async function mount(override?: FetchHandler, state?: string) { +export async function mount( + override?: FetchHandler, + state?: string, + config?: Parameters[0], +) { const calls = createFetch(override) const events = createEventSource() let sync!: ReturnType @@ -52,9 +58,11 @@ export async function mount(override?: FetchHandler, state?: string) { {}}> - - - + + + + + diff --git a/packages/tui/test/cli/cmd/tui/sync-hidden-messages.test.tsx b/packages/tui/test/cli/cmd/tui/sync-hidden-messages.test.tsx new file mode 100644 index 000000000000..0b9991b3feea --- /dev/null +++ b/packages/tui/test/cli/cmd/tui/sync-hidden-messages.test.tsx @@ -0,0 +1,213 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import type { GlobalEvent, Message } from "@opencode-ai/sdk/v2" +import { tmpdir } from "../../../fixture/fixture" +import { json, mount, wait } from "./sync-fixture" + +const sessionID = "ses_hidden" +const session = { + id: sessionID, + title: "hidden", + time: { created: 0, updated: 0 }, + version: "1.15.13", + directory: "/tmp/opencode/packages/opencode", +} + +const id = (index: number) => `msg_${String(index).padStart(4, "0")}` + +function message(index: number): Message { + if (index % 2 === 0) + return { + id: id(index), + sessionID, + role: "user", + agent: "build", + model: { providerID: "test", modelID: "model" }, + time: { created: 1000 + index }, + } + return { + id: id(index), + sessionID, + role: "assistant", + agent: "build", + modelID: "model", + providerID: "test", + mode: "build", + parentID: id(index - 1), + path: { cwd: session.directory, root: session.directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1000 + index, completed: 1000 + index }, + } +} + +// Serves GET /session/:id/message the way the server pages it: newest `limit` +// by default, oldest with order=asc, strictly older/newer than a message ID. +function serve(count: number, requests: URL[] = []) { + const all = Array.from({ length: count }, (_, index) => ({ info: message(index), parts: [] })) + return (url: URL) => { + if (url.pathname === `/session/${sessionID}`) return json(session) + if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([]) + if (url.pathname !== `/session/${sessionID}/message`) return undefined + requests.push(url) + const limit = Number(url.searchParams.get("limit") ?? 0) + const before = url.searchParams.get("before") + const after = url.searchParams.get("after") + if (!limit) return json(all) + const items = after + ? all.filter((item) => item.info.id > after).slice(0, limit) + : url.searchParams.get("order") === "asc" + ? all.slice(0, limit) + : all.filter((item) => !before || item.info.id < before).slice(-limit) + return json(items, { headers: { "x-total-count": String(all.length) } }) + } +} + +function global(payload: GlobalEvent["payload"]): GlobalEvent { + return { directory: "/tmp/other", project: "proj_test", payload } +} + +const ids = (messages: Message[]) => messages.map((item) => Number(item.id.slice(4))) +const range = (from: number, to: number) => Array.from({ length: to - from }, (_, index) => from + index) + +test("a cropped session keeps its first prompt above the hidden gap", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const { app, sync } = await mount(serve(60), tmp.path, { transcript: { max_messages: 3 } }) + try { + await sync.session.sync(sessionID) + expect(ids(sync.data.message[sessionID])).toEqual([0, 57, 58, 59]) + expect(sync.session.hidden(sessionID)).toMatchObject({ head: 1, count: 56 }) + } finally { + app.renderer.destroy() + } +}) + +test("keep_first_prompt off crops from the top only", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const requests: URL[] = [] + const { app, sync } = await mount(serve(60, requests), tmp.path, { + transcript: { max_messages: 3, keep_first_prompt: false }, + }) + try { + await sync.session.sync(sessionID) + expect(ids(sync.data.message[sessionID])).toEqual([57, 58, 59]) + expect(sync.session.hidden(sessionID)).toMatchObject({ head: 0, count: 57 }) + expect(requests.some((url) => url.searchParams.get("order") === "asc")).toBe(false) + } finally { + app.renderer.destroy() + } +}) + +test("an uncropped session asks for nothing beyond its page", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const requests: URL[] = [] + const { app, sync } = await mount(serve(5, requests), tmp.path, { transcript: { max_messages: 10 } }) + try { + await sync.session.sync(sessionID) + expect(ids(sync.data.message[sessionID])).toEqual(range(0, 5)) + expect(sync.session.hidden(sessionID)).toMatchObject({ head: 0, count: 0 }) + expect(requests).toHaveLength(1) + } finally { + app.renderer.destroy() + } +}) + +test("loading below then above fills the gap from both edges", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const requests: URL[] = [] + const { app, sync } = await mount(serve(60, requests), tmp.path, { transcript: { max_messages: 3 } }) + try { + await sync.session.sync(sessionID) + + await sync.session.load(sessionID, "below") + expect(requests.at(-1)?.searchParams.get("before")).toBe(id(57)) + expect(ids(sync.data.message[sessionID])).toEqual([0, ...range(7, 60)]) + expect(sync.session.hidden(sessionID)).toMatchObject({ head: 1, count: 6 }) + + await sync.session.load(sessionID, "above") + expect(requests.at(-1)?.searchParams.get("after")).toBe(id(0)) + expect(ids(sync.data.message[sessionID])).toEqual(range(0, 60)) + expect(sync.session.hidden(sessionID)).toMatchObject({ head: 0, count: 0 }) + } finally { + app.renderer.destroy() + } +}) + +test("loading above without a pinned prompt starts from the session's oldest message", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const requests: URL[] = [] + const { app, sync } = await mount(serve(60, requests), tmp.path, { + transcript: { max_messages: 3, keep_first_prompt: false }, + }) + try { + await sync.session.sync(sessionID) + await sync.session.load(sessionID, "above") + expect(requests.at(-1)?.searchParams.get("order")).toBe("asc") + expect(ids(sync.data.message[sessionID])).toEqual([...range(0, 50), 57, 58, 59]) + expect(sync.session.hidden(sessionID)).toMatchObject({ head: 50, count: 7 }) + } finally { + app.renderer.destroy() + } +}) + +test("new messages hide the oldest recent message, never the pinned prompt", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const { app, emit, sync } = await mount(serve(60), tmp.path, { transcript: { max_messages: 3 } }) + try { + await sync.session.sync(sessionID) + emit(global({ id: "evt_new", type: "message.updated", properties: { sessionID, info: message(60) } })) + await wait(() => sync.data.message[sessionID].at(-1)?.id === id(60)) + expect(ids(sync.data.message[sessionID])).toEqual([0, 58, 59, 60]) + expect(sync.session.hidden(sessionID)).toMatchObject({ head: 1, count: 57 }) + + // A hidden message being updated must stay hidden rather than reappear in the gap. + emit(global({ id: "evt_old", type: "message.updated", properties: { sessionID, info: message(30) } })) + emit(global({ id: "evt_new2", type: "message.updated", properties: { sessionID, info: message(61) } })) + await wait(() => sync.data.message[sessionID].at(-1)?.id === id(61)) + expect(ids(sync.data.message[sessionID])).toEqual([0, 59, 60, 61]) + } finally { + app.renderer.destroy() + } +}) + +test("an uncropped session starts pinning its first prompt once it outgrows the limit", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const { app, emit, sync } = await mount(serve(3), tmp.path, { transcript: { max_messages: 3 } }) + try { + await sync.session.sync(sessionID) + emit(global({ id: "evt_3", type: "message.updated", properties: { sessionID, info: message(3) } })) + await wait(() => sync.data.message[sessionID].at(-1)?.id === id(3)) + expect(ids(sync.data.message[sessionID])).toEqual([0, 1, 2, 3]) + emit(global({ id: "evt_4", type: "message.updated", properties: { sessionID, info: message(4) } })) + await wait(() => sync.data.message[sessionID].at(-1)?.id === id(4)) + expect(ids(sync.data.message[sessionID])).toEqual([0, 2, 3, 4]) + expect(sync.session.hidden(sessionID)).toMatchObject({ head: 1, count: 1 }) + } finally { + app.renderer.destroy() + } +}) + +test("loading all keeps every later message loaded too", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const { app, emit, sync } = await mount(serve(60), tmp.path, { transcript: { max_messages: 3 } }) + try { + await sync.session.sync(sessionID) + await sync.session.load(sessionID, "all") + expect(ids(sync.data.message[sessionID])).toEqual(range(0, 60)) + expect(sync.session.hidden(sessionID)).toMatchObject({ head: 0, count: 0 }) + + emit(global({ id: "evt_new", type: "message.updated", properties: { sessionID, info: message(60) } })) + await wait(() => sync.data.message[sessionID].at(-1)?.id === id(60)) + expect(ids(sync.data.message[sessionID])).toEqual(range(0, 61)) + } finally { + app.renderer.destroy() + } +}) diff --git a/packages/tui/test/config.test.tsx b/packages/tui/test/config.test.tsx index 37fc0033e561..d02055d5746c 100644 --- a/packages/tui/test/config.test.tsx +++ b/packages/tui/test/config.test.tsx @@ -95,6 +95,16 @@ test("resolves overrides without mutating input", () => { expect(input.keybinds).toEqual({ session_list: "ctrl+l" }) }) +test("resolves transcript settings and hidden message keybinds", () => { + const config = resolve( + { transcript: { max_messages: 50, keep_first_prompt: false }, keybinds: { messages_hidden_all: "ctrl+o" } }, + { terminalSuspend: true }, + ) + + expect(config.transcript).toEqual({ max_messages: 50, keep_first_prompt: false }) + expect(config.keybinds.get("session.hidden.all")).toMatchObject([{ key: "ctrl+o" }]) +}) + test("resolves a session move keybind", () => { const config = resolve({ keybinds: { session_move: "ctrl+o" } }, { terminalSuspend: true }) diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 86f67dfd73ca..0578f62b8141 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -80,6 +80,9 @@ OpenCode has a list of keybinds that you can customize through `tui.json`. "messages_next": "none", "messages_previous": "none", "messages_last_user": "none", + "messages_hidden_above": "none", + "messages_hidden_below": "none", + "messages_hidden_all": "none", "messages_copy": "y", "messages_undo": "u", "messages_redo": "r", diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 0351c46cde10..6f859c48e815 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -411,6 +411,8 @@ This is separate from `opencode.json`, which configures server/runtime behavior. - `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout. - `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. +- `transcript.max_messages` - How many of a session's most recent messages the TUI keeps loaded (default: `100`). Older messages are hidden behind a divider showing how many there are, with actions to load 50 more from either edge of the gap or all of them. The same actions are in the command palette and `/load-hidden` loads all of them. +- `transcript.keep_first_prompt` - Keep the session's first prompt at the top, above the divider, when older messages are hidden (default: `true`). - `attention` - Configures TUI desktop notifications and sounds. Disabled by default. Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path. From e0991c916e1065953c9c010620a2e554d3cb7734 Mon Sep 17 00:00:00 2001 From: Nowaker Date: Fri, 16 Oct 2026 18:54:08 -0500 Subject: [PATCH 2/2] feat(tui): three-row hidden-messages bar with 100 pages and a costed load all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hidden-messages divider was a rule with the count and one row of "load 50 above", "load 50 below" and "load all". It is now three rows: "load 50 above load 100 above" above the rule, the rule itself carrying "N messages hidden: load all", and "load 50 below load 100 below" under it. A page is offered only while it is smaller than the gap, so a gap of 51-100 shows only the 50 pages and a gap of 50 or fewer shows only the rule. When the count and "load all" do not fit on the rule together, the rule drops to "N hidden" and "load all" moves to its own centered row under it. While a load is in flight its label reads "loading…" and every action ignores clicks. "load all" now says what it costs. Loading every hidden message of ten real sessions of 2,900 to 13,300 messages took a median 9.3 ms per message before the transcript showed and grew TUI memory by a median 0.52 MB per message (1.3 to 4.3 GB per session). The label is computed per gap from 10 ms and 0.5 MB per message: "(slow)" once the estimate passes 3 s, "(slow / memory heavy)" once it also passes 250 MB, about a third of an idle TUI. That is 300 and 500 hidden messages. The palette command title carries the same note. The divider moves to routes/session/hidden-divider.tsx with a pure layout and takes its width as a prop, so its frames are tested at 40, 80 and 160 columns. sync.session.load takes a page size; new commands session.hidden.above.100 and session.hidden.below.100 with keybinds messages_hidden_above_100 and messages_hidden_below_100, unbound by default. --- packages/tui/src/config/keybind.ts | 8 +- packages/tui/src/context/sync.tsx | 15 +- .../tui/src/routes/session/hidden-divider.tsx | 114 +++++++++++++ packages/tui/src/routes/session/index.tsx | 101 ++++-------- .../cli/cmd/tui/sync-hidden-messages.test.tsx | 21 +++ .../routes/session/hidden-divider.test.tsx | 153 ++++++++++++++++++ packages/web/src/content/docs/keybinds.mdx | 2 + packages/web/src/content/docs/tui.mdx | 2 +- 8 files changed, 338 insertions(+), 78 deletions(-) create mode 100644 packages/tui/src/routes/session/hidden-divider.tsx create mode 100644 packages/tui/test/routes/session/hidden-divider.test.tsx diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 148799f55a0b..b2c821f7e682 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -143,8 +143,10 @@ export const Definitions = { messages_next: keybind("none", "Navigate to next message"), messages_previous: keybind("none", "Navigate to previous message"), messages_last_user: keybind("none", "Navigate to last user message"), - messages_hidden_above: keybind("none", "Load hidden messages above the divider"), - messages_hidden_below: keybind("none", "Load hidden messages below the divider"), + messages_hidden_above: keybind("none", "Load 50 hidden messages above the divider"), + messages_hidden_above_100: keybind("none", "Load 100 hidden messages above the divider"), + messages_hidden_below: keybind("none", "Load 50 hidden messages below the divider"), + messages_hidden_below_100: keybind("none", "Load 100 hidden messages below the divider"), messages_hidden_all: keybind("none", "Load all hidden messages"), messages_copy: keybind("y", "Copy message"), messages_undo: keybind("u", "Undo message"), @@ -352,7 +354,9 @@ export const CommandMap = { messages_previous: "session.message.previous", messages_last_user: "session.messages_last_user", messages_hidden_above: "session.hidden.above", + messages_hidden_above_100: "session.hidden.above.100", messages_hidden_below: "session.hidden.below", + messages_hidden_below_100: "session.hidden.below.100", messages_hidden_all: "session.hidden.all", messages_copy: "messages.copy", messages_undo: "session.undo", diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 0f754bfddb04..ded06550c489 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -39,7 +39,7 @@ const emptyConsoleState: ConsoleState = { switchableOrgCount: 0, } -export const HIDDEN_PAGE_SIZE = 50 +export const HIDDEN_PAGE_SIZES = [50, 100] as const // Messages a session keeps unloaded. The loaded list is `head` messages (the // first prompt, plus any loaded right after it), a gap of `count` hidden @@ -48,9 +48,10 @@ export type HiddenMessages = { head: number count: number limit: number - loading?: HiddenEdge + loading?: HiddenLoad } export type HiddenEdge = "above" | "below" | "all" +export type HiddenLoad = { edge: HiddenEdge; size?: number } function search(items: T[], target: string, key: (item: T) => string) { let left = 0 @@ -732,21 +733,21 @@ export const { }, // Loads hidden messages into the gap: "above" the oldest ones, right after // the head; "below" the newest ones, right before the recent messages. - async load(sessionID: string, edge: HiddenEdge) { + async load(sessionID: string, edge: HiddenEdge, size: number = HIDDEN_PAGE_SIZES[0]) { const hidden = store.hidden[sessionID] if (!hidden || hidden.count === 0 || hidden.loading) return const messages = store.message[sessionID] ?? [] const before = messages[hidden.head]?.id const after = hidden.head > 0 ? messages[hidden.head - 1]?.id : undefined - setStore("hidden", sessionID, "loading", edge) + setStore("hidden", sessionID, "loading", edge === "all" ? { edge } : { edge, size }) const result = await ( edge === "all" ? sdk.client.session.messages({ sessionID }) : edge === "below" - ? sdk.client.session.messages({ sessionID, limit: HIDDEN_PAGE_SIZE, before }) + ? sdk.client.session.messages({ sessionID, limit: size, before }) : after - ? sdk.client.session.messages({ sessionID, limit: HIDDEN_PAGE_SIZE, after }) - : sdk.client.session.messages({ sessionID, limit: HIDDEN_PAGE_SIZE, order: "asc" }) + ? sdk.client.session.messages({ sessionID, limit: size, after }) + : sdk.client.session.messages({ sessionID, limit: size, order: "asc" }) ).finally(() => setStore("hidden", sessionID, "loading", undefined)) if (!result.data) return const total = Number(result.response?.headers.get("x-total-count") ?? Number.NaN) diff --git a/packages/tui/src/routes/session/hidden-divider.tsx b/packages/tui/src/routes/session/hidden-divider.tsx new file mode 100644 index 000000000000..4577b16bbad3 --- /dev/null +++ b/packages/tui/src/routes/session/hidden-divider.tsx @@ -0,0 +1,114 @@ +import { useRenderer } from "@opentui/solid" +import { createMemo, createSignal, For, Show } from "solid-js" +import { HIDDEN_PAGE_SIZES, type HiddenEdge, type HiddenMessages } from "../../context/sync" +import { useTheme } from "../../context/theme" + +// Loading every hidden message, measured on ten real sessions of 2,900 to +// 13,300 messages, cost a median of 9.3 ms before the transcript showed and +// 0.52 MB of TUI memory per message. The label warns once the estimate for +// this gap passes a 3 s freeze or 250 MB, about a third of an idle TUI. +const LOAD_ALL_MS_PER_MESSAGE = 10 +const LOAD_ALL_MB_PER_MESSAGE = 0.5 +const LOAD_ALL_SLOW_MS = 3000 +const LOAD_ALL_HEAVY_MB = 250 + +export type HiddenAction = { edge: HiddenEdge; size?: number; label: string } + +export function loadAllNote(count: number) { + const notes = [ + count * LOAD_ALL_MS_PER_MESSAGE >= LOAD_ALL_SLOW_MS ? "slow" : undefined, + count * LOAD_ALL_MB_PER_MESSAGE >= LOAD_ALL_HEAVY_MB ? "memory heavy" : undefined, + ].filter((note): note is string => note !== undefined) + return notes.length > 0 ? ` (${notes.join(" / ")})` : "" +} + +// Pages are offered only while they are smaller than the gap; a page that +// would load the whole gap is "load all". When the count and "load all" do +// not fit on the rule together, the rule keeps the count and "load all" gets +// a row of its own. +export function hiddenDivider(count: number, width: number) { + const pages = HIDDEN_PAGE_SIZES.filter((size) => size < count) + const all: HiddenAction = { edge: "all", label: `load all${loadAllNote(count)}` } + const shown = count.toLocaleString("en-US") + const title = [`${shown} ${count === 1 ? "message" : "messages"} hidden: `, `${shown} hidden: `].find( + (text) => text.length + all.label.length + 4 <= width, + ) + return { + above: pages.map((size): HiddenAction => ({ edge: "above", size, label: `load ${size} above` })), + below: pages.map((size): HiddenAction => ({ edge: "below", size, label: `load ${size} below` })), + all, + title: title ?? `${shown} hidden`, + inline: title !== undefined, + } +} + +export function HiddenMessagesDivider(props: { + hidden: HiddenMessages + width: number + first: boolean + onLoad: (edge: HiddenEdge, size?: number) => void +}) { + const { theme } = useTheme() + const renderer = useRenderer() + const layout = createMemo(() => hiddenDivider(props.hidden.count, props.width)) + const loading = (action: HiddenAction) => + props.hidden.loading?.edge === action.edge && props.hidden.loading.size === action.size + const text = (action: HiddenAction) => (loading(action) ? "loading…" : action.label) + const rule = createMemo(() => { + const inner = ` ${layout().title}${layout().inline ? text(layout().all) : ""} ` + const space = Math.max(2, props.width - inner.length) + return ["─".repeat(Math.floor(space / 2)), "─".repeat(space - Math.floor(space / 2))] + }) + + function Action(action: HiddenAction) { + const [hover, setHover] = createSignal(false) + return ( + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (props.hidden.loading) return + if (renderer.getSelection()?.getSelectedText()) return + props.onLoad(action.edge, action.size) + }} + > + {text(action)} + + ) + } + + function Row(row: { actions: HiddenAction[] }) { + return ( + 0}> + + {(action) => } + + + ) + } + + return ( + + + + + {rule()[0]} {layout().title} + + + + + + {" "} + {rule()[1]} + + + + + + + + ) +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 52189a4a113c..bb79825fa1da 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -18,7 +18,8 @@ import path from "node:path" import { mkdir, writeFile } from "node:fs/promises" import { useRoute, useRouteData } from "../../context/route" import { useProject } from "../../context/project" -import { HIDDEN_PAGE_SIZE, useSync, type HiddenEdge, type HiddenMessages } from "../../context/sync" +import { useSync, type HiddenEdge } from "../../context/sync" +import { HiddenMessagesDivider, loadAllNote } from "./hidden-divider" import { useEvent } from "../../context/event" import { SplitBorder } from "../../ui/border" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" @@ -132,7 +133,9 @@ const sessionBindingCommands = [ "session.last", "session.messages_last_user", "session.hidden.above", + "session.hidden.above.100", "session.hidden.below", + "session.hidden.below.100", "session.hidden.all", "session.message.next", "session.message.previous", @@ -432,7 +435,7 @@ export function Session() { // fixed scrollTop, so the first visible block is put back on the row it was // on. The scroll position is set, not adjusted, so another layout listener // correcting the same change cannot make it move twice. - async function loadHidden(edge: HiddenEdge) { + async function loadHidden(edge: HiddenEdge, size?: number) { dialog.clear() if (!hidden()) return const atBottom = scroll.scrollTop >= scroll.scrollHeight - scroll.viewport.height - 1 @@ -441,7 +444,7 @@ export function Session() { : scroll.getChildren().find((child) => child.y + child.height > scroll.viewport.y) const row = anchor ? anchor.y - scroll.viewport.y : 0 const before = messages().length - await sync.session.load(route.sessionID, edge) + await sync.session.load(route.sessionID, edge, size) if (!anchor || messages().length === before) return renderer.root.once("layout-changed", () => { if (scroll.isDestroyed || anchor.isDestroyed) return @@ -889,21 +892,35 @@ export function Session() { }, }, { - title: `Load ${HIDDEN_PAGE_SIZE} hidden messages above the divider`, + title: "Load 50 hidden messages above the divider", value: "session.hidden.above", category: "Session", - enabled: hidden() !== undefined, - run: () => void loadHidden("above"), + enabled: (hidden()?.count ?? 0) > 50, + run: () => void loadHidden("above", 50), + }, + { + title: "Load 100 hidden messages above the divider", + value: "session.hidden.above.100", + category: "Session", + enabled: (hidden()?.count ?? 0) > 100, + run: () => void loadHidden("above", 100), }, { - title: `Load ${HIDDEN_PAGE_SIZE} hidden messages below the divider`, + title: "Load 50 hidden messages below the divider", value: "session.hidden.below", category: "Session", - enabled: hidden() !== undefined, - run: () => void loadHidden("below"), + enabled: (hidden()?.count ?? 0) > 50, + run: () => void loadHidden("below", 50), }, { - title: "Load all hidden messages", + title: "Load 100 hidden messages below the divider", + value: "session.hidden.below.100", + category: "Session", + enabled: (hidden()?.count ?? 0) > 100, + run: () => void loadHidden("below", 100), + }, + { + title: `Load all hidden messages${loadAllNote(hidden()?.count ?? 0)}`, value: "session.hidden.all", category: "Session", enabled: hidden() !== undefined, @@ -1254,7 +1271,12 @@ export function Session() { <> {(value) => ( - @@ -1421,63 +1443,6 @@ export function Session() { ) } -// Marks where messages are not loaded and offers to load them, centered on a -// horizontal rule. "above" fills the gap from its older edge, "below" from its -// newer edge; a gap no bigger than one page only offers to load all of it. -function HiddenMessagesDivider(props: { hidden: HiddenMessages; first: boolean; onLoad: (edge: HiddenEdge) => void }) { - const ctx = use() - const { theme } = useTheme() - const renderer = useRenderer() - const label = createMemo(() => { - const count = props.hidden.count - return ` ${count.toLocaleString("en-US")} ${count === 1 ? "message" : "messages"} hidden ` - }) - const rule = createMemo(() => { - const space = Math.max(2, ctx.width - label().length) - return ["─".repeat(Math.floor(space / 2)), "─".repeat(space - Math.floor(space / 2))] - }) - const actions = createMemo((): { edge: HiddenEdge; label: string }[] => - props.hidden.count > HIDDEN_PAGE_SIZE - ? [ - { edge: "above", label: `load ${HIDDEN_PAGE_SIZE} above` }, - { edge: "below", label: `load ${HIDDEN_PAGE_SIZE} below` }, - { edge: "all", label: "load all" }, - ] - : [{ edge: "all", label: `load all ${props.hidden.count}` }], - ) - return ( - - - {rule()[0]} - {label()} - {rule()[1]} - - - loading…}> - - {(action) => { - const [hover, setHover] = createSignal(false) - return ( - setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={() => { - if (renderer.getSelection()?.getSelectedText()) return - props.onLoad(action.edge) - }} - > - {action.label} - - ) - }} - - - - - ) -} - function UserMessage(props: { message: UserMessage parts: Part[] diff --git a/packages/tui/test/cli/cmd/tui/sync-hidden-messages.test.tsx b/packages/tui/test/cli/cmd/tui/sync-hidden-messages.test.tsx index 0b9991b3feea..d47a5b02d663 100644 --- a/packages/tui/test/cli/cmd/tui/sync-hidden-messages.test.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-hidden-messages.test.tsx @@ -137,6 +137,27 @@ test("loading below then above fills the gap from both edges", async () => { } }) +test("a 100-message page loads 100 from the edge it names", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const requests: URL[] = [] + const { app, sync } = await mount(serve(300, requests), tmp.path, { transcript: { max_messages: 3 } }) + try { + await sync.session.sync(sessionID) + + await sync.session.load(sessionID, "below", 100) + expect(requests.at(-1)?.searchParams.get("limit")).toBe("100") + expect(ids(sync.data.message[sessionID])).toEqual([0, ...range(197, 300)]) + expect(sync.session.hidden(sessionID)).toMatchObject({ head: 1, count: 196 }) + + await sync.session.load(sessionID, "above", 100) + expect(ids(sync.data.message[sessionID])).toEqual([...range(0, 101), ...range(197, 300)]) + expect(sync.session.hidden(sessionID)).toMatchObject({ head: 101, count: 96 }) + } finally { + app.renderer.destroy() + } +}) + test("loading above without a pinned prompt starts from the session's oldest message", async () => { await using tmp = await tmpdir() await Bun.write(`${tmp.path}/kv.json`, "{}") diff --git a/packages/tui/test/routes/session/hidden-divider.test.tsx b/packages/tui/test/routes/session/hidden-divider.test.tsx new file mode 100644 index 000000000000..5dd61933ed05 --- /dev/null +++ b/packages/tui/test/routes/session/hidden-divider.test.tsx @@ -0,0 +1,153 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { testRender } from "@opentui/solid" +import { createSignal } from "solid-js" +import { TuiConfigProvider } from "../../../src/config" +import { KVProvider } from "../../../src/context/kv" +import type { HiddenEdge, HiddenMessages } from "../../../src/context/sync" +import { ThemeProvider } from "../../../src/context/theme" +import { HiddenMessagesDivider, loadAllNote } from "../../../src/routes/session/hidden-divider" +import { tmpdir } from "../../fixture/fixture" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" + +async function frame(root: string, count: number, width: number, loading?: HiddenMessages["loading"]) { + const state = path.join(root, `state-${count}-${width}-${loading?.edge ?? "idle"}`) + await mkdir(state, { recursive: true }) + await Bun.write(path.join(state, "kv.json"), "{}") + const loads: { edge: HiddenEdge; size?: number }[] = [] + const [hidden] = createSignal({ head: 1, count, limit: 100, loading }) + const app = await testRender( + () => ( + + + + + + + + + ), + { width, height: 8 }, + ) + const lines = () => + app + .captureCharFrame() + .split("\n") + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0) + const start = Date.now() + while (!app.captureCharFrame().includes("hidden")) { + if (Date.now() - start > 2000) throw new Error("divider never rendered") + await app.renderOnce() + await Bun.sleep(10) + } + return { app, lines: lines(), loads } +} + +test("160 columns: page rows around a rule carrying the count and load all", async () => { + await using tmp = await tmpdir() + const view = await frame(tmp.path, 3250, 160) + try { + expect(view.lines).toEqual([ + " ".repeat(65) + "load 50 above load 100 above", + "─".repeat(52) + " 3,250 messages hidden: load all (slow / memory heavy) " + "─".repeat(52), + " ".repeat(65) + "load 50 below load 100 below", + ]) + } finally { + view.app.renderer.destroy() + } +}) + +test("80 columns: the same three rows, a shorter rule", async () => { + await using tmp = await tmpdir() + const view = await frame(tmp.path, 3250, 80) + try { + expect(view.lines).toEqual([ + " ".repeat(25) + "load 50 above load 100 above", + "─".repeat(12) + " 3,250 messages hidden: load all (slow / memory heavy) " + "─".repeat(12), + " ".repeat(25) + "load 50 below load 100 below", + ]) + } finally { + view.app.renderer.destroy() + } +}) + +test("40 columns: load all moves under a rule that keeps only the count", async () => { + await using tmp = await tmpdir() + const view = await frame(tmp.path, 3250, 40) + try { + expect(view.lines).toEqual([ + " load 50 above load 100 above", + "─".repeat(13) + " 3,250 hidden " + "─".repeat(13), + " load all (slow / memory heavy)", + " load 50 below load 100 below", + ]) + } finally { + view.app.renderer.destroy() + } +}) + +test("a gap of 51 to 100 offers only the 50 page, a gap of 50 or fewer only load all", async () => { + await using tmp = await tmpdir() + const mid = await frame(tmp.path, 75, 80) + try { + expect(mid.lines).toEqual([ + " ".repeat(33) + "load 50 above", + "─".repeat(24) + " 75 messages hidden: load all " + "─".repeat(25), + " ".repeat(33) + "load 50 below", + ]) + } finally { + mid.app.renderer.destroy() + } + const small = await frame(tmp.path, 1, 80) + try { + expect(small.lines).toEqual(["─".repeat(25) + " 1 message hidden: load all " + "─".repeat(26)]) + } finally { + small.app.renderer.destroy() + } +}) + +test("load all warns from 300 hidden messages and adds memory heavy from 500", () => { + expect(loadAllNote(299)).toBe("") + expect(loadAllNote(300)).toBe(" (slow)") + expect(loadAllNote(499)).toBe(" (slow)") + expect(loadAllNote(500)).toBe(" (slow / memory heavy)") +}) + +test("clicks load the page they name; a load in flight shows loading… and ignores clicks", async () => { + await using tmp = await tmpdir() + const view = await frame(tmp.path, 3250, 80) + try { + const click = async (row: number, label: string) => { + const col = view.lines[row].indexOf(label) + 1 + await view.app.mockMouse.click(col, row) + } + await click(0, "load 100 above") + await click(2, "load 50 below") + await click(1, "load all") + expect(view.loads).toEqual([ + { edge: "above", size: 100 }, + { edge: "below", size: 50 }, + { edge: "all", size: undefined }, + ]) + } finally { + view.app.renderer.destroy() + } + const busy = await frame(tmp.path, 3250, 80, { edge: "below", size: 100 }) + try { + expect(busy.lines[2].trim()).toBe("load 50 below loading…") + await busy.app.mockMouse.click(busy.lines[0].indexOf("load 50 above") + 1, 0) + expect(busy.loads).toEqual([]) + } finally { + busy.app.renderer.destroy() + } +}) diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 0578f62b8141..f1076f6f9739 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -81,7 +81,9 @@ OpenCode has a list of keybinds that you can customize through `tui.json`. "messages_previous": "none", "messages_last_user": "none", "messages_hidden_above": "none", + "messages_hidden_above_100": "none", "messages_hidden_below": "none", + "messages_hidden_below_100": "none", "messages_hidden_all": "none", "messages_copy": "y", "messages_undo": "u", diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 6f859c48e815..c9c94bd1a511 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -411,7 +411,7 @@ This is separate from `opencode.json`, which configures server/runtime behavior. - `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout. - `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. -- `transcript.max_messages` - How many of a session's most recent messages the TUI keeps loaded (default: `100`). Older messages are hidden behind a divider showing how many there are, with actions to load 50 more from either edge of the gap or all of them. The same actions are in the command palette and `/load-hidden` loads all of them. +- `transcript.max_messages` - How many of a session's most recent messages the TUI keeps loaded (default: `100`). Older messages are hidden behind a divider: a rule with the hidden count and `load all`, `load 50 above` and `load 100 above` above it, and `load 50 below` and `load 100 below` under it. "Above" fills the gap from its older edge, "below" from its newer edge; a page as big as the gap is left out, since `load all` does the same. Loading every message costs about 10 ms and 0.5 MB of memory per message, so `load all` reads `load all (slow)` from 300 hidden messages and `load all (slow / memory heavy)` from 500. The same actions are in the command palette, `/load-hidden` loads all of them, and the `messages_hidden_*` keybinds are unbound by default. - `transcript.keep_first_prompt` - Keep the session's first prompt at the top, above the divider, when older messages are hidden (default: `true`). - `attention` - Configures TUI desktop notifications and sounds. Disabled by default.