From a2c90f5e5f347ab8f7969cb8563561e2f864e139 Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 22 Jul 2026 18:12:24 +0000 Subject: [PATCH] fix(tui): collapsed synthetic advisor inputs in transcript viewer Cold-opening a large read-only Advisor transcript froze the TUI for tens of seconds: AgentTranscriptViewer.render() called the full container.render() before ScrollView clipped, laying out every synthetic `Session update` input as full Markdown. A 6.5 MiB __advisor.jsonl blocked the first frame ~27s in a repro. Synthetic (agent-attributed) inputs now render as a CollapsedSyntheticMessageComponent: one dim summary row (heading, size, line count, ctrl+o hint) that builds the heavy UserMessageComponent Markdown only on expand. Blocks above the viewport never pay layout on cold open; the raw observability data in __advisor.jsonl is untouched. Real user prompts stay fully rendered. Fixes #6308 --- packages/coding-agent/CHANGELOG.md | 4 + .../components/chat-transcript-builder.ts | 15 ++- .../src/modes/components/user-message.ts | 101 +++++++++++++++++- .../test/agent-hub-advisor-scroll.test.ts | 72 ++++++++++++- 4 files changed, 187 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 945a4ed1c..5b7c4d16a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Agent Hub freezing for tens of seconds when opening a large read-only Advisor transcript. On cold open the viewer laid out every synthetic `Session update` input as full Markdown before `ScrollView` clipped the viewport (a 6.5 MiB `__advisor.jsonl` blocked `render()` for ~27s in a repro). Synthetic (agent-attributed) inputs now collapse to a compact summary row (` · · lines · ctrl+o`) and build their Markdown body only when expanded, so blocks above the viewport never pay layout cost on first frame ([#6308](https://github.com/can1357/oh-my-pi/issues/6308)). + ## [17.0.7] - 2026-07-21 ### Fixed diff --git a/packages/coding-agent/src/modes/components/chat-transcript-builder.ts b/packages/coding-agent/src/modes/components/chat-transcript-builder.ts index 6182563d7..02e58fe98 100644 --- a/packages/coding-agent/src/modes/components/chat-transcript-builder.ts +++ b/packages/coding-agent/src/modes/components/chat-transcript-builder.ts @@ -56,7 +56,7 @@ import { SkillMessageComponent } from "./skill-message"; import { ToolExecutionComponent } from "./tool-execution"; import { TranscriptContainer } from "./transcript-container"; import { createUsageRowBlock } from "./usage-row"; -import { UserMessageComponent } from "./user-message"; +import { CollapsedSyntheticMessageComponent, UserMessageComponent } from "./user-message"; export interface ChatTranscriptBuilderDeps { ui: TUI; @@ -233,7 +233,18 @@ export class ChatTranscriptBuilder { const textContent = message.role === "user" ? userMessageText(message) : ""; if (textContent) { const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false); - this.container.addChild(new UserMessageComponent(textContent, isSynthetic)); + // Synthetic (agent-attributed) inputs — chiefly the advisor's `Session + // update` replay dumps — can be hundreds of KiB of Markdown each. + // Rendering their full body on cold open blocked the TUI (issue #6308); + // collapse them behind a compact summary that builds Markdown only on + // ctrl+o expand. Real user prompts stay fully rendered. + if (isSynthetic) { + const collapsed = new CollapsedSyntheticMessageComponent(textContent); + this.#trackExpandable(collapsed); + this.container.addChild(collapsed); + } else { + this.container.addChild(new UserMessageComponent(textContent, false)); + } } break; } diff --git a/packages/coding-agent/src/modes/components/user-message.ts b/packages/coding-agent/src/modes/components/user-message.ts index 0b494727e..d58463fca 100644 --- a/packages/coding-agent/src/modes/components/user-message.ts +++ b/packages/coding-agent/src/modes/components/user-message.ts @@ -1,4 +1,5 @@ -import { Container, Markdown } from "@oh-my-pi/pi-tui"; +import { type Component, Container, Markdown } from "@oh-my-pi/pi-tui"; +import { formatBytes } from "@oh-my-pi/pi-utils"; import { getMarkdownTheme, theme } from "../../modes/theme/theme"; import { imageReferenceHyperlink, renderPlaceholders } from "../image-references"; import { highlightMagicKeywords } from "../magic-keywords"; @@ -66,3 +67,101 @@ export class UserMessageComponent extends Container { return wrapped; } } + +/** + * Collapsed placeholder for a synthetic (agent-attributed) user input in the + * file/remote-backed transcript viewer — chiefly the advisor's `Session update` + * replay dumps, which can each be hundreds of KiB of Markdown and, on cold open, + * blocked the TUI for tens of seconds while every historical body was laid out + * before the viewport clip (issue #6308). + * + * Collapsed by default: renders one dim summary row (label · size · line count · + * expand hint) and builds NO Markdown. The heavy {@link UserMessageComponent} is + * constructed lazily only when expanded via `ctrl+o`, so blocks above the + * viewport never pay layout cost until the reader asks to see them. The raw + * observability data stays intact in `__advisor.jsonl`. + */ +export class CollapsedSyntheticMessageComponent implements Component { + #expanded = false; + #cache?: { width: number; lines: readonly string[] }; + #body?: UserMessageComponent; + readonly #summary: string; + + constructor( + private readonly text: string, + private readonly imageLinks?: readonly (string | undefined)[], + ) { + this.#summary = summarizeSyntheticInput(text); + } + + /** ctrl+o toggle: reveal/hide the full Markdown body. */ + setExpanded(expanded: boolean): void { + if (this.#expanded === expanded) return; + this.#expanded = expanded; + this.#cache = undefined; + } + + invalidate(): void { + this.#cache = undefined; + this.#body?.invalidate?.(); + } + + dispose(): void { + this.#body?.dispose?.(); + } + + render(width: number): readonly string[] { + width = Math.max(1, width); + if (this.#cache?.width === width) return this.#cache.lines; + const lines = this.#expanded ? this.#renderExpanded(width) : [` ${this.#summaryRow(width)}`]; + this.#cache = { width, lines }; + return lines; + } + + #renderExpanded(width: number): readonly string[] { + if (!this.#body) this.#body = new UserMessageComponent(this.text, true, this.imageLinks); + return [` ${this.#summaryRow(width)}`, ...this.#body.render(width)]; + } + + #summaryRow(width: number): string { + const hint = `${theme.sep.dot.trim()} ctrl+o`; + return theme.fg("dim", truncateSummary(`${this.#summary} ${hint}`, Math.max(10, width - 1))); + } +} + +/** Truncate a plain summary label to `maxWidth` display columns, appending `…`. */ +function truncateSummary(text: string, maxWidth: number): string { + if (Bun.stringWidth(text, { countAnsiEscapeCodes: false }) <= maxWidth) return text; + let out = ""; + let w = 0; + for (const ch of text) { + const cw = Bun.stringWidth(ch, { countAnsiEscapeCodes: false }); + if (w + cw > maxWidth - 1) break; + out += ch; + w += cw; + } + return `${out}…`; +} + +/** + * One-line summary for a collapsed synthetic input: `