/** * 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 { LAUNCH_COMPLETION_MESSAGE_TYPE } from "../../session/launch-completion"; 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 { groupedReadUsageCallIds, ReadToolGroupComponent, readArgsCollapseIntoGroup } from "./read-tool-group"; import { SkillMessageComponent } from "./skill-message"; import { ToolActivityContainer } from "./tool-activity"; 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; /** Whether the active registry entry came from a built-in factory. */ isBuiltInTool?: (name: string) => boolean; 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): 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(); #readArgs = new Map>(); #readGroup: ReadToolGroupComponent | null = null; #pendingUsage: Usage | undefined; #pendingUsageDuration: number | undefined; #pendingUsageTtft: number | undefined; #pendingUsageTimestamp: number | undefined; #pendingReadUsageCallIds: string[] | 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) { this.container.setToolActivityVisible(!settings.get("display.hideToolActivity")); } /** 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.#pendingReadUsageCallIds = 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; } // Defer per-turn metrics until the turn's tool results have materialized. // Read-only invisible turns attach the metrics to their shared compact // group; every other turn keeps the standalone row below its tool blocks. #flushPendingUsage(): void { if (!this.#pendingUsage) return; const usageAttached = this.#pendingReadUsageCallIds !== undefined && (this.#readGroup?.attachUsage( this.#pendingReadUsageCallIds, this.#pendingUsage, this.#pendingUsageDuration, this.#pendingUsageTtft, this.#pendingUsageTimestamp, ) ?? false); if (!usageAttached) { 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; this.#pendingReadUsageCallIds = undefined; } #appendChatMessage(message: AgentMessage): void { if (message.role !== "toolResult") this.#flushPendingUsage(); if (message.role !== "assistant" && message.role !== "toolResult") { this.#readGroup?.seal(); this.#readGroup = null; } 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): 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")); assistantComponent.setToolResultImagesVisible(!settings.get("display.hideToolActivity")); this.#trackExpandable(assistantComponent); 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 | 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")); component.setToolResultImagesVisible(!settings.get("display.hideToolActivity")); this.#trackExpandable(component); 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, { useBuiltInRenderer: this.deps.isBuiltInTool?.(content.name) ?? true, // 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; this.#pendingReadUsageCallIds = this.#pendingUsage ? groupedReadUsageCallIds(message) : undefined; } #appendToolResult(message: Extract): 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): void { if (!message.display) return; if (message.customType === "async-result") { const component = buildAsyncResultBlock(message); this.container.addChild(component); 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)); return; } if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) { const component = new SkillMessageComponent(message as CustomMessage); 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).details; this.container.addChild(createAdvisorMessageCard(details, () => this.#expanded, theme)); return; } if (message.customType === LAUNCH_COMPLETION_MESSAGE_TYPE) { const messageComponent = new CustomMessageComponent( message as CustomMessage, this.deps.getMessageRenderer?.(message.customType), ); this.#trackExpandable(messageComponent); const component = new ToolActivityContainer(messageComponent); this.container.addChild(component); return; } if (message.customType === BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE) { this.container.addChild(createBackgroundTanDispatchBlock(message as CustomMessage)); return; } const handoffComponent = createHandoffSummaryMessageComponent(message as CustomMessage, this.#expanded); if (handoffComponent) { this.#trackExpandable(handoffComponent); this.container.addChild(handoffComponent); return; } const component = new CustomMessageComponent( message as CustomMessage, this.deps.getMessageRenderer?.(message.customType), ); this.#trackExpandable(component); this.container.addChild(component); } }