Files
oh-my-pi/packages/coding-agent/src/modes/components/chat-transcript-builder.ts
T
roboomp a2c90f5e5f 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
2026-07-22 18:12:24 +00:00

494 lines
19 KiB
TypeScript

/**
* Builds transcript components from persisted session message entries — the
* file/remote-backed counterpart to {@link UiHelpers.addMessageToChat} (which is
* bound to the live InteractiveModeContext). Used by the fullscreen transcript
* viewer ({@link AgentTranscriptViewer}) to render a parked subagent / advisor /
* collab-guest transcript that has no live session.
*
* Unlike the old incremental hub sync, {@link ChatTranscriptBuilder.rebuild}
* always discards prior components and rebuilds the whole transcript from the
* supplied entries. Re-rendering a growing transcript is therefore O(n) in the
* entry count, but it cannot duplicate or misorder rows the way incremental
* component reuse could.
*/
import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core";
import type { Usage } from "@oh-my-pi/pi-ai";
import type { TUI } from "@oh-my-pi/pi-tui";
import type { AdvisorMessageDetails } from "../../advisor";
import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol";
import { settings } from "../../config/settings";
import type { MessageRenderer } from "../../extensibility/extensions/types";
import {
BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE,
type CustomMessage,
LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE,
SKILL_PROMPT_MESSAGE_TYPE,
type SkillPromptDetails,
} from "../../session/messages";
import type { SessionMessageEntry } from "../../session/session-entries";
import { theme } from "../theme/theme";
import {
assistantHasVisibleContent,
assistantUsageIsBilled,
buildAsyncResultBlock,
buildFileMentionBlock,
buildIrcMessageCard,
normalizeToolArgs,
resolveAssistantErrorPresentation,
splitAssistantMessageToolTimeline,
} from "../utils/transcript-render-helpers";
import { createAdvisorMessageCard } from "./advisor-message";
import { AssistantMessageComponent } from "./assistant-message";
import { createBackgroundTanDispatchBlock } from "./background-tan-message";
import { BashExecutionComponent } from "./bash-execution";
import { detectCacheInvalidation } from "./cache-invalidation-marker";
import { CollabPromptMessageComponent } from "./collab-prompt-message";
import {
BranchSummaryMessageComponent,
CompactionSummaryMessageComponent,
createHandoffSummaryMessageComponent,
} from "./compaction-summary-message";
import { CustomMessageComponent } from "./custom-message";
import { EvalExecutionComponent } from "./eval-execution";
import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent } from "./late-diagnostics-message";
import { ReadToolGroupComponent, readArgsCollapseIntoGroup } from "./read-tool-group";
import { SkillMessageComponent } from "./skill-message";
import { ToolExecutionComponent } from "./tool-execution";
import { TranscriptContainer } from "./transcript-container";
import { createUsageRowBlock } from "./usage-row";
import { CollapsedSyntheticMessageComponent, UserMessageComponent } from "./user-message";
export interface ChatTranscriptBuilderDeps {
ui: TUI;
getTool?: (name: string) => AgentTool | undefined;
getMessageRenderer?: (customType: string) => MessageRenderer | undefined;
cwd: string;
hideThinkingBlock?: () => boolean;
proseOnlyThinking?: () => boolean;
requestRender: () => void;
}
/** Extracts the plain-text content of a user message (string or text blocks). */
function userMessageText(message: Extract<AgentMessage, { role: "user" }>): string {
if (typeof message.content === "string") return message.content;
return message.content
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.map(block => block.text)
.join("");
}
export class ChatTranscriptBuilder {
readonly container = new TranscriptContainer();
#pendingTools = new Map<string, ToolExecutionComponent | ReadToolGroupComponent>();
#readArgs = new Map<string, Record<string, unknown>>();
#readGroup: ReadToolGroupComponent | null = null;
#pendingUsage: Usage | undefined;
#pendingUsageDuration: number | undefined;
#pendingUsageTtft: number | undefined;
#pendingUsageTimestamp: number | undefined;
#lastAssistantUsage: Usage | undefined;
#waitingPoll: ToolExecutionComponent | null = null;
#todoSnapshot: ToolExecutionComponent | null = null;
#expandables: Array<{ setExpanded(expanded: boolean): void }> = [];
#expanded = false;
constructor(private readonly deps: ChatTranscriptBuilderDeps) {}
/** Whether the transcript currently holds any rendered rows. */
get isEmpty(): boolean {
return this.container.children.length === 0;
}
/** Discard all components and rebuild the whole transcript from `entries`. */
rebuild(entries: SessionMessageEntry[]): void {
this.reset();
for (const entry of entries) this.#appendChatMessage(entry.message);
// Flush the trailing turn's usage row only once its tools are materialized
// (a read whose result has not arrived stays pending); otherwise the row
// would sit above its tools. The drain happens here at the end of the pass.
if (this.#readArgs.size === 0 && this.#pendingTools.size === 0) this.#flushPendingUsage();
}
/** Append newly persisted entries without rebuilding already rendered rows. */
append(entries: SessionMessageEntry[]): void {
for (const entry of entries) this.#appendChatMessage(entry.message);
if (this.#readArgs.size === 0 && this.#pendingTools.size === 0) this.#flushPendingUsage();
}
/** Toggle tool-output expansion across every expandable component. */
setExpanded(expanded: boolean): void {
this.#expanded = expanded;
for (const component of this.#expandables) component.setExpanded(expanded);
}
get expanded(): boolean {
return this.#expanded;
}
/** Tear down components (sealing pending spinners) and clear build state. */
reset(): void {
for (const pending of this.#pendingTools.values()) pending.seal();
this.#pendingTools.clear();
this.#readArgs.clear();
this.#readGroup = null;
this.#pendingUsage = undefined;
this.#pendingUsageDuration = undefined;
this.#pendingUsageTtft = undefined;
this.#pendingUsageTimestamp = undefined;
this.#lastAssistantUsage = undefined;
this.#waitingPoll = null;
this.#todoSnapshot = null;
this.#expandables = [];
this.container.dispose();
this.container.clear();
}
dispose(): void {
this.reset();
}
#trackExpandable(component: { setExpanded(expanded: boolean): void }): void {
component.setExpanded(this.#expanded);
this.#expandables.push(component);
}
/** A `hub` wait showing all-running is displaced by the next `hub` call. */
#resolveWaitingPoll(nextToolName?: string): void {
const previous = this.#waitingPoll;
if (!previous) return;
this.#waitingPoll = null;
if (nextToolName === "hub" && previous.isDisplaceableBlock() && this.container.isBlockUncommitted(previous)) {
this.container.removeChild(previous);
}
previous.seal();
}
#resolveTodoSnapshot(nextToolName?: string): void {
const previous = this.#todoSnapshot;
if (!previous) return;
if (!previous.isDisplaceableBlock()) {
this.#todoSnapshot = null;
return;
}
if (previous.canBeDisplacedBy(nextToolName)) {
this.#todoSnapshot = null;
if (this.container.isBlockUncommitted(previous)) {
this.container.removeChild(previous);
}
previous.seal();
return;
}
if (nextToolName !== undefined) return;
this.#todoSnapshot = null;
previous.seal();
}
#ensureReadGroup(): ReadToolGroupComponent {
if (!this.#readGroup) {
this.#readGroup = new ReadToolGroupComponent({
showContentPreview: settings.get("read.toolResultPreview"),
});
this.#trackExpandable(this.#readGroup);
this.container.addChild(this.#readGroup);
}
return this.#readGroup;
}
// The per-turn token-usage row must land below the turn's tool blocks, but
// normal `read` calls only materialize their group in #appendToolResult. Defer
// the row: stash it on the assistant message and flush once the turn's tools
// are placed, sealing the read run so the row sits under it.
#flushPendingUsage(): void {
if (!this.#pendingUsage) return;
this.#readGroup?.seal();
this.#readGroup = null;
this.container.addChild(
createUsageRowBlock(
this.#pendingUsage,
this.#pendingUsageDuration,
this.#pendingUsageTtft,
this.#pendingUsageTimestamp,
),
);
this.#pendingUsage = undefined;
this.#pendingUsageDuration = undefined;
this.#pendingUsageTtft = undefined;
this.#pendingUsageTimestamp = undefined;
}
#appendChatMessage(message: AgentMessage): void {
if (message.role !== "toolResult") this.#flushPendingUsage();
switch (message.role) {
case "assistant":
this.#appendAssistantMessage(message);
break;
case "toolResult":
this.#appendToolResult(message);
break;
case "user":
case "developer": {
// A user prompt closes the poll-displacement window, same as the live path.
if (message.role === "user") this.#resolveWaitingPoll();
if (message.role === "user") this.#resolveTodoSnapshot();
const textContent = message.role === "user" ? userMessageText(message) : "";
if (textContent) {
const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
// 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;
}
case "bashExecution": {
const component = new BashExecutionComponent(message.command, this.deps.ui, message.excludeFromContext);
if (message.output) component.appendOutput(message.output);
component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation });
this.container.addChild(component);
break;
}
case "pythonExecution": {
const component = new EvalExecutionComponent(message.code, this.deps.ui, message.excludeFromContext);
if (message.output) component.appendOutput(message.output);
component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation });
this.container.addChild(component);
break;
}
case "hookMessage":
case "custom":
this.#appendCustomMessage(message);
break;
case "compactionSummary": {
const component = new CompactionSummaryMessageComponent(message);
this.#trackExpandable(component);
this.container.addChild(component);
break;
}
case "branchSummary": {
const component = new BranchSummaryMessageComponent(message);
this.#trackExpandable(component);
this.container.addChild(component);
break;
}
case "fileMention": {
// Indent one column to match the transcript's other rows (the viewer renders
// body rows without an outer gutter; rows own their left pad).
const block = buildFileMentionBlock(message.files, 1);
if (block.children.length > 0) this.container.addChild(block);
break;
}
default:
message satisfies never;
}
}
#appendAssistantMessage(message: Extract<AgentMessage, { role: "assistant" }>): void {
const hideThinkingBlock = this.deps.hideThinkingBlock?.() ?? false;
const proseOnlyThinking = this.deps.proseOnlyThinking ? this.deps.proseOnlyThinking() : true;
const timeline = splitAssistantMessageToolTimeline(message);
const assistantComponent = new AssistantMessageComponent(
timeline.beforeTools,
hideThinkingBlock,
() => this.deps.requestRender(),
this.deps.getMessageRenderer ? undefined : [], // placeholder for thinkingRenderers
this.deps.ui.imageBudget,
proseOnlyThinking,
);
assistantComponent.setImagesVisible(settings.get("terminal.showImages"));
this.container.addChild(assistantComponent);
if (settings.get("display.cacheMissMarker")) {
const invalidation = detectCacheInvalidation(this.#lastAssistantUsage, message.usage);
if (invalidation) assistantComponent.setCacheInvalidation(invalidation);
}
if (message.usage.cacheRead + message.usage.cacheWrite + message.usage.input > 0) {
this.#lastAssistantUsage = message.usage;
}
const hasVisibleAssistantContent = assistantHasVisibleContent(message);
if (hasVisibleAssistantContent) {
// New visible turn content closes the current read run (mirrors rebuild).
this.#readGroup?.seal();
this.#readGroup = null;
}
const errorPresentation = resolveAssistantErrorPresentation(message);
const hasErrorStop = errorPresentation.kind === "full";
const errorMessage = hasErrorStop ? errorPresentation.text : null;
const appendAssistantSegment = (segment: Extract<AgentMessage, { role: "assistant" }> | undefined) => {
if (!segment || !assistantHasVisibleContent(segment)) return;
const component = new AssistantMessageComponent(
segment,
hideThinkingBlock,
() => this.deps.requestRender(),
this.deps.getMessageRenderer ? undefined : [],
undefined,
proseOnlyThinking,
);
component.setImagesVisible(settings.get("terminal.showImages"));
this.container.addChild(component);
};
for (const content of message.content) {
if (content.type !== "toolCall") continue;
this.#resolveWaitingPoll(content.name);
const afterToolSegment = timeline.afterToolCalls.get(content.id);
if (content.name === "read" && readArgsCollapseIntoGroup(content.arguments)) {
if (hasErrorStop && errorMessage) {
const group = this.#ensureReadGroup();
group.updateArgs(content.arguments, content.id);
group.updateResult(
{ content: [{ type: "text", text: errorMessage }], isError: true },
false,
content.id,
);
} else if (afterToolSegment) {
const group = this.#ensureReadGroup();
group.updateArgs(content.arguments, content.id);
this.#pendingTools.set(content.id, group);
} else {
const normalizedArgs = normalizeToolArgs(content.arguments);
this.#readArgs.set(content.id, normalizedArgs);
}
appendAssistantSegment(afterToolSegment);
continue;
}
this.#readGroup?.seal();
this.#readGroup = null;
const component = new ToolExecutionComponent(
content.name,
content.arguments,
{
// Stable ids and Kitty placeholder cells keep images anchored
// while the transcript viewport scrolls and reflows.
showImages: settings.get("terminal.showImages"),
editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
editAllowFuzzy: settings.get("edit.fuzzyMatch"),
liveRegion: this.container,
},
this.deps.getTool?.(content.name),
this.deps.ui,
this.deps.cwd,
content.id,
);
this.#trackExpandable(component);
this.container.addChild(component);
if (hasErrorStop && errorMessage) {
component.updateResult(
{ content: [{ type: "text", text: errorMessage }], isError: true },
false,
content.id,
);
} else {
this.#pendingTools.set(content.id, component);
}
appendAssistantSegment(afterToolSegment);
}
this.#pendingUsage =
settings.get("display.showTokenUsage") && assistantUsageIsBilled(message.usage) ? message.usage : undefined;
this.#pendingUsageDuration = message.duration;
this.#pendingUsageTtft = message.ttft;
this.#pendingUsageTimestamp = message.timestamp;
}
#appendToolResult(message: Extract<AgentMessage, { role: "toolResult" }>): void {
const pending = this.#pendingTools.get(message.toolCallId);
const isReadGroupResult = message.toolName === "read" && (!pending || pending instanceof ReadToolGroupComponent);
if (isReadGroupResult) {
let component = pending;
if (!component) {
const group = this.#ensureReadGroup();
const args = this.#readArgs.get(message.toolCallId);
if (args) group.updateArgs(args, message.toolCallId);
component = group;
}
component.updateResult(message, false, message.toolCallId);
this.#pendingTools.delete(message.toolCallId);
this.#readArgs.delete(message.toolCallId);
return;
}
if (!pending) return;
pending.updateResult(message, false, message.toolCallId);
this.#pendingTools.delete(message.toolCallId);
if (message.toolName === "hub" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) {
this.#waitingPoll = pending;
} else if (
message.toolName === "todo" &&
pending instanceof ToolExecutionComponent &&
pending.canBeDisplacedBy("todo")
) {
// A successful todo result supersedes the prior live snapshot. Failed
// follow-ups return false from canBeDisplacedBy("todo"), so the
// last-good panel stays on screen.
this.#resolveTodoSnapshot("todo");
this.#todoSnapshot = pending;
}
}
#appendCustomMessage(message: Extract<AgentMessage, { role: "custom" | "hookMessage" }>): void {
if (!message.display) return;
if (message.customType === "async-result") {
this.container.addChild(buildAsyncResultBlock(message));
return;
}
if (message.customType === LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE) {
const details = (message as CustomMessage<{ files?: LateDiagnosticsFile[] }>).details;
const component = new LateDiagnosticsMessageComponent(details?.files ?? []);
this.#trackExpandable(component);
this.container.addChild(component);
return;
}
if (message.customType === COLLAB_PROMPT_MESSAGE_TYPE) {
this.container.addChild(new CollabPromptMessageComponent(message as CustomMessage<CollabPromptDetails>));
return;
}
if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) {
const component = new SkillMessageComponent(message as CustomMessage<SkillPromptDetails>);
this.#trackExpandable(component);
this.container.addChild(component);
return;
}
if (
message.customType === "irc:incoming" ||
message.customType === "irc:autoreply" ||
message.customType === "irc:relay"
) {
this.container.addChild(buildIrcMessageCard(message, () => this.#expanded));
return;
}
if (message.customType === "advisor") {
const details = (message as CustomMessage<AdvisorMessageDetails>).details;
this.container.addChild(createAdvisorMessageCard(details, () => this.#expanded, theme));
return;
}
if (message.customType === BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE) {
this.container.addChild(createBackgroundTanDispatchBlock(message as CustomMessage<unknown>));
return;
}
const handoffComponent = createHandoffSummaryMessageComponent(message as CustomMessage<unknown>, this.#expanded);
if (handoffComponent) {
this.#trackExpandable(handoffComponent);
this.container.addChild(handoffComponent);
return;
}
const component = new CustomMessageComponent(
message as CustomMessage<unknown>,
this.deps.getMessageRenderer?.(message.customType),
);
this.#trackExpandable(component);
this.container.addChild(component);
}
}