diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index d8dbd689f1eb..aeb2dc6df0ec 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -91,6 +91,7 @@ import { SessionRetry } from "@/session/retry" 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) @@ -100,6 +101,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"]) function goUpsellKeys(action: SessionRetry.Retryable["action"]) { if (!action) return @@ -415,6 +417,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) { @@ -1008,6 +1053,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: "Go to child session", value: "session.child.first",