import * as fs from "node:fs"; import * as path from "node:path"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, ImageContent, Usage } from "@oh-my-pi/pi-ai"; import { getStreamingPartialJson } from "@oh-my-pi/pi-ai/utils/block-symbols"; import { type Component, Text } from "@oh-my-pi/pi-tui"; import { RecapNotice } from "@oh-my-pi/pi-tui/chat/recap-notice"; import { StatusNotice } from "@oh-my-pi/pi-tui/chrome/status-notice"; import { QueuedMessagesBand } from "@oh-my-pi/pi-tui/prompt/queued-messages"; import { previewLine, TRUNCATE_LENGTHS } from "@oh-my-pi/pi-tui/render/render-utils"; import { logger } from "@oh-my-pi/pi-utils"; import type { AdvisorMessageDetails } from "../../advisor"; import { InternalUrlRouter } from "../../internal-urls"; import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol"; import { settings } from "../../config/settings"; import { continuesConversation } from "./conversation-position"; import { createAdvisorMessageCard } from "@oh-my-pi/pi-tui/chat/advisor-message"; import { AssistantMessageComponent } from "@oh-my-pi/pi-tui/chat/assistant-message"; import { readMediaPath } from "@oh-my-pi/pi-tui/native/blobs"; import { createBackgroundTanDispatchBlock } from "@oh-my-pi/pi-tui/chat/background-tan-message"; import { BashExecutionComponent } from "@oh-my-pi/pi-tui/chat/bash-execution"; import { detectCacheInvalidation } from "@oh-my-pi/pi-tui/chat/cache-invalidation-marker"; import { ServedModelTracker } from "@oh-my-pi/pi-tui/chat/served-model-marker"; import { CollabPromptMessageComponent } from "@oh-my-pi/pi-tui/chat/collab-prompt-message"; import { BranchSummaryMessageComponent, CompactionSummaryMessageComponent, createHandoffSummaryMessageComponent, } from "@oh-my-pi/pi-tui/chat/compaction-summary-message"; import { CustomMessageComponent } from "@oh-my-pi/pi-tui/chat/custom-message"; import { DynamicBorder } from "@oh-my-pi/pi-tui/chrome/dynamic-border"; import { EvalExecutionComponent } from "@oh-my-pi/pi-tui/chat/eval-execution"; import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent, routeLateDiagnostics, } from "@oh-my-pi/pi-tui/chat/late-diagnostics-message"; import { groupedReadUsageCallIds, ReadToolGroupComponent, readArgsCollapseIntoGroup, } from "@oh-my-pi/pi-tui/chat/read-tool-group"; import { ReplyWriteComponent, replyDisplayMessage, toolResultReplies } from "@oh-my-pi/pi-tui/chat/reply-write"; import { SkillMessageComponent } from "@oh-my-pi/pi-tui/chat/skill-message"; import { StrippedToolCallsPlaceholder } from "@oh-my-pi/pi-tui/chat/stripped-tool-calls-placeholder"; import { imageContent, textContent } from "@oh-my-pi/pi-tui/chat/transcript-entry"; import { messageBlock, PresentedBlock } from "@oh-my-pi/pi-tui/chat/transcript-presentation"; import { ToolActivityContainer } from "@oh-my-pi/pi-tui/chrome/tool-activity"; import { isSettledJobFrame, parksAsBackground, settleWithJobFrame, ToolExecutionComponent, type ToolExecutionHandle, toolRenderName, } from "@oh-my-pi/pi-tui/chat/tool-execution"; import { TranscriptContainer } from "@oh-my-pi/pi-tui/chrome/transcript-container"; import { noticeRow, TranscriptNotice } from "@oh-my-pi/pi-tui/chrome/transcript-notice"; import { span } from "@oh-my-pi/pi-tui/native/describe"; import { createUsageRowBlock, turnElapsedMs } from "@oh-my-pi/pi-tui/overlays/usage-row"; import { UserMessageComponent } from "@oh-my-pi/pi-tui/chat/user-message"; import { decodeStreamedToolArgs, streamingStringKeysForTool } from "../../modes/controllers/tool-args-reveal"; import { createToolCallBlock } from "../../modes/controllers/tool-call-block"; import { materializeImageReferenceLinks, materializeImageReferenceLinksSync, } from "@oh-my-pi/pi-tui/prompt/image-references"; import { normalizeBlobExtension } from "@oh-my-pi/pi-tui/prompt/image-format"; import { imageAttachmentSource } from "@oh-my-pi/pi-tui/prompt/image-source"; import { theme } from "@oh-my-pi/pi-tui/theme"; import type { CompactionQueuedMessage, InteractiveModeContext, RenderInitialMessagesOptions, RenderSessionContextOptions, ShowStatusOptions, } from "../../modes/types"; import { extractVisibleAssistantText } from "../rpc/rpc-live"; import { LAUNCH_COMPLETION_MESSAGE_TYPE } from "../../session/launch-completion"; import { BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE, type CustomMessage, isUserTurnInitiator, LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE, SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails, } from "../../session/messages"; import type { SessionContext, StrippedToolCallsMarker } from "../../session/session-context"; import { listSessionRecaps } from "../../session/session-index"; import { executeBuiltinSlashCommand, lookupBuiltinSlashCommand } from "../../slash-commands/builtin-registry"; import { parseSlashCommand } from "../../slash-commands/helpers/parse"; import { buildSkillCommandPrompt, invokeSkillCommandFromText, isKnownSkillCommand } from "../skill-command"; import { createAssistantMessageComponent, getAssistantMessageLinkTargets, refreshAssistantMessageLinkTargets, } from "@oh-my-pi/pi-tui/prompt/interactive-context-helpers"; import { assistantHasVisibleContent, assistantUsageIsBilled, buildAsyncResultComponent, buildFileMentionBlock, buildIrcMessageCard, buildParentIrcMessageCard, buildLaunchCompletionBlock, normalizeToolArgs, resolveAssistantErrorPresentation, splitAssistantMessageToolTimeline, } from "@oh-my-pi/pi-tui/chat/transcript-render-helpers"; import { cfgComposerRecallClearedDrafts, cfgDisplayCacheMissMarker, cfgDisplayCollapseCompacted, cfgDisplayShowTokenUsage, cfgDisplayShowTurnTime, cfgTerminalShowImages, } from "../settings"; import { cfgReadToolResultPreview } from "../../tools/settings"; const TRANSCRIPT_RENDER_CHUNK_MESSAGES = 32; const TRANSCRIPT_RENDER_CHUNK_MS = 8; /** * Upper bound on full-transcript replay restarts inside * {@link UiHelpers.renderInitialMessages}. Each restart discards the staged * tree and replays every message from scratch, so on a large resumed session * (issue #7811: ~6k entries) a single pass takes longer than the interval * between entries persisted by background sources — the restart condition * becomes permanently true and an unbounded loop livelocks at 100% CPU. * Entries that land after the final accepted pass are * durable in the session file and reach the display on the next rebuild. */ const TRANSCRIPT_REPLAY_MAX_ATTEMPTS = 5; function waitForImmediate(): Promise { const { promise, resolve } = Promise.withResolvers(); setImmediate(resolve); return promise; } type QueuedMessages = { steering: string[]; followUp: string[]; liveSteered: number; }; type AddMessageOptions = { imageLinks?: readonly (string | undefined)[]; reuseSettledComponent?: boolean; }; type ImageChipSessionManager = Pick< InteractiveModeContext["sessionManager"], "putBlob" | "putBlobSync" | "getArtifactsDir" | "getSessionId" >; /** Where an image chip's bytes live: a file the chip opens directly, or the file behind an internal URL. */ type ImageChipSource = { kind: "file"; path: string } | { kind: "internal"; path: string }; /** * Internal URLs (`local://`) stay on the image for the model but resolve against the * session root, which `/move` relocates, so a chip cannot point at them. Their backing * file holds the original pasted bytes (the payload may be auto-resized), so the chip * opens a stable blob copy of that file. Undefined when no file backs the image. */ function imageChipSource(image: ImageContent, sessionManager: ImageChipSessionManager): ImageChipSource | undefined { const sourcePath = imageAttachmentSource(image)?.path; if (!sourcePath) return undefined; const router = InternalUrlRouter.instance(); if (!router.canHandle(sourcePath)) return { kind: "file", path: sourcePath }; try { const located = router.locateSync(sourcePath, { localProtocolOptions: { getArtifactsDir: () => sessionManager.getArtifactsDir(), getSessionId: () => sessionManager.getSessionId(), }, }); return located === undefined ? undefined : { kind: "internal", path: located }; } catch { return undefined; } } /** Chip targets for attached images: each file on disk, else a blob copy of the original bytes (or the payload). */ export async function materializeImageChipLinks( images: readonly ImageContent[], sessionManager: ImageChipSessionManager, ): Promise<(string | undefined)[]> { const putBlob = sessionManager.putBlob.bind(sessionManager); return Promise.all( images.map(async image => { const source = imageChipSource(image, sessionManager); if (source?.kind === "file") return source.path; if (source) { try { const bytes = await fs.promises.readFile(source.path); return (await putBlob(bytes, { extension: normalizeBlobExtension(path.extname(source.path)) })) .displayPath; } catch (error) { logger.warn("Failed to copy original image for its chip", { path: source.path, error: error instanceof Error ? error.message : String(error), }); } } return (await materializeImageReferenceLinks([image], putBlob))?.[0]; }), ); } /** Synchronous {@link materializeImageChipLinks} for transcript rebuilds. */ function imageLinksForMessage( images: readonly ImageContent[], sessionManager: ImageChipSessionManager, ): (string | undefined)[] | undefined { if (images.length === 0) return undefined; const putBlobSync = sessionManager.putBlobSync.bind(sessionManager); return images.map(image => { const source = imageChipSource(image, sessionManager); if (source?.kind === "file") return source.path; if (source) { try { const bytes = fs.readFileSync(source.path); return putBlobSync(bytes, { extension: normalizeBlobExtension(path.extname(source.path)) }).displayPath; } catch (error) { logger.warn("Failed to copy original image for its chip", { path: source.path, error: error instanceof Error ? error.message : String(error), }); } } return materializeImageReferenceLinksSync([image], putBlobSync)?.[0]; }); } export class UiHelpers { /** * Tail of the transcript-replay chain. Replays swap `ctx.chatContainer` and the * live component maps for their whole duration, so two overlapping replays would * capture each other's staged state as "visible" and commit interleaved trees. */ #replayTail: Promise = Promise.resolve(); /** Bumped by every replay request; a queued or in-flight replay is stale once it moves on. */ #replayGeneration = 0; /** Requested replays that have not settled yet. */ #replaysInFlight = 0; /** `initialChatRendered` to restore when the latest replay ends without committing. */ #chatRenderedBeforeReplay = false; /** Bumped by {@link restartTranscriptReplay}; a replay pass that saw it move rebuilds again. */ #textRendererSetVersion = 0; /** The text renderer set changed while a replay was in flight; the commit retires stale scrollback. */ #textRendererSetChangedDuringReplay = false; constructor(private ctx: InteractiveModeContext) {} /** * Show a status message in the chat. * * If multiple status messages are emitted back-to-back (without anything else being added to the chat), * we update the previous status line instead of appending new ones to avoid log spam. A repeat of the * exact same text replaces the line with a fresh notice: an in-place update with identical props sends * nothing to a native terminal, so its expired toast would never show again. */ showStatus(message: string, options?: ShowStatusOptions): void { const children = this.ctx.chatContainer.children; const last = children.length > 0 ? children[children.length - 1] : undefined; const useDim = options?.dim ?? true; // Resolve the dim color lazily so a later theme change re-shapes the line // instead of leaving the palette that was active when it was presented. const styleFn = useDim ? (t: string) => theme.fg("dim", t) : undefined; const notice = { styleFn, toast: options?.toast, action: options?.action }; if (last && last === this.ctx.lastStatus) { if (this.ctx.lastStatus.message !== message) { this.ctx.lastStatus.setMessage(message, notice); this.ctx.ui.requestRender(); return; } this.ctx.chatContainer.removeChild(last); } const status = new StatusNotice(message, notice); this.ctx.present([status]); this.ctx.lastStatus = status; } addMessageToChat(message: AgentMessage, options?: AddMessageOptions): Component[] { switch (message.role) { case "bashExecution": { const component = new BashExecutionComponent(message.command, this.ctx.ui, message.excludeFromContext); if (message.output) { component.appendOutput(message.output); } component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation, artifactError: message.meta?.artifactError, minimized: message.meta?.minimized, images: message.images, showImages: cfgTerminalShowImages.get(settings), }); const block = new PresentedBlock(component, messageBlock(message)); block.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(block); break; } case "pythonExecution": { const component = new EvalExecutionComponent(message.code, this.ctx.ui, message.excludeFromContext); if (message.output) { component.appendOutput(message.output); } component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation, artifactError: message.meta?.artifactError, }); const block = new PresentedBlock(component, messageBlock(message)); block.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(block); break; } case "hookMessage": case "custom": { if (message.display) { if (message.customType === "async-result") { const renderer = this.ctx.viewSession.extensionRunner?.getMessageRenderer(message.customType); const component = new PresentedBlock( buildAsyncResultComponent(message, renderer), messageBlock(message), ); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(component); break; } if (message.customType === LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE) { const details = ( message as CustomMessage<{ files?: LateDiagnosticsFile[]; }> ).details; // Native: into the edit/write frames they belong to; the rest stand alone. const files = routeLateDiagnostics(this.ctx.chatContainer.children, details?.files ?? []); if (files.length === 0) break; const component = new PresentedBlock( new LateDiagnosticsMessageComponent(files), messageBlock(message), ); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(component); break; } if (message.customType === LAUNCH_COMPLETION_MESSAGE_TYPE) { const component = new PresentedBlock(buildLaunchCompletionBlock(message), messageBlock(message)); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(component); break; } if (message.customType === COLLAB_PROMPT_MESSAGE_TYPE) { const component = new CollabPromptMessageComponent(message as CustomMessage); this.ctx.chatContainer.addChild(component); break; } if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) { const component = new PresentedBlock( new SkillMessageComponent(message as CustomMessage), messageBlock(message), ); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(component); break; } if ( message.customType === "irc:incoming" || message.customType === "irc:autoreply" || message.customType === "irc:relay" || message.customType === "irc:workpool" ) { const card = buildIrcMessageCard(message, () => this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(card); return [card]; } if (message.customType === "advisor") { const details = (message as CustomMessage).details; const card = new PresentedBlock( createAdvisorMessageCard(details, () => this.ctx.toolOutputExpanded, theme), messageBlock(message), ); card.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(card); break; } if (message.customType === BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE) { this.ctx.chatContainer.addChild(createBackgroundTanDispatchBlock(message as CustomMessage)); break; } const handoffComponent = createHandoffSummaryMessageComponent( message as CustomMessage, this.ctx.toolOutputExpanded, ); if (handoffComponent) { this.ctx.chatContainer.addChild(handoffComponent); break; } const renderer = this.ctx.viewSession.extensionRunner?.getMessageRenderer(message.customType); // Both HookMessage and CustomMessage have the same structure, cast for compatibility const component = new CustomMessageComponent(message as CustomMessage, renderer); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(component); } break; } case "compactionSummary": { const component = new CompactionSummaryMessageComponent(message); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(component); break; } case "branchSummary": { const component = new BranchSummaryMessageComponent(message); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(component); break; } case "fileMention": { // Render compact file mention display const block = buildFileMentionBlock(message.files, 0); if (block.children.length > 0) this.ctx.chatContainer.addChild(block); break; } case "user": case "developer": { if (message.role === "user" && message.parentIrc) { const card = buildParentIrcMessageCard( message.parentIrc, message.timestamp, () => this.ctx.toolOutputExpanded, ); this.ctx.chatContainer.addChild(card); break; } const userText = message.role === "user" ? textContent(message.content) : ""; if (userText) { const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false); const cached = options?.reuseSettledComponent ? this.ctx.transcriptMessageComponents.get(message) : undefined; let userComponent: UserMessageComponent; if (cached instanceof UserMessageComponent) { userComponent = cached; } else { const images = imageContent(message.content); const imageLinks = options?.imageLinks ?? imageLinksForMessage(images, this.ctx.viewSession.sessionManager); userComponent = new UserMessageComponent(userText, { synthetic: isSynthetic, imageLinks, images, liveSteered: message.role === "user" && message.liveSteered === true, timestamp: message.timestamp, }); this.ctx.transcriptMessageComponents.set(message, userComponent); } this.ctx.chatContainer.addChild(userComponent); } break; } case "assistant": { const cached = options?.reuseSettledComponent ? this.ctx.transcriptMessageComponents.get(message) : undefined; const assistantComponent = cached instanceof AssistantMessageComponent ? cached : createAssistantMessageComponent( this.ctx, splitAssistantMessageToolTimeline(message).beforeTools, getAssistantMessageLinkTargets(this.ctx), ); if (cached !== assistantComponent) { this.ctx.transcriptMessageComponents.set(message, assistantComponent); } assistantComponent.pickReactionTarget(this.ctx.chatContainer.children); this.ctx.chatContainer.addChild(assistantComponent); break; } case "toolResult": { // Tool results are rendered inline with tool calls, handled separately break; } default: { message satisfies never; } } return []; } /** * Render session context to chat. Used for initial load and rebuild after compaction. * @param sessionContext Session context to render * @param options.updateFooter Update footer state * @param options.populateHistory Add user messages to editor history */ renderSessionContext(sessionContext: SessionContext, options: RenderSessionContextOptions = {}): void { const steps = this.#renderSessionContextSteps(sessionContext, options); while (!steps.next().done) {} } /** Build a session context in bounded chunks so terminal input runs between event-loop turns. */ async renderSessionContextIncrementally( sessionContext: SessionContext, options: RenderSessionContextOptions, renderChunk?: () => void, ): Promise { const steps = this.#renderSessionContextSteps(sessionContext, options); let messagesSinceYield = 0; let chunkStartedAt = performance.now(); while (!steps.next().done) { messagesSinceYield++; if ( messagesSinceYield < TRANSCRIPT_RENDER_CHUNK_MESSAGES && performance.now() - chunkStartedAt < TRANSCRIPT_RENDER_CHUNK_MS ) { continue; } renderChunk?.(); await waitForImmediate(); messagesSinceYield = 0; chunkStartedAt = performance.now(); } } *#renderSessionContextSteps( sessionContext: SessionContext, options: RenderSessionContextOptions = {}, ): Generator { // Preserved: message_start handler owns this lifecycle (see #783) this.ctx.pendingTools.clear(); const activeToolExecutionUpdates = this.ctx.viewSession.activeToolExecutionUpdates?.() ?? []; const runningAsyncJobs = this.ctx.viewSession.getAsyncJobSnapshot?.()?.running ?? []; // Reseed the cache-invalidation baseline: this rebuild re-derives every // turn's marker from usage, and the last turn becomes the live baseline. this.ctx.lastAssistantUsage = undefined; // Same for the served-model tracker: replaying history re-flags the first // occurrence of each substitution and carries the memory forward live. this.ctx.servedModelTracker = new ServedModelTracker(); if (options.updateFooter) { this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); } let readGroup: ReadToolGroupComponent | null = null; const readToolCallArgs = new Map>(); const readToolCallAssistantComponents = new Map(); // 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. let pendingUsage: Usage | undefined; let pendingUsageDuration: number | undefined; let pendingUsageTtft: number | undefined; let pendingUsageTimestamp: number | undefined; let pendingReadUsageCallIds: string[] | undefined; let pendingUsageTurnElapsed: number | undefined; let turnStartedAt: number | undefined; const flushPendingUsage = () => { if (!pendingUsage) return; const usageAttached = pendingReadUsageCallIds !== undefined && (readGroup?.attachUsage( pendingReadUsageCallIds, pendingUsage, pendingUsageDuration, pendingUsageTtft, pendingUsageTimestamp, pendingUsageTurnElapsed, ) ?? false); if (!usageAttached) { readGroup?.seal(); readGroup = null; this.ctx.chatContainer.addChild( createUsageRowBlock( pendingUsage, pendingUsageDuration, pendingUsageTtft, pendingUsageTimestamp, pendingUsageTurnElapsed, ), ); } pendingUsage = undefined; pendingUsageDuration = undefined; pendingUsageTtft = undefined; pendingUsageTimestamp = undefined; pendingReadUsageCallIds = undefined; pendingUsageTurnElapsed = undefined; }; // Rebuild-time mirror of the event controller's displaceable-poll // bookkeeping: a `wait` that found every watched job still running is // superseded by the next `wait` call, so a rebuilt transcript collapses a // repeated-poll run to its final snapshot instead of replaying the spam. let waitingPoll: ToolExecutionComponent | null = null; const resolveWaitingPoll = (nextToolName?: string) => { const previous = waitingPoll; if (!previous) return; waitingPoll = null; if ( nextToolName === "wait" && previous.isDisplaceableBlock() && this.ctx.chatContainer.canDisplaceBlock(previous) ) { this.ctx.chatContainer.removeChild(previous); } // Sealing finalizes the block and stops the waiting-poll spinner that // updateResult armed. previous.seal(); }; let todoSnapshot: ToolExecutionComponent | null = null; const resolveTodoSnapshot = (nextToolName?: string) => { const previous = todoSnapshot; if (!previous) return; if (!previous.isDisplaceableBlock()) { todoSnapshot = null; return; } if (previous.canBeDisplacedBy(nextToolName)) { todoSnapshot = null; this.ctx.chatContainer.removeChild(previous); previous.seal(); return; } if (nextToolName !== undefined) return; todoSnapshot = null; previous.seal(); }; // Background job calls persist their initial `async.state === "running"` // result, but the job keeps streaming progress afterwards. Parked here // (not finalized) so replayed and live frames still route to the card; // the ids are handed back to the controller after the loop (#10447). const backgroundCallIds = new Set(); const messages = sessionContext.messages; const count = messages.length; for (let i = 0; i < count; i++) { // Yield BEFORE each message (except the first) rather than after: the // per-message body has several early `continue` paths (preserved live // results, image-only and grouped `read` results), and a trailing yield // is skipped by all of them. A large parallel-read batch is entirely // such results, so an after-body yield never trips the chunk counter and // the whole batch replays in one event-loop turn. Yielding at the top of // the next iteration is reached no matter how the prior message exited. if (i > 0) yield; const message = messages[i]!; if (message.role !== "toolResult") flushPendingUsage(); // Assistant messages need special handling for tool calls if (message.role === "assistant") { const timeline = splitAssistantMessageToolTimeline(message); this.ctx.addMessageToChat(message, { reuseSettledComponent: options.reuseSettledComponents }); const lastChild = this.ctx.chatContainer.children[this.ctx.chatContainer.children.length - 1]; const assistantComponent = lastChild instanceof AssistantMessageComponent ? lastChild : undefined; if (assistantComponent) { const usage = message.usage; const explained = sessionContext.cacheMissExplainedAt?.[i] ?? false; if (cfgDisplayCacheMissMarker.get(this.ctx.settings) && !explained) { const invalidation = detectCacheInvalidation(this.ctx.lastAssistantUsage, usage); if (invalidation) assistantComponent.setCacheInvalidation(invalidation); } if (usage.cacheRead + usage.cacheWrite + usage.input > 0) { this.ctx.lastAssistantUsage = usage; } assistantComponent.setServedModelMismatch(this.ctx.servedModelTracker.check(message)); } const hasVisibleAssistantContent = assistantHasVisibleContent(message); if (hasVisibleAssistantContent) { // Rebuild reconstructs immutable history; seal (not finalize) because // a pending entry otherwise keeps the group active indefinitely. readGroup?.seal(); readGroup = null; } const errorPresentation = resolveAssistantErrorPresentation(message, this.ctx.viewSession.retryAttempt); const hasErrorStop = errorPresentation.kind === "full"; const errorMessage = hasErrorStop ? errorPresentation.text : null; const appendAssistantSegment = (toolCallId: string) => { const segment = timeline.afterToolCalls.get(toolCallId); if (!segment || !assistantHasVisibleContent(segment)) return; const component = createAssistantMessageComponent( this.ctx, segment, getAssistantMessageLinkTargets(this.ctx), timeline.contentOffsets.get(toolCallId), ); this.ctx.chatContainer.addChild(component); }; // Render tool call components for (const content of message.content) { if (content.type !== "toolCall") { continue; } const afterToolSegment = timeline.afterToolCalls.get(content.id); if (options.preservedLiveToolCallIds?.has(content.id)) { appendAssistantSegment(content.id); continue; } const tool = this.ctx.viewSession.getToolByName(content.name); const renderToolName = toolRenderName(content.name, tool); resolveWaitingPoll(renderToolName); if (renderToolName === "read" && readArgsCollapseIntoGroup(content.arguments)) { if (hasErrorStop && errorMessage) { if (!readGroup) { readGroup = new ReadToolGroupComponent({ showContentPreview: cfgReadToolResultPreview.get(this.ctx.settings), }); readGroup.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(readGroup); } readGroup.updateArgs(content.arguments, content.id); readGroup.updateResult( { content: [{ type: "text", text: errorMessage }], isError: true }, false, content.id, ); } else if (afterToolSegment) { if (!readGroup) { readGroup = new ReadToolGroupComponent({ showContentPreview: cfgReadToolResultPreview.get(this.ctx.settings), }); readGroup.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(readGroup); } readGroup.updateArgs(content.arguments, content.id); this.ctx.pendingTools.set(content.id, readGroup); if (assistantComponent) { readToolCallAssistantComponents.set(content.id, assistantComponent); if (this.ctx.viewSession.isStreaming) { this.ctx.eventController?.inheritReadToolAssistant(content.id, assistantComponent); } } } else { const normalizedArgs = normalizeToolArgs(content.arguments); readToolCallArgs.set(content.id, normalizedArgs); if (assistantComponent) { readToolCallAssistantComponents.set(content.id, assistantComponent); } } appendAssistantSegment(content.id); continue; } readGroup?.seal(); readGroup = null; const partialJson = getStreamingPartialJson(content); // Mid-stream rebuild (theme change, settings, focus replay): decode // display args from the raw stream exactly like the live reveal path. // The provider-parsed `arguments` lag the stream by up to a throttled // parse window, so spreading them alone would freeze a long write/edit // preview at its last full parse. const rawInput = content.customWireName !== undefined; const renderArgs = partialJson ? decodeStreamedToolArgs(partialJson, { rawInput, fullArgs: content.arguments, streamingStringKeys: streamingStringKeysForTool(renderToolName, rawInput), }) : content.arguments; const component = createToolCallBlock(this.ctx, { name: renderToolName, args: renderArgs, tool, id: content.id, cwd: this.ctx.viewSession.sessionManager.getCwd(), }); // Persisted args are final: settle a reply's text now. Cards keep their own args-complete timing. if (!partialJson && component instanceof ReplyWriteComponent) component.setArgsComplete(); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(component); if (hasErrorStop && errorMessage) { component.updateResult( { content: [{ type: "text", text: errorMessage }], isError: true }, false, content.id, ); } else { this.ctx.pendingTools.set(content.id, component); } appendAssistantSegment(content.id); } // Dangling toolCalls (no result on the resolved path — failed or // retried turns, results on sibling branches) were stripped by the // context build; surface a placeholder so the turn's activity is // visibly elided instead of silently vanishing (the "bare thinking // lines" transcript trap). const strippedToolCalls = (message as AgentMessage & StrippedToolCallsMarker).strippedToolCalls ?? 0; if (strippedToolCalls > 0) { this.ctx.chatContainer.addChild( new StrippedToolCallsPlaceholder(strippedToolCalls, !this.ctx.hideToolActivity), ); } pendingUsage = cfgDisplayShowTokenUsage.get(this.ctx.settings) && assistantUsageIsBilled(message.usage) ? message.usage : undefined; pendingUsageDuration = message.duration; pendingUsageTtft = message.ttft; pendingUsageTimestamp = message.timestamp; pendingReadUsageCallIds = pendingUsage ? groupedReadUsageCallIds(message) : undefined; pendingUsageTurnElapsed = cfgDisplayShowTurnTime.get(this.ctx.settings) ? turnElapsedMs(turnStartedAt, message) : undefined; } else if (message.role === "toolResult") { if (options.preservedLiveToolCallIds?.has(message.toolCallId)) continue; const pendingReadComponent = this.ctx.pendingTools.get(message.toolCallId); // Mirror the call side: resolve the wire toolName through the registry // so aliased results route under the canonical name their call used. const tool = this.ctx.viewSession.getToolByName(message.toolName); const renderToolName = toolRenderName(message.toolName, tool); const isReadGroupResult = renderToolName === "read" && (!pendingReadComponent || pendingReadComponent instanceof ReadToolGroupComponent); if (isReadGroupResult) { const assistantComponent = readToolCallAssistantComponents.get(message.toolCallId); if (this.ctx.viewSession.isStreaming) { this.ctx.eventController?.inheritReadToolAssistant(message.toolCallId, undefined); } const images: ImageContent[] = message.content.filter( (content): content is ImageContent => content.type === "image", ); const mediaPath = readMediaPath(message.details); if (mediaPath && images.length === 0) assistantComponent?.setToolResultImages(message.toolCallId, [], mediaPath); if (images.length > 0 && assistantComponent) { assistantComponent.setToolResultImages(message.toolCallId, images, mediaPath); const hasText = message.content.some(c => c.type === "text"); if (!hasText && cfgTerminalShowImages.get(settings)) { if (pendingReadComponent) { pendingReadComponent.updateResult(message, false, message.toolCallId); this.ctx.pendingTools.delete(message.toolCallId); } readToolCallArgs.delete(message.toolCallId); readToolCallAssistantComponents.delete(message.toolCallId); continue; } } let component = this.ctx.pendingTools.get(message.toolCallId); if (!component) { if (!readGroup) { readGroup = new ReadToolGroupComponent({ showContentPreview: cfgReadToolResultPreview.get(this.ctx.settings), }); readGroup.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(readGroup); } const args = readToolCallArgs.get(message.toolCallId); if (args) { readGroup.updateArgs(args, message.toolCallId); } component = readGroup; this.ctx.pendingTools.set(message.toolCallId, readGroup); } component.updateResult(message, false, message.toolCallId); this.ctx.pendingTools.delete(message.toolCallId); readToolCallArgs.delete(message.toolCallId); readToolCallAssistantComponents.delete(message.toolCallId); continue; } // Match tool results to pending tool components const component = this.ctx.pendingTools.get(message.toolCallId); if (component) { const jobId = (message.details as { async?: { jobId?: string } } | undefined)?.async?.jobId; const frame = activeToolExecutionUpdates.find( event => event.toolCallId === message.toolCallId, )?.partialResult; const isBackground = parksAsBackground(message.toolName, message.details) && (frame !== undefined || runningAsyncJobs.some(job => job.id === jobId)); const settled = isBackground && isSettledJobFrame(frame?.details); // A background job call's persisted result is only its "still running" // snapshot; the job's latest frame supersedes it. Until that frame // settles, keep the card partial, parked, and in `pendingTools` so // later live progress frames land on it instead of hitting the // no-pending-component early return (#10447). if (settled) { settleWithJobFrame(component, frame, message.toolCallId); } else if (frame && isBackground) { component.updateResult(frame, true, message.toolCallId); } else { component.updateResult(message, isBackground, message.toolCallId); } if (isBackground && !settled) { component.parkAsBackground(); backgroundCallIds.add(message.toolCallId); } else { this.ctx.pendingTools.delete(message.toolCallId); if ( renderToolName === "wait" && component instanceof ToolExecutionComponent && component.isDisplaceableBlock() ) { waitingPoll = component; } else if ( renderToolName === "todo" && component instanceof ToolExecutionComponent && component.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. resolveTodoSnapshot("todo"); todoSnapshot = component; } } } for (const reply of toolResultReplies(message.details)) { this.ctx.chatContainer.addChild(createAssistantMessageComponent(this.ctx, replyDisplayMessage(reply))); } } else { readGroup?.seal(); readGroup = null; // A user prompt closes the displacement window, same as the live path. if (message.role === "user") resolveWaitingPoll(); if (message.role === "user") resolveTodoSnapshot(); // Only genuinely user-attributed prompts anchor the delta; a mid-run // agent-attributed `user` message (advisor tool-loop redirect) must not. if (message.role === "user" && message.attribution !== "agent") turnStartedAt = message.timestamp; // A synthetic developer message initiates a fresh run (auto-continue, // /goal, approved plan): replay must not inherit the preceding user // prompt's timestamp, mirroring the live agent_start clear. Same-turn // continuation reminders (todo, plan) are persisted developer messages // WITHOUT the synthetic marker, so their anchor survives the rebuild. if (message.role === "developer" && message.synthetic) { // A deliberate operator action (`.`, `c` continue shortcut) is the // turn's own prompt: anchor the delta to it instead of clearing. if (message.userInitiated) turnStartedAt = message.timestamp; else turnStartedAt = undefined; } if (message.role === "custom" && isUserTurnInitiator(message as CustomMessage)) { turnStartedAt = message.timestamp; } // All other messages use standard rendering this.ctx.addMessageToChat(message, { reuseSettledComponent: options.reuseSettledComponents }); } } flushPendingUsage(); // The trailing read run has no following break to close it; seal so the // rebuilt group can retire as history even with a never-persisted result. readGroup?.seal(); // A trailing waiting poll is final history on rebuild; seal it and stop // its spinner timer. resolveWaitingPoll(); // A trailing todo snapshot is live state, not history: when the rebuild // runs mid-turn (settings overlay close, focus attach during streaming), // hand it back to the controller so a follow-up `todo` update keeps // displacing instead of stacking. Idle rebuilds (resume / compaction) // fall through to the seal path so the snapshot retires as history. if (todoSnapshot && this.ctx.viewSession.isStreaming) { this.ctx.eventController?.inheritDisplaceableTodo(todoSnapshot); todoSnapshot = null; } else { resolveTodoSnapshot(); } // Same mid-turn handoff for the prompt→yield delta: focus attach and // mid-turn rebuilds reset the controller's turn start before replaying, // so the in-flight assistant `message_end` would otherwise render the // usage row without the elapsed figure. Mirrors inheritDisplaceableTodo. if (this.ctx.viewSession.isStreaming) { this.ctx.eventController?.inheritTurnStart(turnStartedAt); } // Re-register parked background cards with the controller: focus // attach resets its `#backgroundCallIds` before replaying, and unlike // the todo/turn handoffs this must run whether or not the session streams // — a background job keeps running while the main session sits idle // (#10447). Membership is re-checked against `pendingTools`. if (backgroundCallIds.size > 0) { this.ctx.eventController?.markBackgroundCalls(backgroundCallIds); } // The idle recap is journaled only in history.db, never in the session file, so a replay // (resume, /restart, rebuild) would drop it. Re-show the newest one while the conversation // has not moved since it was recorded and the view still stands at that point. const sessionManager = this.ctx.viewSession.sessionManager; const tip = this.ctx.viewSession.isStreaming ? undefined : sessionManager.getConversationTip(); if (tip) { const [recap] = listSessionRecaps({ sessionIds: [sessionManager.getSessionId()], limit: 1 }); // `createdAt` has second precision; a recap lands at least a second after the turn it follows. if (recap && recap.createdAt * 1000 > Date.parse(tip.timestamp)) { const text = previewLine(recap.recap, TRUNCATE_LENGTHS.RECAP); if (text) this.ctx.chatContainer.addChild(new RecapNotice(text)); } } // Entries still in `pendingTools` are toolCalls whose result never landed // during the replay — with `keepInFlightToolCalls` these are exactly the // turn's in-flight calls (assistant turn persisted at message_end, tool // still executing). While the viewed session streams, keep them tracked so // the live event stream routes `tool_execution_update`/`_end` into the // rebuilt components instead of dropping the result; their args are final, // so mark them complete. Idle rebuilds have no result coming: seal so the // blocks can retire as history, then clear them so reconstructed historical // components never leak into active tracking. // (`rebuildChatFromMessages` builds its context WITHOUT dangling calls and // restores its own preserved live components afterwards — for that caller // the map is empty here either way.) if (this.ctx.viewSession.isStreaming) { for (const [toolCallId, component] of this.ctx.pendingTools) { component.setArgsComplete(toolCallId); if (this.ctx.eventController?.hasToolExecutionStarted(toolCallId)) { component.setExecutionStarted(toolCallId); } } } else { for (const [toolCallId, component] of this.ctx.pendingTools) { // A parked background job keeps running even while the main session // is idle, so leave its card pending — sealing and clearing it would // drop the replayed snapshot and every later job frame (#10447). if (backgroundCallIds.has(toolCallId)) { component.setArgsComplete(toolCallId); continue; } component.seal(); this.ctx.pendingTools.delete(toolCallId); } } this.ctx.ui.requestRender(); } /** * Fast-path history rewind (esc-esc branch, /tree rewind to an ancestor): * drop the rendered components at/after `message` in place instead of the * destructive clear-scrollback replay. Rows already committed to native * scrollback are immutable, so the drop is expressible only while every * affected block is still wholly inside the visible window; returns false * when the caller must fall back to * `renderInitialMessages({ clearTerminalHistory: true })`. * * Callers must have already rewound the session so that `message` and * everything after it are no longer part of the view session's transcript. */ truncateTranscriptFromMessage(message: AgentMessage): boolean { if (!this.ctx.initialChatRendered || this.ctx.focusedAgentId || this.ctx.viewSession.isStreaming) return false; // In-flight blocks route future events into their components; a rewind // with any of them live takes the full-replay path instead. if ( this.ctx.pendingTools.size > 0 || this.ctx.pendingBashComponents.length > 0 || this.ctx.pendingPythonComponents.length > 0 ) { return false; } const chat = this.ctx.chatContainer; const cut = this.ctx.transcriptMessageComponents.get(message); if (!cut) return false; const index = chat.children.indexOf(cut); if (index < 0) return false; // Every dropped block must still be uncommitted: removing rows already on // the tape is an interior deletion of committed history the render engine // cannot express (see TranscriptContainer.isBlockUncommitted). for (let i = index; i < chat.children.length; i++) { if (!chat.canRemoveBlock(chat.children[i]!)) return false; } // Ground truth for the surviving prefix. The cut message still present // means the session was not actually rewound past it — bail before // mutating anything. const context = this.ctx.viewSession.buildTranscriptSessionContext({ collapseCompactedHistory: cfgDisplayCollapseCompacted.get(settings), }); for (const remaining of context.messages) { if (remaining === message) return false; } const dropped = chat.children.slice(index); for (let i = dropped.length - 1; i >= 0; i--) { const child = dropped[i]!; chat.removeChild(child); child.dispose?.(); } // Prune the settled-component cache to the surviving messages — dropped // entries stay strongly reachable through the session tree and would // otherwise pin their components' layout caches (same rationale as // rebuildChatFromMessages). const retained = new WeakMap(); for (const remaining of context.messages) { const component = this.ctx.transcriptMessageComponents.get(remaining); if (component) retained.set(remaining, component); } this.ctx.transcriptMessageComponents = retained; // Reseed the cache-invalidation baseline from the surviving transcript // (mirrors the replay path's billed-usage rule). let baseline: Usage | undefined; for (let i = context.messages.length - 1; i >= 0; i--) { const candidate = context.messages[i]!; if (candidate.role !== "assistant") continue; const usage = candidate.usage; if (usage.cacheRead + usage.cacheWrite + usage.input > 0) { baseline = usage; break; } } this.ctx.lastAssistantUsage = baseline; this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); this.ctx.ui.requestRender(); return true; } /** * Rebuild the transcript from the view session. Calls run one at a time; a call * superseded by a newer one before or during its replay rolls back without * committing or clearing terminal history, so only the latest request paints. */ renderInitialMessages(options: RenderInitialMessagesOptions = {}): Promise { const generation = ++this.#replayGeneration; // Close the display gate at request time, not when the queued replay starts: // a display message persisted in between must reach the chat only through // the replay, never also live (issue #1955). if (this.#replaysInFlight++ === 0) this.#chatRenderedBeforeReplay = this.ctx.initialChatRendered; this.ctx.initialChatRendered = false; const run = this.#replayTail .then(() => this.#renderInitialMessages(options, generation)) .catch((error: unknown) => { // A replay that throws before its rollback (building the transcript context) // must not leave live display closed until some later replay. if (generation === this.#replayGeneration) this.ctx.initialChatRendered = this.#chatRenderedBeforeReplay; throw error; }) .finally(() => { this.#replaysInFlight--; }); this.#replayTail = run.catch(() => {}); return run; } /** * Make a transcript replay still in flight rebuild its components with the current extension * text renderers and link schemes before it commits. Components take both when built, so a * change while a replay yields would otherwise stay on the committed transcript. Returns * whether a replay was in flight; the caller then leaves the redraw to it. */ restartTranscriptReplay(): boolean { if (this.#replaysInFlight === 0) return false; this.#textRendererSetVersion++; this.#textRendererSetChangedDuringReplay = true; return true; } async #renderInitialMessages(options: RenderInitialMessagesOptions, generation: number): Promise { if (generation !== this.#replayGeneration) return; // Collapsed replay keeps in-flight calls so pending tools remain routable during mid-turn rebuilds. let context = this.ctx.viewSession.buildTranscriptSessionContext({ collapseCompactedHistory: cfgDisplayCollapseCompacted.get(settings), keepInFlightToolCalls: this.ctx.viewSession.isStreaming, }); let replayEntryCount = this.ctx.viewSession.sessionManager.getEntries().length; // Build against a detached container. Incremental construction still yields // to terminal input, while paints keep using the complete visible transcript // until the replacement is ready to swap in. const visibleChatContainer = this.ctx.chatContainer; const stagedChatContainer = new TranscriptContainer(); stagedChatContainer.setToolActivityVisible(!this.ctx.hideToolActivity); const preservedChatChildren = options.preserveExistingChat ? [...visibleChatContainer.children] : undefined; const previousTranscriptMessageComponents = this.ctx.transcriptMessageComponents; const previousPendingTools = this.ctx.pendingTools; const previousPendingBashComponents = this.ctx.pendingBashComponents; const previousPendingPythonComponents = this.ctx.pendingPythonComponents; const previousLastAssistantUsage = this.ctx.lastAssistantUsage; const previousServedModelTracker = this.ctx.servedModelTracker; const renderOptions = { updateFooter: true, }; let committed = false; let replayAttempts = 0; try { // Resolve before replacing live component maps: streaming events may arrive during filesystem I/O. await refreshAssistantMessageLinkTargets( this.ctx, context.messages.filter((message): message is AssistantMessage => message.role === "assistant"), ); if (generation !== this.#replayGeneration) return; this.ctx.chatContainer = stagedChatContainer; this.ctx.transcriptMessageComponents = new WeakMap(); this.ctx.pendingTools = new Map(); // Drops deferred bash/python blocks with the old transcript, then repaints // the queued-message bar from the live session queue: a mid-turn rebuild // (rewind, /tree) keeps the queue, so it must stay visible and editable. this.ctx.pendingShellContainer.disposeChildren(); this.ctx.updatePendingMessagesDisplay(); this.ctx.pendingBashComponents = []; this.ctx.pendingPythonComponents = []; while (true) { const passRendererSetVersion = this.#textRendererSetVersion; if (this.ctx.viewSession.isStreaming) { // Live events mutate the same component maps; keep their replay atomic so // a delta cannot land halfway through rebuilding its pending tool block. this.ctx.renderSessionContext(context, renderOptions); } else { await this.ctx.renderSessionContextIncrementally(context, renderOptions); } if (generation !== this.#replayGeneration) return; const entriesChanged = this.ctx.viewSession.sessionManager.getEntries().length !== replayEntryCount; if (!entriesChanged && passRendererSetVersion === this.#textRendererSetVersion) { break; } replayAttempts++; if (replayAttempts >= TRANSCRIPT_REPLAY_MAX_ATTEMPTS) { // A source keeps persisting entries faster than a full replay pass // completes. Accept the transcript just replayed instead of // restarting forever (see TRANSCRIPT_REPLAY_MAX_ATTEMPTS). logger.warn("renderInitialMessages: transcript replay did not converge; accepting current replay", { attempts: replayAttempts, replayEntryCount, currentEntryCount: this.ctx.viewSession.sessionManager.getEntries().length, }); break; } // An extension persisted a display message, or the extension text renderer set // changed, while the transcript replay yielded. The display callback stayed gated by // initialChatRendered; discard the stale partial tree and replay the current session // once more instead of letting a reentrant synchronous rebuild interleave. context = this.ctx.viewSession.buildTranscriptSessionContext({ collapseCompactedHistory: cfgDisplayCollapseCompacted.get(settings), keepInFlightToolCalls: this.ctx.viewSession.isStreaming, }); replayEntryCount = this.ctx.viewSession.sessionManager.getEntries().length; await refreshAssistantMessageLinkTargets( this.ctx, context.messages.filter((message): message is AssistantMessage => message.role === "assistant"), ); if (generation !== this.#replayGeneration) return; stagedChatContainer.disposeChildren(); this.ctx.transcriptMessageComponents = new WeakMap(); this.ctx.pendingTools.clear(); this.ctx.pendingBashComponents = []; this.ctx.pendingPythonComponents = []; } const replayedChatChildren = [...stagedChatContainer.children]; stagedChatContainer.clear(); this.ctx.chatContainer = visibleChatContainer; if (preservedChatChildren) { visibleChatContainer.clear(); } else { visibleChatContainer.disposeChildren(); } for (const child of replayedChatChildren) { visibleChatContainer.addChild(child); } if (preservedChatChildren) { for (const child of preservedChatChildren) { visibleChatContainer.addChild(child); } } committed = true; options.onCommit?.(context); // Show compaction info if session was compacted. const allEntries = this.ctx.viewSession.sessionManager.getEntries(); let compactionCount = 0; for (const entry of allEntries) { if (entry.type === "compaction") { compactionCount++; } } if (compactionCount > 0) { const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`; this.ctx.showStatus(`Session compacted ${times}`); } // Rows of the previous transcript already in native scrollback were drawn with the old // text renderer set; retire them as the direct renderer-change rebuild does. const staleRendererRows = this.#textRendererSetChangedDuringReplay && this.#chatRenderedBeforeReplay; this.#textRendererSetChangedDuringReplay = false; if (options.clearTerminalHistory || staleRendererRows) { this.ctx.ui.requestRender(true, { clearScrollback: true }); } else { this.ctx.ui.requestRender(); } } finally { if (!committed) { this.ctx.chatContainer = visibleChatContainer; this.ctx.transcriptMessageComponents = previousTranscriptMessageComponents; this.ctx.pendingTools = previousPendingTools; this.ctx.pendingBashComponents = previousPendingBashComponents; this.ctx.pendingPythonComponents = previousPendingPythonComponents; this.ctx.lastAssistantUsage = previousLastAssistantUsage; this.ctx.servedModelTracker = previousServedModelTracker; stagedChatContainer.disposeChildren(); } // A superseded replay leaves the gate closed for the newer one still queued. if (committed) this.#chatRenderedBeforeReplay = true; this.ctx.initialChatRendered = generation === this.#replayGeneration && this.#chatRenderedBeforeReplay; } } clearEditor(): void { if (cfgComposerRecallClearedDrafts.get(this.ctx.settings)) this.ctx.editor.clearDraftForRecall(); else this.ctx.editor.clearDraft(); this.ctx.ui.requestRender(); } showError(errorMessage: string): void { this.ctx.present(TranscriptNotice.line("error", errorMessage)); } showWarning(warningMessage: string, options?: { hideWithToolActivity?: boolean }): void { const notice = TranscriptNotice.line("warning", warningMessage); this.ctx.present(options?.hideWithToolActivity ? new ToolActivityContainer(notice) : notice); } showNewVersionNotification(newVersion: string): void { const title = "Update Available"; const prefix = `New version ${newVersion} is available. Run: `; const command = "omp update"; const native = noticeRow( "info", [ span("Update available", "strong"), span(` · New version ${newVersion} is available. Run `), span(command, "code"), ], { icon: "download" }, ); this.ctx.present( new TranscriptNotice(native, [ new DynamicBorder(text => theme.fg("warning", text)), new Text(`${title}\n${prefix}${command}`, 1, 0).setStyleFn( () => `${theme.bold(theme.fg("warning", title))}\n${theme.fg("muted", prefix)}${theme.fg("accent", command)}`, ), new DynamicBorder(text => theme.fg("warning", text)), ]), ); } updatePendingMessagesDisplay(): void { this.ctx.pendingMessagesContainer.disposeChildren(); const queuedMessages = this.ctx.viewSession.getQueuedMessages() as QueuedMessages; const steeringMessages = [...queuedMessages.steering]; const followUpMessages = [...queuedMessages.followUp]; if (!this.ctx.focusedAgentId) { if (this.ctx.yieldCompaction) followUpMessages.push(this.ctx.yieldCompaction.text); for (const entry of this.ctx.compactionQueuedMessages as CompactionQueuedMessage[]) { if (entry.mode === "steer") steeringMessages.push(entry.text); else followUpMessages.push(entry.text); } } const groups = [ { label: "Steering", messages: steeringMessages }, { label: "After yield", messages: followUpMessages }, ].filter(group => group.messages.length > 0); if (groups.length > 0) { const dequeueKey = this.ctx.keybindings.getKeys("app.message.dequeue")[0] ?? "alt+up"; this.ctx.pendingMessagesContainer.addChild( new QueuedMessagesBand(groups, dequeueKey, () => this.ctx.handleDequeue()), ); } this.ctx.ui.requestComponentRender(this.ctx.pendingMessagesContainer); } queueCompactionMessage( text: string, mode: "steer" | "followUp", images?: ImageContent[], options?: { preserveDraft?: boolean }, ): void { const queuedImages = images && images.length > 0 ? images : undefined; this.ctx.compactionQueuedMessages.push({ text, mode, images: queuedImages } as CompactionQueuedMessage); if (options?.preserveDraft) this.ctx.editor.addToHistory(text); else this.ctx.editor.clearDraft(text); this.ctx.updatePendingMessagesDisplay(); this.ctx.showStatus( queuedImages ? "Queued message with image for after compaction" : "Queued message for after compaction", ); } /** * Park the loop when drain dispatch consumes the armed body locally (void * custom command) instead of starting a turn. The drain otherwise discards * prompt()'s result, and the next idle tick would resubmit a local action. */ #parkLoopOnLocalConsume(text: string, forwarded: boolean): void { if (!forwarded && this.ctx.loopPrompt === text) this.ctx.pauseLoop(); } /** Deliver one compaction-queued message; `onQueued` receives what it pushes onto the session queues. */ async #deliverQueuedMessage( message: CompactionQueuedMessage, onQueued: (queued: readonly AgentMessage[]) => void, ): Promise { const builtin = await executeBuiltinSlashCommand(message.text, { ctx: this.ctx, input: message.images ? { images: message.images } : undefined, }); if (builtin === true) { this.#parkLoopOnLocalConsume(message.text, false); return; } if (typeof builtin === "string") { const forwarded = await this.ctx.session.prompt(builtin, { streamingBehavior: message.mode, images: message.images, onPromptQueued: onQueued, }); this.#parkLoopOnLocalConsume(message.text, forwarded); return; } if ( await invokeSkillCommandFromText(this.ctx, message.text, message.mode, { propagateErrors: true, queueOnly: true, images: message.images, onPromptQueued: onQueued, }) ) { return; } if (this.ctx.isKnownSlashCommand(message.text)) { // No `streamingBehavior`: this prompt never queues (a busy session rejects it). const forwarded = await this.ctx.session.prompt(message.text); this.#parkLoopOnLocalConsume(message.text, forwarded); return; } await this.ctx.withLocalSubmission( message.text, () => message.mode === "followUp" ? this.ctx.session.followUp(message.text, message.images, { onPromptQueued: onQueued }) : this.ctx.session.steer(message.text, message.images, { onPromptQueued: onQueued }), { imageCount: message.images?.length ?? 0 }, ); } isKnownSlashCommand(text: string): boolean { if (!text.startsWith("/")) return false; const parsed = parseSlashCommand(text); const builtin = parsed && lookupBuiltinSlashCommand(parsed.name); if (builtin && (builtin.allowArgs || !parsed.args)) return true; const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); if (!commandName) return false; if (this.ctx.session.extensionRunner?.getCommand(commandName)) { return true; } for (const command of this.ctx.session.customCommands) { if (command.command.name === commandName) { return true; } } return this.ctx.fileSlashCommands.has(commandName); } async flushCompactionQueue(options?: { willRetry?: boolean }): Promise { // Follow-ups queued behind a yield compaction wait for it, not for a pass that lands first. if (this.ctx.yieldCompaction) return; const origin = this.ctx.compactionQueueOrigin; this.ctx.compactionQueueOrigin = undefined; if (this.ctx.compactionQueuedMessages.length === 0) return; // Held behind the queued compaction that just ran: a switch or tree move while it summarized // means they belong to a conversation that is no longer live, so they go back to the editor. if (origin && !continuesConversation(this.ctx.sessionManager, origin)) { this.ctx.restoreCompactionQueueToEditor(); return; } const queuedMessages = [...(this.ctx.compactionQueuedMessages as CompactionQueuedMessage[])]; this.ctx.compactionQueuedMessages = [] as CompactionQueuedMessage[]; this.ctx.updatePendingMessagesDisplay(); // On failure the batch goes back to the compaction queue, so the messages this flush pushed // onto the session queues must leave them. Only those: what other sources queue (an // extension's message, input sent through other paths), before, during or after the flush, // stays queued. const deliveredByFlush: AgentMessage[] = []; const onDelivered = (queued: readonly AgentMessage[]) => { deliveredByFlush.push(...queued); }; let restored = false; // The fire-and-forget first prompt belongs to the session once admitted (queued, or its turn // started): a restore then leaves it out of the batch, and keeps its queued messages, instead // of sending it again on the next flush — unless the prompt itself fails. let firstPrompt: CompactionQueuedMessage | undefined; /** Set once the first prompt is admitted: the messages it queued, or none if its turn started. */ let firstAdmitted: readonly AgentMessage[] | undefined; let firstQueued: readonly AgentMessage[] = []; let firstFailed = false; let firstLeftOut = false; const reportFailure = (count: number, error: unknown) => { this.ctx.updatePendingMessagesDisplay(); this.ctx.showError( `Failed to send queued message${count > 1 ? "s" : ""}: ${ error instanceof Error ? error.message : String(error) }`, ); }; const restoreQueue = (error: unknown) => { // A `rest` delivery and the fire-and-forget first prompt can both fail; restore once. if (restored) return; restored = true; firstLeftOut = firstAdmitted !== undefined && !firstFailed; this.ctx.session.dropQueuedMessages(firstLeftOut ? deliveredByFlush : [...deliveredByFlush, ...firstQueued]); const batch = firstLeftOut ? queuedMessages.filter(message => message !== firstPrompt) : queuedMessages; this.ctx.compactionQueuedMessages = batch; reportFailure(batch.length, error); }; try { if (options?.willRetry) { for (const message of queuedMessages) { await this.#deliverQueuedMessage(message, onDelivered); } this.ctx.updatePendingMessagesDisplay(); return; } let firstPromptIndex = -1; for (let i = 0; i < queuedMessages.length; i++) { if (!this.ctx.isKnownSlashCommand(queuedMessages[i].text)) { firstPromptIndex = i; break; } } if (firstPromptIndex === -1) { for (const message of queuedMessages) { await this.#deliverQueuedMessage(message, onDelivered); } return; } const preCommands = queuedMessages.slice(0, firstPromptIndex); const first = queuedMessages[firstPromptIndex]; firstPrompt = first; const rest = queuedMessages.slice(firstPromptIndex + 1); for (const message of preCommands) { // preCommands are all slash commands; #deliverQueuedMessage handles // that branch (no local-submission marking needed since slash // commands don't generate a matching user message_start). await this.#deliverQueuedMessage(message, onDelivered); } const onFirstQueued = (queued: readonly AgentMessage[]) => { firstQueued = queued; }; const onFirstAdmitted = () => { firstAdmitted = firstQueued; // Admitted after the batch was already restored with it: the session must not send // it as well. Take a queued prompt back out (the batch keeps it); a prompt whose turn // already started leaves the batch instead. if (!restored || firstLeftOut) return; if (firstAdmitted.length > 0) { this.ctx.session.dropQueuedMessages(firstAdmitted); } else { firstLeftOut = true; this.ctx.compactionQueuedMessages = this.ctx.compactionQueuedMessages.filter(m => m !== first); this.ctx.updatePendingMessagesDisplay(); } }; /** Put the first prompt back into the batch, after the slash commands that preceded it. */ const putFirstBack = () => { firstLeftOut = false; const current = this.ctx.compactionQueuedMessages; let at = 0; for (let i = 0; i < current.length; i++) if (preCommands.includes(current[i])) at = i + 1; this.ctx.compactionQueuedMessages = [...current.slice(0, at), first, ...current.slice(at)]; }; const onFirstFailed = (error: unknown) => { firstFailed = true; if (!firstLeftOut) { restoreQueue(error); return; } // A restore left the admitted first prompt out of the batch, but it failed after all. putFirstBack(); reportFailure(1, error); }; // A skill prompt the session dropped before sending it (usage preflight, cost cap, an // interrupt) resolves `false` even after admission, and nothing hands a custom prompt // back to the editor: the session does not own it after all, so a restore keeps it in // the batch, or puts it back if one already left it out. const onFirstDropped = () => { firstAdmitted = undefined; if (!firstLeftOut) return; putFirstBack(); this.ctx.updatePendingMessagesDisplay(); }; // First prompt is fire-and-forget — its rejection is funneled through // `onFirstFailed` rather than rethrown. Plain prompts use primitive // recordLocalSubmission and dispose manually in the catch. Skill prompts // are rebuilt as user-attributed custom messages so queued `/skill:` text // is not sent as a literal prompt after compaction. let promptPromise: Promise; if (isKnownSkillCommand(this.ctx, first.text)) { const built = await buildSkillCommandPrompt(this.ctx, first.text, first.mode, first.images); promptPromise = built ? this.ctx.session .promptCustomMessage(built.message, { ...built.options, onPromptQueued: onFirstQueued, onPromptAdmitted: onFirstAdmitted, }) .then(sent => { if (sent === false) onFirstDropped(); }, onFirstFailed) : Promise.resolve(); } else { const disposeFirstPrompt = this.ctx.recordLocalSubmission(first.text, first.images?.length ?? 0); promptPromise = this.ctx.session .prompt(first.text, { streamingBehavior: first.mode === "followUp" ? "followUp" : "steer", images: first.images, onPromptQueued: onFirstQueued, onPromptAdmitted: onFirstAdmitted, }) .catch((error: unknown) => { disposeFirstPrompt(); onFirstFailed(error); }); } for (const message of rest) { await this.#deliverQueuedMessage(message, onDelivered); // The first prompt failed while the rest was still being delivered: the whole batch is // back in the compaction queue, so take back what was delivered since and stop. if (restored) { this.ctx.session.dropQueuedMessages(deliveredByFlush); return; } } this.ctx.updatePendingMessagesDisplay(); // The dispatch above bypasses `getUserInput`, so nothing would schedule // the next loop iteration for a prompt queued during compaction. if (this.ctx.loopModeEnabled) this.ctx.armLoopAutoSubmit(); void promptPromise; } catch (error) { restoreQueue(error); } } /** Move pending bash components from pending area to chat */ flushPendingBashComponents(): void { for (const component of this.ctx.pendingBashComponents) { this.ctx.pendingShellContainer.removeChild(component); this.ctx.chatContainer.addChild(component); } this.ctx.pendingBashComponents = []; for (const component of this.ctx.pendingPythonComponents) { this.ctx.pendingShellContainer.removeChild(component); this.ctx.chatContainer.addChild(component); } this.ctx.pendingPythonComponents = []; } findLastAssistantMessage(): AssistantMessage | undefined { for (let i = this.ctx.viewSession.messages.length - 1; i >= 0; i--) { const message = this.ctx.viewSession.messages[i]; if (message?.role === "assistant") { return message as AssistantMessage; } } return undefined; } extractAssistantText(message: AssistantMessage): string { return extractVisibleAssistantText(message); } }