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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user