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..b2c821f7e682 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -143,6 +143,11 @@ 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 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"), messages_redo: keybind("r", "Redo message"), @@ -348,6 +353,11 @@ 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_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", messages_redo: "session.redo", diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 71e050d11e68..ded06550c489 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -32,12 +32,27 @@ 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_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 +// messages, then the newest messages, at most `limit` of them. +export type HiddenMessages = { + head: number + count: number + limit: number + 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 let right = items.length - 1 @@ -102,6 +117,9 @@ export const { part: { [messageID: string]: Part[] } + hidden: { + [sessionID: string]: HiddenMessages + } lsp: LspStatus[] mcp: { [key: string]: McpStatus @@ -136,6 +154,7 @@ export const { todo: {}, message: {}, part: {}, + hidden: {}, lsp: [], mcp: {}, mcp_resource: {}, @@ -146,6 +165,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 +354,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 +386,7 @@ export const { delete draft[oldest.id] }), ) + setStore("hidden", sessionID, { head, count: hidden.count + 1, limit: hidden.limit }) }) } break @@ -362,14 +395,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 +640,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 +667,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 +678,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 +728,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, 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 === "all" ? { edge } : { edge, size }) + const result = await ( + edge === "all" + ? sdk.client.session.messages({ sessionID }) + : edge === "below" + ? sdk.client.session.messages({ sessionID, limit: size, before }) + : after + ? 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) + 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/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 2b54f21671b3..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 { useSync } 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" @@ -131,6 +132,11 @@ const sessionBindingCommands = [ "session.first", "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", "messages.copy", @@ -420,6 +426,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, size?: number) { + 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, size) + 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 +891,44 @@ export function Session() { } }, }, + { + title: "Load 50 hidden messages above the divider", + value: "session.hidden.above", + category: "Session", + 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 50 hidden messages below the divider", + value: "session.hidden.below", + category: "Session", + enabled: (hidden()?.count ?? 0) > 50, + run: () => void loadHidden("below", 50), + }, + { + 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, + slash: { + name: "load-hidden", + }, + run: () => void loadHidden("all"), + }, { title: "Next message", value: "session.message.next", @@ -1198,98 +1268,110 @@ 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()} + /> + + + + + + )} 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..d47a5b02d663 --- /dev/null +++ b/packages/tui/test/cli/cmd/tui/sync-hidden-messages.test.tsx @@ -0,0 +1,234 @@ +/** @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("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`, "{}") + 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/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 86f67dfd73ca..f1076f6f9739 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -80,6 +80,11 @@ 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_above_100": "none", + "messages_hidden_below": "none", + "messages_hidden_below_100": "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..c9c94bd1a511 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: 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. Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path.