diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 4d983ee99b7..3e71b3d1ff8 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -81,6 +81,7 @@ import { DialogRetryAction } from "../../component/dialog-retry-action" import { getRevertDiffFiles } from "../../util/revert-diff" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap" import { PathFormatterProvider, usePathFormatter } from "../../context/path-format" +import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" addDefaultParsers(parsers.parsers) @@ -90,6 +91,7 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_ const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show" const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"]) +const SESSION_RENDER_STATE_CHANNELS = new Set(["local", "dev", "beta"]) type RetryAction = Extract["action"] @@ -422,6 +424,49 @@ export function Session() { }, 50) } + async function dumpRenderState() { + const timestamp = Date.now() + const maxTop = Math.max(0, scroll.scrollHeight - scroll.viewport.height) + const filename = `session-${route.sessionID.slice(0, 8)}-render-state-${timestamp}.json` + renderer.dumpBuffers(timestamp) + await Filesystem.writeJson(path.join(process.cwd(), filename), { + capturedAt: timestamp, + channel: InstallationChannel, + version: InstallationVersion, + sessionID: route.sessionID, + scroll: { + top: scroll.scrollTop, + height: scroll.scrollHeight, + viewportHeight: scroll.viewport.height, + maxTop, + atBottom: scroll.scrollTop >= maxTop, + }, + messages: messages().map((message) => ({ + id: message.id, + role: message.role, + parts: (sync.data.part[message.id] ?? []).map((part) => { + const renderable = scroll.findDescendantById(`text-${part.id}`) + return { + id: part.id, + type: part.type, + ...((part.type === "text" || part.type === "reasoning") && { textLength: part.text.length }), + ...(renderable && { + renderable: { + id: renderable.id, + x: renderable.x, + y: renderable.y, + width: renderable.width, + height: renderable.height, + visible: renderable.visible, + }, + }), + } + }), + })), + }) + toast.show({ message: `Session render state written to ${filename}`, variant: "success" }) + } + const local = useLocal() function enterChild(sessionID: string) { @@ -1017,6 +1062,23 @@ export function Session() { dialog.clear() }, }, + ...(SESSION_RENDER_STATE_CHANNELS.has(InstallationChannel) + ? [ + { + title: "Dump session render state", + value: "session.debug.dump_render_state", + category: "Debug", + run: async () => { + try { + await dumpRenderState() + } catch { + toast.show({ message: "Failed to dump session render state", variant: "error" }) + } + dialog.clear() + }, + }, + ] + : []), { title: "Background subagents", value: "session.background",