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
This commit is contained in:
roboomp
2026-07-22 18:12:24 +00:00
parent 7b141199d5
commit a2c90f5e5f
4 changed files with 187 additions and 5 deletions
+4
View File
@@ -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 (`<heading> · <size> · <n> 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
@@ -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;
}
@@ -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: `<label> · <size> · <n>
* lines`. The label is the first Markdown heading's text (e.g. `Session
* update`), falling back to `Synthetic input` when the body opens with none.
*/
function summarizeSyntheticInput(text: string): string {
const size = formatBytes(Buffer.byteLength(text, "utf-8"));
const lineCount = text === "" ? 0 : text.split("\n").length;
const dot = theme.sep.dot.trim();
return `${syntheticInputLabel(text)} ${dot} ${size} ${dot} ${lineCount} line${lineCount === 1 ? "" : "s"}`;
}
/** First Markdown heading text in `text`, else `Synthetic input`. */
function syntheticInputLabel(text: string): string {
for (const raw of text.split("\n")) {
const line = raw.trim();
if (!line) continue;
const heading = /^#{1,6}\s+(.*)$/.exec(line);
return heading ? heading[1]!.trim() || "Synthetic input" : "Synthetic input";
}
return "Synthetic input";
}
@@ -47,7 +47,7 @@ function buildJsonl(): string {
id: "u0",
parentId: null,
timestamp: TS,
message: { role: "user", synthetic: true, attribution: "agent", content: "PROMPTMARKER", timestamp: 0 },
message: { role: "user", synthetic: true, attribution: "agent", content: "### PROMPTMARKER", timestamp: 0 },
}),
);
for (let i = 0; i < 40; i++) {
@@ -133,7 +133,7 @@ function messageLine(id: string, content: string): string {
id,
parentId: null,
timestamp: TS,
message: { role: "user", synthetic: true, attribution: "agent", content, timestamp: 0 },
message: { role: "user", synthetic: true, attribution: "agent", content: `### ${content}`, timestamp: 0 },
});
}
@@ -212,6 +212,74 @@ describe("AgentTranscriptViewer", () => {
});
});
it("collapses synthetic advisor inputs on cold open and expands their body on ctrl+o", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "adv-view-collapse-"));
const file = path.join(dir, "__advisor.jsonl");
// A synthetic `Session update` whose body carries a distinctive marker
// far larger than the viewport. Cold open must NOT lay it out; the reader
// only sees a compact summary until ctrl+o.
const bodyLines = ["### Session update", ""];
for (let i = 0; i < 500; i++) bodyLines.push(`- SYNTHBODYMARKER line ${i}`);
const body = bodyLines.join("\n");
const jsonl = [
JSON.stringify({ type: "session", version: CURRENT_SESSION_VERSION, id: "adv", timestamp: TS, cwd: "/tmp" }),
JSON.stringify({
type: "message",
id: "u0",
parentId: null,
timestamp: TS,
message: { role: "user", synthetic: true, attribution: "agent", content: body, timestamp: 0 },
}),
JSON.stringify({
type: "message",
id: "a0",
parentId: null,
timestamp: TS,
message: {
role: "assistant",
content: [{ type: "text", text: "Advice." }],
api: "anthropic-messages",
provider: "anthropic",
model: "gpt-5.5",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 0,
},
}),
].join("\n");
fs.writeFileSync(file, `${jsonl}\n`);
const viewer = makeViewer(file);
try {
viewer.render(80);
viewer.handleInput("g"); // scroll to top
const collapsed = viewer.render(80).map(l => Bun.stripANSI(l));
const collapsedBody = collapsed.join("\n");
// The synthetic body is not laid out; only the summary row shows.
expect(collapsedBody).not.toContain("SYNTHBODYMARKER");
expect(collapsed.some(l => /Session update .* line/.test(l))).toBe(true);
// ctrl+o reveals the full body; scroll back to the top to see it.
viewer.handleInput("\x0f");
viewer.render(80);
viewer.handleInput("g");
const expandedBody = viewer
.render(80)
.map(l => Bun.stripANSI(l))
.join("\n");
expect(expandedBody).toContain("SYNTHBODYMARKER");
} finally {
viewer.dispose();
removeSyncWithRetries(dir);
}
});
it("scrolls the visible window with j/k and g/G", () => {
withViewer(viewer => {
const atBottom = viewer