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..e75a1d009fa3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -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,35 @@ 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) { + return HttpServerResponse.jsonUnsafe(page.items, { + headers: { + "Access-Control-Expose-Headers": "X-Total-Count", + "X-Total-Count": total.toString(), + }, + }) + } 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 f06c20cc413e..fefc6ce65644 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 856ef392c748..e017ff52d903 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -401,6 +401,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.