import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai"; import * as AIError from "@oh-my-pi/pi-ai/error"; import { getStreamingPartialJson } from "@oh-my-pi/pi-ai/utils/block-symbols"; import { type Component, Loader, TERMINAL } from "@oh-my-pi/pi-tui"; import { formatDuration, isRecord, logger, sanitizeText } from "@oh-my-pi/pi-utils"; import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { extractTextContent } from "../../commit/utils"; import { settings } from "../../config/settings"; import { AssistantMessageComponent } from "@oh-my-pi/pi-tui/chat/assistant-message"; import { detectCacheInvalidation } from "@oh-my-pi/pi-tui/chat/cache-invalidation-marker"; import { groupedReadUsageCallIds, ReadToolGroupComponent, readArgsCollapseIntoGroup, readArgsHaveTarget, } from "@oh-my-pi/pi-tui/chat/read-tool-group"; import { RecapNotice } from "@oh-my-pi/pi-tui/chat/recap-notice"; import { isReplyWrite, ReplyWriteComponent, replyDisplayMessage, toolResultReplies, } from "@oh-my-pi/pi-tui/chat/reply-write"; import { readMediaPath } from "@oh-my-pi/pi-tui/native/blobs"; import { TodoReminderComponent } from "@oh-my-pi/pi-tui/chat/todo-reminder"; import { isNativeRendering, nativePacing } from "@oh-my-pi/pi-tui/native/state"; import { textContent } from "@oh-my-pi/pi-tui/chat/transcript-entry"; import { asyncJobState, isSettledJobFrame, parksAsBackground, settleWithJobFrame, ToolExecutionComponent, type ToolExecutionHandle, toolRenderName, } from "@oh-my-pi/pi-tui/chat/tool-execution"; import { TtsrNotificationComponent } from "@oh-my-pi/pi-tui/chat/ttsr-notification"; import { eventBlockId, PresentedBlock } from "@oh-my-pi/pi-tui/chat/transcript-presentation"; import { createUsageRowBlock, TurnUsageTally, turnElapsedMs } from "@oh-my-pi/pi-tui/overlays/usage-row"; import { appKey } from "@oh-my-pi/pi-tui/chrome/keybinding-hints"; import { getSymbolTheme, theme } from "@oh-my-pi/pi-tui/theme"; import type { InteractiveModeContext } from "../../modes/types"; import type { TodoPhase } from "@oh-my-pi/pi-tui/tools/todo"; import type { AgentSessionEvent } from "../../session/agent-session"; import { isSilentAbort, isUserInvokedSkillPrompt, isUserTurnInitiator, readQueueChipText, resolveAbortLabel, isUserInterruptAbort, USER_INTERRUPT_LABEL, } from "../../session/messages"; import { approvalRestrictions, formatApprovalPrompt, resolveApproval, sessionApprovalScope, } from "../../tools/approval"; import { recoverAskHtml } from "../../tools/ask"; import { fieldQuestions } from "@oh-my-pi/pi-tui/html-form/data"; import { parseHtmlForm } from "@oh-my-pi/pi-tui/html-form/parse"; import { previewLine, PREVIEW_LIMITS, TRUNCATE_LENGTHS } from "@oh-my-pi/pi-tui/render/render-utils"; import { PROPOSE_DEVICE_NAME } from "@oh-my-pi/pi-tui/tools/resolve"; import { writeDeviceDispatch } from "../../tools/resolve"; import { SpeechEnhancer } from "../../tts/speech-enhancer"; import { vocalizer } from "../../tts/vocalizer"; import { canonicalizeMessage } from "@oh-my-pi/pi-tui/chat/thinking-display"; import { type RunStatus, setRunStatus } from "../../utils/run-status"; import { setTerminalTitleState } from "../../utils/title-generator"; import { assistantMessageLinkTargets, createAssistantMessageComponent, refreshAssistantMessageLinkTargets, } from "@oh-my-pi/pi-tui/prompt/interactive-context-helpers"; import { assistantHasVisibleContent, assistantUsageIsBilled, splitAssistantMessageToolTimeline, } from "@oh-my-pi/pi-tui/chat/transcript-render-helpers"; import { isWarpCliAgentProtocolActive } from "../warp-events"; import { ComposerPredictionController } from "./composer-prediction-controller"; import { StreamingRevealController } from "./streaming-reveal"; import { streamingStringKeysForTool, ToolArgsRevealController } from "./tool-args-reveal"; import { createToolCallBlock } from "./tool-call-block"; import { cfgCompletionNotify, cfgDisplayCacheMissMarker, cfgDisplayShowTokenUsage, cfgDisplayShowTurnTime, cfgDisplaySmoothStreaming, cfgErrorNotify, cfgTerminalShowImages, cfgTerminalShowProgress, } from "../settings"; import { cfgReadToolResultPreview, cfgToolsApproval, cfgToolsApprovalMode } from "../../tools/settings"; import { cfgSpeechEnabled, cfgSpeechMode } from "../../tts/settings"; type AgentSessionEventKind = AgentSessionEvent["type"]; const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000; /** * Concurrent IRC cards allowed in the transcript's live region. Cards land * below a still-live block (a running task), where they cannot commit to * native scrollback (commits are prefix-only) — every visible card inflates * the live region and pushes the live block's uncommitted rows above the * window top, where they are neither on screen nor in history. A swarm burst * (several agents coordinating at once) must therefore stay bounded: the * oldest live-region card retires as soon as a new one would exceed the cap. */ const MAX_LIVE_IRC_CARDS = 4; const RAW_PARTIAL_JSON_RENDERERS: Record = { bash: true, edit: true, apply_patch: true }; function hasCommittedTodo(events: unknown): boolean { return ( Array.isArray(events) && events.some(event => isRecord(event) && event.op === "todo" && event.committed === true) ); } /** Eval status events live on each cell. */ function hasNestedTodo(details: unknown): boolean { if (!isRecord(details)) return false; return ( Array.isArray(details.cells) && details.cells.some(cell => isRecord(cell) && hasCommittedTodo(cell.statusEvents)) ); } interface AsyncResultJob { jobId?: string; type?: string; label?: string; durationMs?: number; } /** * Toast text for an `async-result` delivery whose jobs are all background * task spawns, or undefined when it is anything else (bash jobs keep their * transcript rows). Native rendering only: the task call's agent nodes carry * the outcome, so the delivery needs no transcript block of its own. */ function nativeTaskJobToast(message: { role: string; customType?: string; details?: unknown }): string | undefined { if (message.role !== "custom" || message.customType !== "async-result" || !isRecord(message.details)) return; const details = message.details as AsyncResultJob & { jobs?: AsyncResultJob[] }; const jobs = details.jobs && details.jobs.length > 0 ? details.jobs : [details]; if (!jobs.every(job => job.type === "task")) return; if (jobs.length > 1) return `${jobs.length} background tasks completed`; const job = jobs[0]!; const name = sanitizeText(job.label ?? job.jobId ?? "task").trim() || "task"; const took = typeof job.durationMs === "number" ? ` · ${formatDuration(job.durationMs)}` : ""; return `Background task ${name} completed${took}`; } function exposesRawPartialJson(toolName: string, rawInput: boolean, tool: unknown): boolean { if (rawInput) return true; if (RAW_PARTIAL_JSON_RENDERERS[toolName]) return true; if (tool === null || typeof tool !== "object" || !("renderCall" in tool)) return false; return typeof tool.renderCall === "function"; } type AgentSessionEventHandlers = { [E in AgentSessionEventKind]: (event: Extract) => Promise; }; interface ApprovalPreviewGate { promise: Promise; resolve(): void; reject(reason?: unknown): void; started: boolean; } /** * Working-line text for a streamed tool intent, or `undefined` when unusable. * Streamed JSON can deliver non-string `i` (object, number, boolean) before * schema validation, so the type is guarded too. */ function normalizeIntent(intent: unknown): string | undefined { if (typeof intent !== "string") return undefined; return ( intent .trim() .replace(/\s*\.+$/, "") .trim() || undefined ); } /** Job ids the async-wait working row lists before collapsing the rest to `+N`. */ const ASYNC_WAIT_LISTED_JOBS = 3; /** Working-row text while the run only waits on background jobs; short enough for a narrow terminal. */ function formatAsyncWaitLabel(jobIds: readonly string[]): string { if (jobIds.length === 0) return "Waiting for background work"; if (jobIds.length === 1) return `Waiting for background job ${jobIds[0]}`; const listed = jobIds.slice(0, ASYNC_WAIT_LISTED_JOBS).join(", "); const more = jobIds.length > ASYNC_WAIT_LISTED_JOBS ? ` +${jobIds.length - ASYNC_WAIT_LISTED_JOBS}` : ""; return `Waiting for ${jobIds.length} background jobs: ${listed}${more}`; } /** Render args for a live card: the harness strips `i` from executed args, but the stored block keeps it. */ function renderArgsWithIntent(args: unknown, intent: string | undefined): unknown { if (intent === undefined || !isRecord(args) || INTENT_FIELD in args) return args; return { ...args, [INTENT_FIELD]: intent }; } export class EventController { #lastReadGroup: ReadToolGroupComponent | undefined = undefined; /** Timestamp of the current turn's user prompt; drives the usage row's prompt→yield delta. */ #turnStartedAt: number | undefined = undefined; /** The running turn's token and cost totals, shown under its last answer on a TSP terminal. */ readonly #turnUsage = new TurnUsageTally(); /** When the last completed run ended; stale `#turnStartedAt` anchors are cleared against it. */ #lastAgentEndAt: number | undefined = undefined; // Count of visible assistant content blocks (rendered non-empty text/thinking) // already seen in the current streaming message. A newly appearing one breaks // the read run: the rendered reasoning/answer is a visual separator, so reads // after it start a fresh group. Empty/absent thinking — common when a model // emits one read per completion — does not break it, so a run of consecutive // reads collapses into one group even across completion boundaries. #lastVisibleBlockCount = 0; #renderedCustomMessages = new Set(); #lastIntent: string | undefined = undefined; /** Returned calls parked for their background job's frames ({@link parksAsBackground}). */ #backgroundCallIds = new Set(); /** * A job's settled frame ({@link isSettledJobFrame}) that landed while its * call was still executing, keyed by call id: a fast job can settle before * the call's `tool_execution_end` carries its `running` start result. */ #settledBeforeReturn = new Map>(); /** Tool calls waiting on the user (an approval prompt or `ask`), with the * `blocked` status each reports; cleared at their tool_execution_end so the * run returns to `working` once none is left. */ #blockingPrompts = new Map(); #approvalPreviewGates = new Map(); #pendingStreamPreviews = new Map(); #detachToolApprovalPreviewWaiter: (() => void) | undefined; #readToolCallArgs = new Map>(); #readToolCallAssistantComponents = new Map(); #toolTimelineComponents = new Map(); // Stable identity for a streamed tool call while its assistant message is // live: maps the tool-call block's position in the streaming message to the // id last seen at that position. A streamed id can CHANGE across cumulative // `message_update`s — some providers (GitHub Copilot's `call_id|id` // transport, any stream that delivers name/args before the id) emit the // block with an empty or partial id first and rewrite it in a later delta // (openai-completions sets `id: toolCall.id || ""` then overwrites on the // next chunk). Keyed only by id, the changed id reads as a brand-new call // and a second card is created — the old-id card orphans as a pending // preview while the new-id card takes the result (#6879). Reset per assistant // message (indices are per-message). #streamedToolCallIdByIndex = new Map(); // A TTSR rewind retracts its uncommitted never-run cards at message_end; // retain their ids until agent-loop's synthetic tool_execution_start/end for // those calls arrive so the normal no-pending path cannot recreate the // retracted card below the rewind's fresh blocks (#6879). #retractedToolCallIds = new Set(); #executionStartedCallIds = new Set(); // Settled tool cards from the turn that just ended, keyed by call id. A // repeated toolCallId in the next run can only be a tool-replay retry // (AgentSession.retry re-executing a stripped failed batch); // #handleToolExecutionStart evicts the stale aborted card so the fresh // execution replaces it in the transcript instead of stacking a duplicate. #priorTurnToolComponents = new Map(); // Cards settled by a synthetic aborted/error `tool_execution_end` (agent-loop // emits one per never-run call on a terminal error/abort). They stay visible // for a genuinely terminal failure, but if an auto-retry then supersedes the // turn (`auto_retry_start`, decided AFTER message_end) they must be removed so // the retry's fresh cards do not render the same call twice (Codex review on // #6881). Keyed by call id; reset per turn. #syntheticFailureCards = new Map(); // Completions can outrun their streamed tool-call block: server-resolved // tools may finish synchronously, and any sufficiently fast tool can settle // before the UI dispatch creates its card. Hold the result // so the eventual card finalizes instead of pinning transcript retirement. // User-facing side effects run only on first arrival; only the component // update is deferred. Call ids are unique per turn, and this map is cleared // with the other transcript anchors. #orphanedToolCompletions = new Map>(); #postToolAssistantComponents = new Map(); /** Reply blocks drawn per call from its frames' `details.replies`, in order; the count dedupes repeated frames. */ #toolReplyBlocks = new Map(); #lastAssistantComponent: AssistantMessageComponent | undefined = undefined; // Assistant component whose turn-ending error is currently mirrored in the // pinned banner. Its inline `Error: …` line is suppressed while pinned and // restored when the banner clears at the next `agent_start` (see // #handleMessageEnd / #handleAgentStart). #pinnedErrorComponent: AssistantMessageComponent | undefined = undefined; #pinnedErrorMessage: AssistantMessage | undefined = undefined; #restorePinnedErrorInline = true; #retrySupersededAssistantComponents = new Map(); #retrySupersededAssistantQueue: AssistantMessageComponent[] = []; // Set when `auto_retry_start` fires and cleared by `auto_retry_end` (both // outcomes) — true for exactly the window a retry is outstanding. Gates // `sendErrorNotification`: the wire-level `agent_end` for a retryable // failure is coalesced with every other attempt in the same saga while the // prompt is in flight (see `AgentSession#emitSessionEvent`), so the single // `agent_end` that survives to reach this controller can be either a // mid-retry blip or the final settle — only the retry lifecycle events // (never deferred) can tell them apart. #retryPending = false; /** Next-message prediction offered as composer ghost text after a completed turn. */ readonly composerPrediction: ComposerPredictionController; #ircExpiryTimers = new Map(); // Insertion-ordered IRC cards not yet retired; values are the transcript // components each card contributed (see #retireIrcCard for the guard). #liveIrcCards = new Map(); // Most recent `wait` tool block whose result still had every watched job // running. Kept un-finalized (live) so the next `wait` call displaces it — // one persistent poll instead of a stack of "waiting on N jobs" frames — // and sealed in place the moment anything else lands below it. #displaceablePollComponent: ToolExecutionComponent | undefined = undefined; // Most recent successful `todo` snapshot in the active turn. It stays live // across intervening tool output so a later `todo` update can replace the // old full list; the turn boundary seals the final snapshot as history. #displaceableTodoComponent: ToolExecutionComponent | undefined = undefined; // Most recent TTSR notification block. A new ttsr_triggered event merges its // rules into this block while it is still the (live-region) transcript tail. #lastTtsrNotification: PresentedBlock | undefined = undefined; #streamingReveal: StreamingRevealController; #toolArgsReveal: ToolArgsRevealController; #prevHideThinking = false; #handlers: AgentSessionEventHandlers; #terminalProgressActive = false; /** * Whether the running auto-compaction turned terminal progress on itself. * Only then does its end turn progress off: a compaction inside a live turn * leaves the turn's progress for that turn's `agent_end`, so Tern (which * reads progress as busy state) never sees the agent stop mid-run. */ #compactionOwnsProgress = false; /** Bumped at every `agent_start`; a settle watch stands down once a new run begins. */ #runEpoch = 0; /** Epoch of the in-flight {@link #finishWhenRunSettles} watch, if any. */ #settleWatchEpoch: number | undefined = undefined; /** * The settle that handed control back to background work only * (`awaitingAsyncWork`), until a new run starts or the run is torn down. * While set, the working row names the jobs instead of the last intent. */ #asyncWaitEnd: Extract | undefined = undefined; // Composer activity: a host run or idle maintenance (auto-compaction) owns // turn-scoped chrome, so the post-run chrome floor must not settle on it. #runActive = false; #maintenanceActive = false; // Coalescing window for `message_update` events at the subscription boundary. // `message_update` carries the CUMULATIVE assistant message (every update // re-lists all content blocks), so when a burst of deltas arrives faster than // this window only the latest snapshot needs to rebuild streaming state — the // intermediate rebuilds are redundant work. The TUI already caps the paint // rate via its own render cadence; this caps the per-token handler work that // feeds it. Speech stays intact: `#vocalizeDelta` runs at ARRIVAL for every // delta before the snapshot is coalesced away. #pendingMessageUpdate: Extract | undefined = undefined; // Provider replay resets are not cumulative: retain the boundary even when // the empty restart snapshot is replaced by a later delta before painting. #pendingMessageRestart = false; #messageUpdateTimer: NodeJS.Timeout | undefined = undefined; /** Tail of the serialized dispatch chain; see #runSerialized. */ #dispatchTail: Promise = Promise.resolve(); /** Whether a chained run is currently in flight (awaiting its own awaits). */ #dispatchInFlight = false; #dispatchGeneration = 0; // Deltas already fed to speech at arrival by the coalescer. `#handleMessageUpdate` // also vocalizes so the direct `handleEvent` path (tests, session focus replay) // keeps working — the WeakSet makes the coalesced path speak each delta exactly // once instead of twice. #vocalizedMessageUpdates = new WeakSet(); static readonly #MESSAGE_UPDATE_COALESCE_MS = 33; constructor(private ctx: InteractiveModeContext) { // Enhanced speech (`speech.enhanced`) rewrites blocks through the // tiny/smol role with this session's registry and credentials; the // vocalizer falls back to mechanical cleanup when unset. Tolerates // partial contexts (tests, minimal embeddings) by wiring null. const session = ctx.session; this.#detachToolApprovalPreviewWaiter = session?.extensionRunner?.setToolApprovalPreviewWaiter(toolCallId => this.#waitForToolApprovalPreview(toolCallId), ); vocalizer.setEnhancer( session?.modelRegistry && session.agent && session.settings ? new SpeechEnhancer({ settings: session.settings, registry: session.modelRegistry, sessionId: session.sessionId, metadataResolver: provider => session.agent.metadataForProvider(provider), }) : null, ); this.#streamingReveal = new StreamingRevealController({ // Extension text renderers get the full text so far on each drawn update (deltas within one // frame are coalesced), never a partly revealed prefix from the typing reveal. A terminal that // paces streamed `md` itself (Tern) gets the text as it arrives: pacing it here too is wasted work. getSmoothStreaming: () => cfgDisplaySmoothStreaming.get(this.ctx.settings) && !nativePacing() && !this.ctx.viewSession.extensionRunner?.getAssistantTextRenderers?.().length, getHideThinkingBlock: () => this.ctx.effectiveHideThinkingBlock, getProseOnlyThinking: () => this.ctx.proseOnlyThinking, requestRender: component => this.ctx.ui.requestComponentRender(component), }); this.#toolArgsReveal = new ToolArgsRevealController({ getSmoothStreaming: () => cfgDisplaySmoothStreaming.get(this.ctx.settings), requestRender: component => this.ctx.ui.requestComponentRender(component), }); this.composerPrediction = new ComposerPredictionController(ctx); this.#handlers = { agent_start: e => this.#handleAgentStart(e), agent_end: e => this.#handleAgentEnd(e), turn_start: async () => {}, turn_end: async e => this.#handleTurnEnd(e), message_start: e => this.#handleMessageStart(e), message_update: e => this.#handleMessageUpdate(e), message_end: e => this.#handleMessageEnd(e), tool_execution_start: e => this.#handleToolExecutionStart(e), tool_execution_update: e => this.#handleToolExecutionUpdate(e), tool_stream_update: async event => { const component = this.ctx.pendingTools.get(event.toolCallId); if (component?.updateStreamPreview) { component.updateStreamPreview(event.update); } else { this.#pendingStreamPreviews.set(event.toolCallId, event.update); } }, tool_execution_end: e => this.#handleToolExecutionEnd(e), auto_compaction_start: e => this.#handleAutoCompactionStart(e), auto_compaction_end: e => this.#handleAutoCompactionEnd(e), // `/compact` drives its own loader (command-controller); these exist for RPC hosts. manual_compaction_start: async () => {}, manual_compaction_end: async () => {}, auto_retry_start: e => this.#handleAutoRetryStart(e), auto_retry_end: e => this.#handleAutoRetryEnd(e), // The cache details show a refresh in flight; its hit moves the entry's expiry, a miss turns it cold. cache_warming_start: async () => { this.ctx.statusLine.invalidate(); this.ctx.ui.requestRender(); }, cache_warming_end: async () => { this.ctx.statusLine.invalidate(); this.ctx.ui.requestRender(); }, retry_fallback_applied: e => this.#handleRetryFallbackApplied(e), retry_fallback_succeeded: e => this.#handleRetryFallbackSucceeded(e), ttsr_triggered: e => this.#handleTtsrTriggered(e), todo_reminder: e => this.#handleTodoReminder(e), todo_auto_clear: e => this.#handleTodoAutoClear(e), irc_message: e => this.#handleIrcMessage(e), notice: e => this.#handleNotice(e), model_changed: async () => { this.ctx.statusLine.invalidate(); this.ctx.ui.requestRender(); }, // Header rebuild is owned by InteractiveMode's own session // subscription (mirrors model_changed); nothing to do here. config_warnings_changed: async () => {}, advisor_cost_changed: async () => { this.ctx.statusLine.invalidate(); this.ctx.ui.requestRender(); }, advisor_yielded: async () => { // The advisor finished reviewing the yielded primary turn (no more // comments coming) — repaint so the closed-eye state lands. this.ctx.statusLine.invalidate(); this.ctx.ui.requestRender(); }, thinking_level_changed: async () => { this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); const hideThinking = this.ctx.effectiveHideThinkingBlock; // Only do the expensive full resetDisplay when the effective // visibility actually changed. Auto-classification (e.g. high→medium) // emits thinking_level_changed without changing visibility — a full // terminal replay for those would be disruptive. if (hideThinking === this.#prevHideThinking) { this.ctx.ui.requestRender(); return; } this.#prevHideThinking = hideThinking; // Propagate visibility to existing rendered messages. for (const child of this.ctx.chatContainer.children) { if (child instanceof AssistantMessageComponent) { child.setHideThinkingBlock(hideThinking); } } if (this.ctx.streamingComponent && this.ctx.streamingMessage) { this.ctx.streamingComponent.setHideThinkingBlock(hideThinking); this.#streamingReveal.resyncVisibility(); } this.ctx.ui.requestRender(true); }, goal_updated: async () => {}, // The pending-messages bar mirrors the queue snapshot this event carries. // Refreshing only at the TUI's own call sites left pills of messages the // agent drained by itself (live steering, a queued batch it delivered) // standing above the prompt after they were sent. queue_update: async () => { this.ctx.updatePendingMessagesDisplay(); }, } satisfies AgentSessionEventHandlers; } /** Surface an idle recap reply in the transcript as a one-line {@link RecapNotice}; a blank reply mounts nothing. */ presentIdleRecap(reply: string): void { const recap = previewLine(reply, TRUNCATE_LENGTHS.RECAP); if (recap) this.ctx.present(new RecapNotice(recap)); } dispose(): void { this.#detachToolApprovalPreviewWaiter?.(); this.#detachToolApprovalPreviewWaiter = undefined; this.#clearApprovalPreviewGates(); if (this.#messageUpdateTimer) { clearTimeout(this.#messageUpdateTimer); this.#messageUpdateTimer = undefined; } this.#pendingMessageUpdate = undefined; this.#pendingMessageRestart = false; this.#streamingReveal.stop(); this.#toolArgsReveal.stop(); this.composerPrediction.cancel(); this.#setTerminalProgress(false); for (const timer of this.#ircExpiryTimers.values()) { clearTimeout(timer); } this.#ircExpiryTimers.clear(); this.#liveIrcCards.clear(); } #resetReadGroup(): void { this.#lastReadGroup?.finalize(); this.#lastReadGroup = undefined; } /** Freeze foreground tool cards once no live agent turn can complete them. */ #sealAbandonedForegroundTools(): void { const background = new Set(); for (const toolCallId of this.#backgroundCallIds) { const component = this.ctx.pendingTools.get(toolCallId); if (component) background.add(component); } for (const component of this.#toolTimelineComponents.values()) { if ( (component instanceof ToolExecutionComponent || component instanceof ReadToolGroupComponent) && !background.has(component) ) { component.seal(); } } for (const [toolCallId, component] of this.ctx.pendingTools) { if (this.#backgroundCallIds.has(toolCallId)) continue; component.seal(); this.ctx.pendingTools.delete(toolCallId); } this.#backgroundCallIds = new Set( Array.from(this.#backgroundCallIds).filter(toolCallId => this.ctx.pendingTools.has(toolCallId)), ); this.#finalizeAbandonedPostToolSegments(); } /** * Finalize post-tool assistant segments whose `message_end` never fired (a * mid-stream throw or dropped agent_end). They are created unfinalized at * `message_update` and finalized only at `message_end`, so a dropped end * leaves one permanently active block — and one unfinalized block at the * transcript frontier blocks history retirement, degrading every later * block to its one-line live allocation. Idempotent for settled segments. */ #finalizeAbandonedPostToolSegments(): void { for (const component of this.#postToolAssistantComponents.values()) { component.markTranscriptBlockFinalized(); } } /** * Reply blocks of calls still executing. Their results are not in the session's committed * messages yet, so a transcript rebuild must carry these over itself; replies of finished * calls are replayed from the persisted results. */ liveToolReplies(): Component[] { const live: Component[] = []; for (const toolCallId of this.#executionStartedCallIds) { const blocks = this.#toolReplyBlocks.get(toolCallId); if (blocks) live.push(...blocks); } return live; } /** Draw the replies of a call's frame (`details.replies`, see {@link toolResultReplies}) it has not drawn yet. */ #showToolReplies(toolCallId: string, details: unknown): void { const replies = toolResultReplies(details); const shown = this.#toolReplyBlocks.get(toolCallId) ?? []; if (replies.length <= shown.length) return; for (const text of replies.slice(shown.length)) { const block = createAssistantMessageComponent(this.ctx, replyDisplayMessage(text)); this.ctx.chatContainer.addChild(block); shown.push(block); } this.#toolReplyBlocks.set(toolCallId, shown); this.ctx.ui.requestRender(); } /** * Post-tool text segments of the reply still streaming. The reply is not in the session's * committed messages until `message_end`, so a transcript rebuild must carry these over itself; * segments of replies already committed are replayed instead. */ liveAssistantSegments(): AssistantMessageComponent[] { const live = this.ctx.viewSession.agent.state.streamMessage; if (live?.role !== "assistant") return []; const segments: AssistantMessageComponent[] = []; for (const block of live.content) { if (block.type !== "toolCall") continue; const segment = this.#postToolAssistantComponents.get(block.id); if (segment) segments.push(segment); } return segments; } /** * Hand the streaming reply's components the current extension text renderers (the setting enabling * them changed). Renderers take the full text, so a running typing reveal is dropped first. */ applyAssistantTextRenderers(): void { this.#streamingReveal.resyncSmoothStreaming(); const renderers = this.ctx.viewSession.extensionRunner?.getAssistantTextRenderers?.() ?? []; this.ctx.streamingComponent?.setTextRenderers(renderers); for (const segment of this.liveAssistantSegments()) segment.setTextRenderers(renderers); } #approvalPreviewGate(toolCallId: string): ApprovalPreviewGate { let gate = this.#approvalPreviewGates.get(toolCallId); if (!gate) { const deferred = Promise.withResolvers(); gate = { ...deferred, started: false }; this.#approvalPreviewGates.set(toolCallId, gate); } return gate; } async #waitForToolApprovalPreview(toolCallId: string): Promise { await this.#approvalPreviewGate(toolCallId).promise; } #startToolApprovalPreview(toolCallId: string): void { const gate = this.#approvalPreviewGate(toolCallId); if (gate.started) return; gate.started = true; const component = this.ctx.pendingTools.get(toolCallId); const ready = component instanceof ToolExecutionComponent ? component.whenPreviewSettled() : Promise.resolve(); void ready.then(() => { this.ctx.ui.requestRender(); gate.resolve(); }, gate.reject); } #clearApprovalPreviewGates(): void { for (const gate of this.#approvalPreviewGates.values()) gate.resolve(); this.#approvalPreviewGates.clear(); } #getReadGroup(): ReadToolGroupComponent { if (!this.#lastReadGroup) { const group = new ReadToolGroupComponent({ showContentPreview: cfgReadToolResultPreview.get(this.ctx.settings), }); group.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(group); this.#lastReadGroup = group; } return this.#lastReadGroup; } #trackReadToolCall(toolCallId: string, args: unknown): void { if (!toolCallId) return; const normalizedArgs = args && typeof args === "object" && !Array.isArray(args) ? (args as Record) : {}; this.#readToolCallArgs.set(toolCallId, normalizedArgs); const assistantComponent = this.ctx.streamingComponent ?? this.#lastAssistantComponent; if (assistantComponent) { this.#readToolCallAssistantComponents.set(toolCallId, assistantComponent); } } inheritReadToolAssistant(toolCallId: string, component: AssistantMessageComponent | undefined): void { if (component) this.#readToolCallAssistantComponents.set(toolCallId, component); else this.#readToolCallAssistantComponents.delete(toolCallId); } #clearReadToolCall(toolCallId: string): void { this.#readToolCallArgs.delete(toolCallId); this.#readToolCallAssistantComponents.delete(toolCallId); } #retractToolCardEntry(toolCallId: string, component: ToolExecutionHandle): void { let removeComponent = true; if (component instanceof ReadToolGroupComponent) { removeComponent = component.removeEntry(toolCallId); if (component === this.#lastReadGroup) this.#resetReadGroup(); } if (removeComponent) { component.seal(); this.ctx.chatContainer.removeChild(component); } this.ctx.pendingTools.delete(toolCallId); this.#toolTimelineComponents.delete(toolCallId); this.#clearReadToolCall(toolCallId); } /** * Re-key a live streamed tool card whose id changed mid-stream (see * {@link #streamedToolCallIdByIndex}). Moves every id-keyed tracker from the * old id to the new one so the next cumulative `message_update` reuses the * existing card instead of creating a duplicate (#6879). Routing is via * `pendingTools`; the card (or the shared read group's entry) takes the new * id too, which is what transcript presenters see and refresh by. */ #migrateStreamedToolCallId(oldId: string, newId: string): void { // `oldId` may be "" (the block streamed before its id): that empty key still // owns a live card and must migrate. Skip only a no-op or an empty target. if (oldId === newId || !newId) return; const pending = this.ctx.pendingTools.get(oldId); if (pending && !this.ctx.pendingTools.has(newId)) { this.ctx.pendingTools.delete(oldId); this.ctx.pendingTools.set(newId, pending); } const timeline = this.#toolTimelineComponents.get(oldId); if (timeline && !this.#toolTimelineComponents.has(newId)) { this.#toolTimelineComponents.delete(oldId); this.#toolTimelineComponents.set(newId, timeline); } // A held stream preview is id-keyed and consumed under the card's final id // (message_update creation / tool_execution_start); move it with the card // or the lookup under `newId` misses and the preview is lost. const preview = this.#pendingStreamPreviews.get(oldId); if (preview !== undefined && !this.#pendingStreamPreviews.has(newId)) { this.#pendingStreamPreviews.delete(oldId); this.#pendingStreamPreviews.set(newId, preview); } // The reveal controller is id-keyed; drop the stale target so the loop's // setTarget/bind under the new id owns the paced reveal. this.#toolArgsReveal.finish(oldId); if (this.#executionStartedCallIds.delete(oldId)) { this.#executionStartedCallIds.add(newId); } const readArgs = this.#readToolCallArgs.get(oldId); if (readArgs !== undefined) { this.#readToolCallArgs.delete(oldId); this.#readToolCallArgs.set(newId, readArgs); } const readAssistant = this.#readToolCallAssistantComponents.get(oldId); if (readAssistant !== undefined) { this.#readToolCallAssistantComponents.delete(oldId); this.#readToolCallAssistantComponents.set(newId, readAssistant); } // A collapsed read renders into a shared group keyed by id; rename its // entry so the row isn't duplicated under the new id. if (pending instanceof ReadToolGroupComponent) pending.renameEntry(oldId, newId); // A card now owning `newId` hands presenters (and refreshTranscript) that id. else if (pending instanceof ToolExecutionComponent && this.ctx.pendingTools.get(newId) === pending) { pending.renameToolCallId(newId); } // A fast completion can land under `newId` while the card is still keyed // by `oldId`. Consume it now that the card owns the final id; the normal // creation path is skipped on a re-key (Codex review on #6881). if (pending) this.#settleHeldCompletionIfPresent(newId, pending); } /** * Reconcile every tool-call block's live card key with the id its content * block currently carries (see {@link #streamedToolCallIdByIndex}), moving * the id-keyed card state via {@link #migrateStreamedToolCallId}. A streamed * id can change across cumulative `message_update`s (#6879), and the final * snapshot can carry a per-index id change no delta ever showed — agent-loop * mints and re-keys tool-call ids at `done`, after the last * `message_update` — so the `message_end` path replays the same * reconciliation the delta path runs. Without it the pre-mint card is never * re-keyed: `tool_execution_start` mounts a second card under the minted id * and the streamed one ghosts until sealed. * * Several indices can share one streamed id (a reused provider id, or * siblings that all streamed before their ids materialized) and then split * into distinct ids. The shared card migrates only when the old id is no * longer referenced by any content index — the last referencer keeps the * card; the other indices pick up their own card at `tool_execution_start`. * Migrating unconditionally would move the one shared card to the first * re-keyed sibling and leave the index that kept the old id with no streamed * card (a reverse ghost). */ #reconcileStreamedToolCallIds(content: AssistantMessage["content"]): void { const finalIdByIndex = new Map(); for (let contentIndex = 0; contentIndex < content.length; contentIndex++) { const block = content[contentIndex]!; if (block.type === "toolCall") finalIdByIndex.set(contentIndex, block.id); } for (const [contentIndex, id] of finalIdByIndex) { const priorId = this.#streamedToolCallIdByIndex.get(contentIndex); if (priorId === undefined || priorId === id) { this.#streamedToolCallIdByIndex.set(contentIndex, id); continue; } const oldIdStaysOwned = [...finalIdByIndex].some(([otherIndex, otherId]) => { if (otherIndex === contentIndex) return false; // The other index either already resolved to the old id or still // streams under it and has not been re-keyed yet. return otherId === priorId || this.#streamedToolCallIdByIndex.get(otherIndex) === priorId; }); if (oldIdStaysOwned) { // The shared card stays under `priorId` for its remaining // referencer; make it show THAT call's arguments, not the last // cumulative update's (every sibling updated the one shared card // while the id was still shared). const card = this.ctx.pendingTools.get(priorId); for (const [keeperIndex, keeperId] of finalIdByIndex) { if (keeperId !== priorId || keeperIndex === contentIndex) continue; const keeper = content[keeperIndex]; if (card && keeper?.type === "toolCall") card.updateArgs(keeper.arguments, priorId); } } else { this.#migrateStreamedToolCallId(priorId, id); } this.#streamedToolCallIdByIndex.set(contentIndex, id); } } #inlineReadToolImages( toolCallId: string, result: { content: Array<{ type: string; data?: string; mimeType?: string }>; details?: unknown }, ): boolean { const assistantComponent = this.#readToolCallAssistantComponents.get(toolCallId); if (!assistantComponent) return false; const images: ImageContent[] = result.content .filter( (content): content is ImageContent => content.type === "image" && typeof content.data === "string" && typeof content.mimeType === "string", ) .map(content => ({ type: "image", data: content.data, mimeType: content.mimeType })); const mediaPath = readMediaPath(result.details); if (images.length === 0 && !mediaPath) return false; assistantComponent.setToolResultImages(toolCallId, images, mediaPath); // A media-only result (a model, a PDF) still settles through the read group. return images.length > 0 && cfgTerminalShowImages.get(settings); } #insertAfterTranscriptComponent(anchor: Component | undefined, component: Component): boolean { const children = this.ctx.chatContainer.children; const anchorIndex = anchor ? children.indexOf(anchor) : -1; if (anchorIndex < 0) return false; if (children.slice(anchorIndex + 1).some(child => !this.ctx.chatContainer.canRemoveBlock(child))) { return false; } this.ctx.chatContainer.insertChildAt(component, anchorIndex + 1); return true; } #upsertPostToolAssistantSegment( toolCallId: string, segment: AssistantMessage | undefined, contentOffset: number | undefined, linkTargets?: ReadonlyMap, opts?: { transient?: boolean }, ): AssistantMessageComponent | undefined { if (!segment || !assistantHasVisibleContent(segment)) return undefined; const existing = this.#postToolAssistantComponents.get(toolCallId); if (existing) { if (linkTargets) existing.setLinkTargets(linkTargets); // Closed segments may already be committed to append-only terminal history. if (existing.isTranscriptBlockFinalized()) return existing; existing.updateContent(segment, opts); return existing; } const component = createAssistantMessageComponent(this.ctx, undefined, linkTargets, contentOffset); component.updateContent(segment, opts); this.#postToolAssistantComponents.set(toolCallId, component); if (!this.#insertAfterTranscriptComponent(this.#toolTimelineComponents.get(toolCallId), component)) { this.ctx.chatContainer.addChild(component); } return component; } #updateWorkingMessageFromIntent(intent: unknown): void { if (this.ctx.session.isAborting) return; const trimmed = normalizeIntent(intent); if (!trimmed || trimmed === this.#lastIntent) return; this.#lastIntent = trimmed; this.ctx.setWorkingMessage(trimmed); } subscribeToAgent(): void { // Serialize non-update dispatch behind any in-flight handler run: // AgentSession.#emit fires listeners fire-and-forget (it does not await // listener promises), so without this a rapid stream tail // (message_update → message_end → agent_end) could let a later callback // overtake the coalesced flush's handler mid-await — agent_end removing // `streamingComponent` before #handleMessageEnd finalizes and records // the final message (issue #7443 follow-up). When the tail has settled, // dispatch stays synchronous: the flush's streaming rebuild runs before // the listener's first await, preserving the timing the coalescing // tests assert on. `message_update` enqueue is itself synchronous and // needs no serialization. this.ctx.unsubscribe = this.ctx.session.subscribe(event => this.dispatchSessionEvent(event)); } /** * Route one session event through the same pipeline as the live * subscription: coalesce the cumulative `message_update` deltas of a * streaming turn into at most one handler run per window, and serialize * every other event behind any in-flight run. * * `#handleMessageUpdate` is synchronous, so without coalescing every token * re-runs the whole streaming rebuild (splitAssistantMessageToolTimeline, * reveal setTarget, per-block tool-call reconciliation) even though the TUI * paints at most ~30fps — at 40-100 tps the handler work then dominates the * CPU profile of an idle-looking streaming session (issue #7443). Only the * latest snapshot is meaningful; non-update events flush the pending * snapshot first so ordering is preserved. */ async dispatchSessionEvent(event: AgentSessionEvent): Promise { if (event.type === "message_update") { this.#enqueueMessageUpdate(event); return; } await this.runSerialized(async () => { await this.handleEvent(event); }); } /** * Run `run` in the serialized session-event dispatch pipeline, flushing any * pending coalesced `message_update` first. This does not drop work when the * viewed session changes: a caller that queues across that change must skip * the stale work itself. */ async runSerialized(run: () => Promise): Promise { await this.#runSerialized(async () => { await this.#flushPendingMessageUpdate(); await run(); }); } /** * Run `run` in the serialized dispatch chain: every run is its own link on * the tail, so a burst of events queued behind an in-flight run start one * after the other, never concurrently. This closes two races (issue #7443 * follow-up): a rapid stream tail (message_update → message_end → * agent_end) cannot overtake the coalesced flush mid-await — agent_end * removing `streamingComponent` before #handleMessageEnd finalizes and * records the final message — and two+ events landing in the same window * cannot all resume from one shared await and dispatch in parallel. When * the chain is drained, `run` starts synchronously (no intermediate * microtask), preserving the synchronous-flush timing the coalescing * tests assert on. A rejection propagates to the caller (the session's * fire-and-forget emit) and the next event starts a fresh chain link * instead of being dropped. */ async #runSerialized(run: () => Promise): Promise { if (this.#dispatchInFlight) { // Queue behind the CURRENT tail: the next run starts only after // the previous one settles. Each waiter gets its own link, so a // burst cannot fan out from the same shared await. const link = this.#dispatchTail.then( () => run(), () => run(), ); this.#dispatchTail = link; void link.then( () => { // Only the tail owner clears the flag: a later chained // link clears it when it settles as the tail. if (this.#dispatchTail === link) this.#dispatchInFlight = false; }, () => { if (this.#dispatchTail === link) this.#dispatchInFlight = false; }, ); await link; return; } this.#dispatchInFlight = true; const link = run(); this.#dispatchTail = link; void link.then( () => { if (this.#dispatchTail === link) this.#dispatchInFlight = false; }, () => { if (this.#dispatchTail === link) this.#dispatchInFlight = false; }, ); await link; } /** * Queue a streaming `message_update` for the next coalesced handler run. * Speech is per-delta, so the delta is vocalized at arrival before the * snapshot is (possibly) superseded by a newer one. */ #enqueueMessageUpdate(event: Extract): void { // Speech is per-delta: every delta is spoken at arrival even when its // cumulative snapshot is later superseded and never rebuilt. this.#vocalizeDelta(event); this.#vocalizedMessageUpdates.add(event); this.#pendingMessageUpdate = event; this.#pendingMessageRestart ||= event.assistantMessageEvent.type === "start"; if (this.#messageUpdateTimer) return; this.#messageUpdateTimer = setTimeout(() => { this.#messageUpdateTimer = undefined; // Mirror AgentSession.#emit: attach a catch so a streaming rebuild // failure surfaces as a logged warning instead of a process-level // unhandled rejection (the timer path has no listener to attach one). // Runs inside the serialized dispatch chain so a message_end / // agent_end landing mid-window cannot overtake this flush (issue // #7443 follow-up). void this.#runSerialized(async () => { await this.#flushPendingMessageUpdate(); }).catch(err => { logger.warn("Message update flush rejected", { error: err instanceof Error ? err.message : String(err), }); }); }, EventController.#MESSAGE_UPDATE_COALESCE_MS); } /** * Run the coalesced `message_update` handler on the latest pending snapshot * (dropping any superseded intermediates) and clear the queue. Safe to call * more than once; no-ops when nothing is pending. */ async #flushPendingMessageUpdate(): Promise { if (this.#messageUpdateTimer) { clearTimeout(this.#messageUpdateTimer); this.#messageUpdateTimer = undefined; } const event = this.#pendingMessageUpdate; if (!event) return; this.#pendingMessageUpdate = undefined; const restarted = this.#pendingMessageRestart; this.#pendingMessageRestart = false; if (restarted && event.assistantMessageEvent.type !== "start") { await this.#restartAssistantStream(event.message); } await this.handleEvent(event); } /** Whether `#handleToolExecutionStart` has fired for this call id this turn. */ hasToolExecutionStarted(toolCallId: string): boolean { return this.#executionStartedCallIds.has(toolCallId); } /** The viewed session was swapped for an idle one: no run owns the chrome any more. */ markViewIdle(): void { this.#runActive = false; this.#maintenanceActive = false; this.#syncComposerActivity(); } #syncComposerActivity(): void { this.ctx.composer.setRunActive(this.#runActive || this.#maintenanceActive); } /** * Clear every transcript-anchored/turn-scoped piece of state. Used by the * session focus proxy when re-pointing the transcript at another session: * components, timers, and stream-reveal state all reference the previous * session's transcript and must not bleed into the new one. */ resetTranscriptAnchors(): void { if (this.#messageUpdateTimer) { clearTimeout(this.#messageUpdateTimer); this.#messageUpdateTimer = undefined; } this.#pendingMessageUpdate = undefined; this.#pendingMessageRestart = false; this.#dispatchGeneration++; this.#resetReadGroup(); this.#lastVisibleBlockCount = 0; this.#renderedCustomMessages.clear(); this.#lastIntent = undefined; this.#toolTimelineComponents.clear(); this.#streamedToolCallIdByIndex.clear(); this.#pendingStreamPreviews.clear(); this.#retractedToolCallIds.clear(); this.#executionStartedCallIds.clear(); this.#syntheticFailureCards.clear(); this.#orphanedToolCompletions.clear(); this.#postToolAssistantComponents.clear(); this.#toolReplyBlocks.clear(); this.#backgroundCallIds.clear(); this.#settledBeforeReturn.clear(); this.#blockingPrompts.clear(); this.#readToolCallArgs.clear(); this.#readToolCallAssistantComponents.clear(); this.#lastAssistantComponent = undefined; this.#turnStartedAt = undefined; this.#turnUsage.reset(); this.#lastAgentEndAt = undefined; this.#pinnedErrorComponent = undefined; this.#pinnedErrorMessage = undefined; this.#restorePinnedErrorInline = true; this.#retryPending = this.ctx.viewSession.isRetrying; this.composerPrediction.cancel(); for (const timer of this.#ircExpiryTimers.values()) { clearTimeout(timer); } this.#ircExpiryTimers.clear(); this.#liveIrcCards.clear(); this.#displaceablePollComponent = undefined; this.#displaceableTodoComponent = undefined; this.#lastTtsrNotification = undefined; this.#streamingReveal.stop(); this.#toolArgsReveal.stop(); this.#seedHeldCompletionsFromPendingResults(); } /** Restore display state without replaying completion notifications or persistence. */ #seedHeldCompletionsFromPendingResults(): void { const pending = this.ctx.viewSession.agent.getPendingToolResults(); for (const result of pending) { if (this.#toolTimelineComponents.has(result.toolCallId) && !this.ctx.pendingTools.has(result.toolCallId)) { continue; } this.#orphanedToolCompletions.set(result.toolCallId, { type: "tool_execution_end", toolCallId: result.toolCallId, toolName: result.toolName, result: { content: result.content, details: result.details }, isError: result.isError, }); } } /** Settle replay-created cards without waiting for another streaming update. */ restorePendingToolResults(): void { this.#seedHeldCompletionsFromPendingResults(); for (const [toolCallId, component] of this.ctx.pendingTools) { this.#toolTimelineComponents.set(toolCallId, component); this.#settleHeldCompletionIfPresent(toolCallId, component); } } async handleEvent(event: AgentSessionEvent): Promise { if (!this.ctx.isInitialized) { await this.ctx.init(); } // Each handler explicitly requests a render (or leaves it out, when it // changed nothing visible). A blanket pre-render fired on every event — // including the ~hundreds of `message_update` deltas per streaming turn — // doubled the paint rate: the pre-render's frame fires while the handler // is awaiting, then the handler's own final requestRender schedules a // second identical frame. Removing it lets the render cadence follow real // state changes rather than event volume (issue #4353). const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise; await run(event); } #setTerminalProgress(active: boolean): void { if (active) { // Tern reads the progress as the pane's busy state (its tab's live // dot), so it gets it whatever the setting says. Tern's panes carry // `KITTY_WINDOW_ID`, so `TERMINAL.id` says kitty there. const wanted = Bun.env.TERM_PROGRAM?.toLowerCase() === "tern" || (this.ctx.settings ? cfgTerminalShowProgress.get(this.ctx.settings) : undefined) === true; if (this.#terminalProgressActive || !wanted) return; this.ctx.ui.terminal.setProgress(true); this.#terminalProgressActive = true; return; } if (!this.#terminalProgressActive) return; this.ctx.ui.terminal.setProgress(false); this.#terminalProgressActive = false; } #trackRetrySupersededAssistantComponent(component: AssistantMessageComponent | undefined): void { if (!component) return; const persistenceKey = component.messagePersistenceKey(); if (persistenceKey) this.#retrySupersededAssistantComponents.set(persistenceKey, component); if (!this.#retrySupersededAssistantQueue.includes(component)) { this.#retrySupersededAssistantQueue.push(component); } } #takeRetrySupersededAssistantComponent(persistenceKey: string | undefined): AssistantMessageComponent | undefined { if (persistenceKey) { const component = this.#retrySupersededAssistantComponents.get(persistenceKey); if (component) { this.#retrySupersededAssistantComponents.delete(persistenceKey); this.#retrySupersededAssistantQueue = this.#retrySupersededAssistantQueue.filter( item => item !== component, ); return component; } } while (this.#retrySupersededAssistantQueue.length > 0) { const component = this.#retrySupersededAssistantQueue.shift(); if (!component) continue; const key = component.messagePersistenceKey(); if (key && this.#retrySupersededAssistantComponents.get(key) !== component) continue; if (key) this.#retrySupersededAssistantComponents.delete(key); return component; } return undefined; } #clearRetrySupersededAssistantComponents(): void { this.#retrySupersededAssistantComponents.clear(); this.#retrySupersededAssistantQueue = []; } async #handleAgentStart(_event: Extract): Promise { this.#runEpoch += 1; // A run with no user prompt in it (synthetic-only: `/goal` kickoff, // approved-plan execution) must not measure prompt→yield from an unrelated // earlier prompt. Normal user turns reseed via message_start before // agent_start; retries of the same turn keep the anchor because it still // postdates the last completed run. if ( this.#turnStartedAt !== undefined && this.#lastAgentEndAt !== undefined && this.#turnStartedAt < this.#lastAgentEndAt ) { this.#turnStartedAt = undefined; } this.#clearApprovalPreviewGates(); // A new turn cannot inherit a foreground tool execution. A dropped // agent_end (for example after a renderer exception) otherwise leaves a // live-only tool card without a persisted result; ordered transcript // retirement then remains pinned behind that invisible stale card. this.#sealAbandonedForegroundTools(); this.#toolTimelineComponents.clear(); this.#streamedToolCallIdByIndex.clear(); this.#pendingStreamPreviews.clear(); this.#retractedToolCallIds.clear(); this.#executionStartedCallIds.clear(); this.#syntheticFailureCards.clear(); this.#orphanedToolCompletions.clear(); this.#postToolAssistantComponents.clear(); this.#toolReplyBlocks.clear(); this.#lastIntent = undefined; // A background result woke the run: the row stops naming the wait and // follows this run's intents again. if (this.#asyncWaitEnd) { this.#asyncWaitEnd = undefined; this.ctx.setWorkingMessage(); } this.#readToolCallArgs.clear(); this.#readToolCallAssistantComponents.clear(); this.#resetReadGroup(); this.#seedHeldCompletionsFromPendingResults(); this.#resolveDisplaceableTodo(); this.#lastAssistantComponent = undefined; // Restore terminal errors in transcript history when their banner clears. // Recoverable empty-output attempts are discarded by session recovery and // must stay hidden rather than resurfacing as a stale inline error. if (this.#restorePinnedErrorInline) this.#pinnedErrorComponent?.setErrorPinned(false); this.#pinnedErrorComponent = undefined; this.#pinnedErrorMessage = undefined; this.#restorePinnedErrorInline = true; this.ctx.clearPinnedError(); if (this.ctx.retryLoader) { this.ctx.retryLoader.stop(); this.ctx.retryLoader = undefined; this.ctx.statusContainer.disposeChildren(); } this.composerPrediction.cancel(); this.ctx.statusLine.markActivityStart(); // The turn owns progress from here; a compaction that started it hands it over. this.#compactionOwnsProgress = false; this.#setTerminalProgress(true); this.ctx.ensureLoadingAnimation(); this.#runActive = true; this.#syncComposerActivity(); setRunStatus({ state: "working" }); this.ctx.ui.requestRender(); } async #handleMessageStart(event: Extract): Promise { this.#ensureWorkingLoaderWhileStreaming(); if (event.message.role === "hookMessage" || event.message.role === "custom") { if (event.message.role === "custom" && !this.ctx.initialChatRendered && !this.ctx.viewSession.isStreaming) { // Idle custom append while no transcript render has committed (e.g. a startup // display:true sendMessage before renderInitialMessages): AgentSession persists the // entry before emitting this event, so the replay owns the paint — a render not yet // started reads it from session entries, and one in progress sees the entry count // change and restarts. Painting live too would duplicate it, since // renderInitialMessages({ preserveExistingChat: true }) re-appends existing chat // children. Mirrors ExtensionUiController.#applyCustomMessageDisplay's gate. // Streaming is excluded so live-turn rendering is unchanged. return; } const signature = `${event.message.role}:${event.message.customType}:${event.message.timestamp}`; if (this.#renderedCustomMessages.has(signature)) { return; } this.#renderedCustomMessages.add(signature); this.#resetReadGroup(); // A directly-invoked `/skill:` or writable-collab custom prompt is the // run's initiating message (user attribution): seed the prompt→yield // delta from it, the same as a user message. if (event.message.role === "custom" && isUserTurnInitiator(event.message)) { this.#turnStartedAt = event.message.timestamp; this.#turnUsage.reset(); } const taskJobToast = isNativeRendering() ? nativeTaskJobToast(event.message) : undefined; if ( event.message.role === "custom" && this.ctx.optimisticSkillMessagePending && isUserInvokedSkillPrompt(event.message) ) { // The optimistic `/skill:` row painted at submit time (issue #8895): // swap it for the canonical message instead of appending a duplicate. this.ctx.reconcileOptimisticSkillMessage(event.message); } else if (taskJobToast) { // Native: the task call's `agent` nodes already settled in place // (its terminal async update); a toast replaces the appended // "Background job completed" rows. this.ctx.showStatus(taskJobToast); } else { this.ctx.addMessageToChat(event.message); } // Queued custom-message chips are derived from the agent queue; refresh the // pending bar when the queued custom is consumed so the chip disappears // immediately. if (event.message.role === "custom" && readQueueChipText(event.message.details)) { this.ctx.updatePendingMessagesDisplay(); } this.ctx.ui.requestRender(); } else if (event.message.role === "user") { vocalizer.clear(); // Only genuinely user-attributed prompts anchor the delta; a mid-run // agent-attributed `user` message (advisor tool-loop redirect) must not. if (event.message.attribution !== "agent") { this.#turnStartedAt = event.message.timestamp; this.#turnUsage.reset(); } const userText = textContent(event.message.content); const imageBlocks = typeof event.message.content === "string" ? [] : event.message.content.filter( (content): content is ImageContent => content.type === "image" && typeof content.data === "string" && typeof content.mimeType === "string", ); const imageCount = imageBlocks.length; const signature = `${userText}\u0000${imageCount}`; this.#resetReadGroup(); this.#resolveDisplaceablePoll(); this.#resolveDisplaceableTodo(); const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature; const matchedLocalSubmission = this.ctx.locallySubmittedUserSignatures.delete(signature); const replacesOptimistic = this.ctx.optimisticUserMessageSignature !== undefined && !wasOptimistic && !matchedLocalSubmission; if (wasOptimistic) { this.ctx.clearOptimisticUserMessage(); } else if (replacesOptimistic) { this.ctx.replaceOptimisticUserMessage(event.message); } else { // Append synchronously: #emit dispatches to this listener fire-and-forget // (see AgentSession.#emit), so any await between the user message_start and // addMessageToChat lets later events (assistant message_start, tool execution // start/end) append their components first and scramble transcript order / // live-region block boundaries. addMessageToChat materializes clickable image // links via the synchronous putBlobSync fallback, so no await is needed here. this.ctx.addMessageToChat(event.message); } // Never clear the editor here. A local submission (optimistic or // queued-while-streaming) already cleared it at submit time, so clearing // again races with the user typing the next prompt while the previous // large redraw lands and erases their in-progress draft (#783). An inbound // user message this session did not submit locally — an extension // delivering `sendUserMessage`, e.g. HCOM — is a real non-synthetic prompt // that arrives *while* the user may be composing, so it must not touch the // draft either. if (!event.message.synthetic) { this.ctx.updatePendingMessagesDisplay(); } this.ctx.ui.requestRender(true); } else if (event.message.role === "developer") { // A run-initiating synthetic developer prompt (auto-continue, or a // queued follow-up drained inside the current run — plan approval, /goal) // starts fresh work without a new agent_start: clear the preceding user // prompt's anchor so its rows don't inherit the old turn's span. if (event.message.synthetic) { // A deliberate operator action (`.`, `c` continue shortcut) is the // turn's own prompt: anchor the delta to it instead of clearing. if (event.message.userInitiated) this.#turnStartedAt = event.message.timestamp; else this.#turnStartedAt = undefined; this.#turnUsage.reset(); } } else if (event.message.role === "fileMention") { this.#resetReadGroup(); this.ctx.addMessageToChat(event.message); this.ctx.ui.requestRender(); } else if (event.message.role === "assistant") { // A streaming component left over from an attempt that never saw its // message_end (a mid-stream throw the loop could not pair) must not stay // live: one unfinalized block at the transcript frontier blocks history // retirement — and therefore transcript layout — forever. Drop it when // still removable, and finalize it regardless so it can retire. const abandoned = this.ctx.streamingComponent; if (abandoned) { this.ctx.chatContainer.removeChild(abandoned); abandoned.markTranscriptBlockFinalized(); } this.#finalizeAbandonedPostToolSegments(); this.#lastVisibleBlockCount = 0; this.#streamedToolCallIdByIndex.clear(); this.ctx.streamingComponent = createAssistantMessageComponent(this.ctx); this.ctx.streamingMessage = event.message; this.ctx.streamingComponent.pickReactionTarget(this.ctx.chatContainer.children); this.ctx.chatContainer.addChild(this.ctx.streamingComponent); const timeline = splitAssistantMessageToolTimeline(this.ctx.streamingMessage); this.#streamingReveal.begin(this.ctx.streamingComponent, timeline.beforeTools, timeline.hasToolCalls); this.ctx.ui.requestRender(); } } async #handleIrcMessage(event: Extract): Promise { if (!event.message.display) return; const signature = `${event.message.role}:${event.message.customType}:${event.message.timestamp}`; if (this.#renderedCustomMessages.has(signature)) { return; } this.#renderedCustomMessages.add(signature); this.#resetReadGroup(); const components = this.ctx.addMessageToChat(event.message); this.#scheduleIrcExpiry(signature, components); this.#enforceIrcCardCap(signature); this.ctx.ui.requestRender(); } #scheduleIrcExpiry(signature: string, components: Component[]): void { if (components.length === 0 || this.#ircExpiryTimers.has(signature)) return; const timer = setTimeout(() => { this.#ircExpiryTimers.delete(signature); this.#retireIrcCard(signature); }, IRC_MESSAGE_VISIBLE_TTL_MS); timer.unref?.(); this.#ircExpiryTimers.set(signature, timer); this.#liveIrcCards.set(signature, components); } /** * Remove an expired/evicted IRC card only while its transcript entry remains * active. Pending or committed entries already belong to an immutable * ordered history batch and remain as final history. */ #retireIrcCard(signature: string): void { const components = this.#liveIrcCards.get(signature); this.#liveIrcCards.delete(signature); if (!components) return; let removed = false; for (const component of components) { if (!this.ctx.chatContainer.canRemoveBlock(component)) continue; this.ctx.chatContainer.removeChild(component); removed = true; } if (removed) this.ctx.ui.requestRender(); } /** Evict oldest live-region cards beyond {@link MAX_LIVE_IRC_CARDS}. */ #enforceIrcCardCap(latestSignature: string): void { while (this.#liveIrcCards.size > MAX_LIVE_IRC_CARDS) { const oldest = this.#liveIrcCards.keys().next().value; if (oldest === undefined || oldest === latestSignature) return; const timer = this.#ircExpiryTimers.get(oldest); if (timer) { clearTimeout(timer); this.#ircExpiryTimers.delete(oldest); } this.#retireIrcCard(oldest); } } /** * Resolve the pending displaceable poll block before the next block lands. * A follow-up `wait` call displaces it — the stale "waiting on N jobs" frame * is removed so repeated polls read as one persistent poll — while anything * else seals it in place as final history. Removal is gated on none of the * block's rows having entered native scrollback: rows already on the tape * are immutable visual history, so a scrolled-off poll seals instead of * being retracted. */ #resolveDisplaceablePoll(nextToolName?: string): void { const previous = this.#displaceablePollComponent; if (!previous) return; this.#displaceablePollComponent = undefined; if ( nextToolName === "wait" && previous.isDisplaceableBlock() && this.ctx.chatContainer.canDisplaceBlock(previous) ) { this.ctx.chatContainer.removeChild(previous); } // Sealing stops the waiting-poll spinner and freezes the block (for a // just-removed component it only clears the animation timer). previous.seal(); this.ctx.ui.requestRender(); } #resolveDisplaceableTodo(nextToolName?: string): void { const previous = this.#displaceableTodoComponent; if (!previous) return; if (!previous.isDisplaceableBlock()) { this.#displaceableTodoComponent = undefined; return; } if (previous.canBeDisplacedBy(nextToolName)) { this.#displaceableTodoComponent = undefined; this.ctx.chatContainer.removeChild(previous); previous.seal(); this.ctx.ui.requestRender(); return; } if (nextToolName !== undefined) return; this.#displaceableTodoComponent = undefined; previous.seal(); this.ctx.ui.requestRender(); } /** * Detach both displacement trackers and return whichever components were * live, without touching their animation state. `#handleToolExecutionEnd` * settles a displaceable `wait`/`todo` result out of `pendingTools` into * these trackers instead (see the `isDisplaceableBlock()` branch there), so * a caller that enumerates only `pendingTools` before replacing the whole * transcript — the collab guest resync (`guest.ts#finalizeSnapshot`) — * misses a still-animated "waiting" card. That caller must fold this * method's result into its own live-block accounting. */ takeDisplaceableComponents(): ToolExecutionHandle[] { const components: ToolExecutionHandle[] = []; if (this.#displaceablePollComponent) components.push(this.#displaceablePollComponent); if (this.#displaceableTodoComponent && this.#displaceableTodoComponent !== this.#displaceablePollComponent) { components.push(this.#displaceableTodoComponent); } this.#displaceablePollComponent = undefined; this.#displaceableTodoComponent = undefined; return components; } /** * Adopt a rebuilt-tail todo snapshot as the controller's tracked live * snapshot. Used by rebuild paths (settings/extensions overlay close, focus * attach, /resume) to preserve displacement continuity when a turn is still * active — without this, the next same-turn `todo` update would stack * another panel because the controller's tracker was reset before rebuild. * Drops the candidate when it is no longer a displaceable todo. */ inheritDisplaceableTodo(component: ToolExecutionComponent | null | undefined): void { this.#displaceableTodoComponent = component?.canBeDisplacedBy("todo") ? component : undefined; } /** * Adopt the rebuild's replayed user-prompt timestamp so a still-running turn * keeps its prompt→yield delta. Focus attach and mid-turn rebuilds reset the * controller's turn start before replaying entries; without the handoff the * in-flight assistant `message_end` would render the usage row without the * elapsed figure. Mirrors {@link inheritDisplaceableTodo}. Drops the * candidate when the rebuild saw no user message. */ inheritTurnStart(turnStartedAt: number | undefined): void { if (turnStartedAt !== undefined) this.#turnStartedAt = turnStartedAt; } /** * Re-register parked background cards after a transcript rebuild so a * background job's replayed and live progress frames keep routing to the * preserved component. Focus attach clears {@link #backgroundCallIds} * via {@link resetTranscriptAnchors} before the rebuild repopulates it. * Ids whose component did not survive into `pendingTools` are skipped. */ markBackgroundCalls(toolCallIds: Iterable): void { for (const toolCallId of toolCallIds) { if (this.ctx.pendingTools.has(toolCallId)) this.#backgroundCallIds.add(toolCallId); } } async #handleNotice(event: Extract): Promise { const message = event.source ? `${event.source}: ${event.message}` : event.message; if (event.level === "error") { this.ctx.showError(message); } else if (event.level === "warning") { this.ctx.showWarning(message); } else { this.ctx.showStatus(message); } } /** * Speak streamed assistant output as a side effect of the turn. The mode * decides which deltas feed the vocalizer (the vocalizer re-checks enabled): * assistant|all speak text; all also speaks thinking; yield speaks nothing * live (the final message is spoken at turn end). */ #vocalizeDelta(event: Extract): void { if (!cfgSpeechEnabled.get(settings)) return; const mode = cfgSpeechMode.get(settings); const delta = event.assistantMessageEvent; if (delta.type === "text_delta" && (mode === "assistant" || mode === "all")) { vocalizer.pushDelta(delta.delta); } else if (delta.type === "thinking_delta" && mode === "all") { vocalizer.pushDelta(delta.delta); } } /** * End-of-turn vocalization: yield mode speaks the final assistant message in * one shot here (the only mode that is post-hoc); every other mode just makes * sure the live buffer's trailing partial gets flushed. */ #handleTurnEnd(event: Extract): void { if (!cfgSpeechEnabled.get(settings)) return; if (cfgSpeechMode.get(settings) !== "yield") { vocalizer.flush(); return; } if (event.message.role !== "assistant") return; if (event.message.stopReason === "aborted") return; // interrupted: never speak the aborted partial const text = extractTextContent(event.message); if (text) vocalizer.speak(text); } async #restartAssistantStream(message: AgentMessage): Promise { this.#retractStreamedToolPreviews(); await this.handleEvent({ type: "message_start", message }); } #retractStreamedToolPreviews(): void { // A provider replay can abandon previews without ever executing them. // No result will settle those cards; leaving one pins all later history. for (const toolCallId of this.#streamedToolCallIdByIndex.values()) { if (this.#executionStartedCallIds.has(toolCallId) || this.#backgroundCallIds.has(toolCallId)) continue; this.#toolArgsReveal.finish(toolCallId); this.#pendingStreamPreviews.delete(toolCallId); const component = this.ctx.pendingTools.get(toolCallId); if (component) this.#retractToolCardEntry(toolCallId, component); const segment = this.#postToolAssistantComponents.get(toolCallId); if (segment) { this.ctx.chatContainer.removeChild(segment); segment.markTranscriptBlockFinalized(); this.#postToolAssistantComponents.delete(toolCallId); } } this.#resetReadGroup(); } async #handleMessageUpdate(event: Extract): Promise { const generation = this.#dispatchGeneration; this.#ensureWorkingLoaderWhileStreaming(); if (!this.#vocalizedMessageUpdates.delete(event)) { this.#vocalizeDelta(event); } if (event.assistantMessageEvent?.type === "start") { await this.#restartAssistantStream(event.message); } // A focus switch reset the view while the restart was in flight: this // update belongs to the previous session's stream. if (generation !== this.#dispatchGeneration) return; if (event.message.role === "assistant") await this.syncAssistantMessage(event.message); } /** Resync a cumulative snapshot without replay cleanup or speech side effects. */ async syncAssistantMessage(message: AssistantMessage): Promise { const generation = this.#dispatchGeneration; if (this.ctx.streamingComponent) { const unlockedThinkingVisibility = this.ctx.noteDisplayableThinkingContent(message); if (unlockedThinkingVisibility) { this.ctx.streamingComponent.setHideThinkingBlock(this.ctx.effectiveHideThinkingBlock); this.#streamingReveal.resyncVisibility(); } this.ctx.streamingMessage = message; const timeline = splitAssistantMessageToolTimeline(this.ctx.streamingMessage); this.#streamingReveal.setTarget(timeline.beforeTools, timeline.hasToolCalls); const visibleBlockCount = this.ctx.streamingMessage.content.filter( content => (content.type === "text" && canonicalizeMessage(content.text)) || (content.type === "thinking" && canonicalizeMessage(content.thinking)), ).length; if (visibleBlockCount > this.#lastVisibleBlockCount) { this.#resetReadGroup(); this.#lastVisibleBlockCount = visibleBlockCount; } // Content blocks stream sequentially: a toolCall block can only begin // after every preceding thinking/text block has closed, and the // reveal's setTarget above force-completes the visible text for // toolCall messages. Finalize the assistant block now instead of at // message_end so the transcript's commit-safe run can extend through // it into the streaming tool preview below — otherwise a long args // stream (a big write/edit/eval) sits below a still-live block and // can never reach native scrollback: the head of the preview is // neither committed nor on screen and the transcript reads as cut. if ( this.ctx.streamingMessage.content.some(content => content.type === "toolCall") && !this.ctx.streamingComponent.isTranscriptBlockFinalized() ) { const linkTargets = await refreshAssistantMessageLinkTargets(this.ctx, [timeline.beforeTools]); // A focus switch reset the view during link resolution (it waits // behind this run): the streaming state below is gone or the next // session's, so none of this snapshot may be drawn into it. if (generation !== this.#dispatchGeneration || !this.ctx.streamingComponent) return; this.ctx.streamingComponent.setLinkTargets(assistantMessageLinkTargets(timeline.beforeTools, linkTargets)); this.ctx.streamingComponent.markTranscriptBlockFinalized(); } // Re-key live cards when a provider rewrites a block's id across // deltas, so the changed id reuses the existing card instead of // spawning a duplicate (#6879). this.#reconcileStreamedToolCallIds(this.ctx.streamingMessage.content); for (let contentIndex = 0; contentIndex < this.ctx.streamingMessage.content.length; contentIndex++) { const content = this.ctx.streamingMessage.content[contentIndex]!; if (content.type !== "toolCall") continue; const tool = this.ctx.viewSession.getToolByName(content.name); const renderToolName = toolRenderName(content.name, tool); if (renderToolName === "read") { if (!readArgsHaveTarget(content.arguments)) { // Args still streaming — defer until path is parseable so we can route to the // read group (files + xd:// devices) vs ToolExecutionComponent (other internal URLs). // Creating either component now would lock the read into the wrong shape. continue; } if (readArgsCollapseIntoGroup(content.arguments)) { const existing = this.ctx.pendingTools.get(content.id); if (existing) { this.#trackReadToolCall(content.id, content.arguments); existing.updateArgs(content.arguments, content.id); } else if (!this.#toolTimelineComponents.has(content.id)) { // A completed read remains in the timeline after leaving pendingTools. this.#resolveDisplaceablePoll(renderToolName); this.#trackReadToolCall(content.id, content.arguments); const group = this.#getReadGroup(); group.updateArgs(content.arguments, content.id); this.ctx.pendingTools.set(content.id, group); this.#toolTimelineComponents.set(content.id, group); this.#settleHeldCompletionIfPresent(content.id, group); } continue; } // Other internal-URL reads fall through to ToolExecutionComponent below. } // Preserve the raw partial JSON only for renderers that need to surface fields before the JSON object closes. // Bash uses this to show inline env assignments during streaming instead of popping them in at completion. // While the JSON is still open, ToolArgsRevealController paces the // reveal (write/edit/bash previews grow smoothly when a slow provider // delivers large batches); once it closes, the final args render // as-is — mirroring how assistant text snaps at message_end. let renderArgs: Record; const partialJson = getStreamingPartialJson(content); const rawInput = content.customWireName !== undefined; // `message_update` is cumulative — every update re-lists all blocks // of the streaming message — so creation must also be guarded by the // timeline map: `pendingTools` loses the id the moment a completion // settles the card, and for server-resolved (Cursor) tools that can // happen while the message is still streaming. Without the second // check the next cumulative update would recreate a card for a call // that already finished, permanently pending. const isNew = !this.ctx.pendingTools.has(content.id) && !this.#toolTimelineComponents.has(content.id); if (partialJson) { renderArgs = this.#toolArgsReveal.setTarget(content.id, partialJson, { rawInput, exposeRawPartialJson: exposesRawPartialJson(renderToolName, rawInput, tool), streamingStringKeys: streamingStringKeysForTool(renderToolName, rawInput), }); } else { this.#toolArgsReveal.finish(content.id); renderArgs = content.arguments; } if (isNew) { // A write's streamed path may still grow into a reply URL (`ou…` → `out://`), which // draws as prose rather than a card; the block waits until the path tells. Empty partial // JSON (`toolcall_start`) is still streaming, not final empty args. An unbound reveal // never advances, so drop it: the next update's fresh entry shows all received. if (partialJson !== undefined && isReplyWrite(renderToolName, renderArgs) === undefined) { this.#toolArgsReveal.finish(content.id); continue; } this.#resolveDisplaceablePoll(renderToolName); this.#resetReadGroup(); const component = createToolCallBlock(this.ctx, { name: renderToolName, args: renderArgs, tool, id: content.id, cwd: this.ctx.sessionManager.getCwd(), }); const pendingPreview = this.#pendingStreamPreviews.get(content.id); if (pendingPreview !== undefined) { component.updateStreamPreview?.(pendingPreview); this.#pendingStreamPreviews.delete(content.id); } component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(component); this.ctx.pendingTools.set(content.id, component); this.#toolTimelineComponents.set(content.id, component); this.#toolArgsReveal.bind(content.id, component); // A held completion settles only the new card; its user-facing // side effects already ran on first arrival. this.#settleHeldCompletionIfPresent(content.id, component); } else { const component = this.ctx.pendingTools.get(content.id); if (component) { component.updateArgs(renderArgs, content.id); this.#toolArgsReveal.bind(content.id, component); } } } for (const [toolCallId, segment] of timeline.afterToolCalls) { if (this.#postToolAssistantComponents.get(toolCallId)?.isTranscriptBlockFinalized()) continue; const closed = toolCallId !== timeline.lastToolCallId; const linkTargets = closed ? await refreshAssistantMessageLinkTargets(this.ctx, [segment]) : undefined; if (generation !== this.#dispatchGeneration || !this.ctx.streamingMessage) return; const component = this.#upsertPostToolAssistantSegment( toolCallId, segment, timeline.contentOffsets.get(toolCallId), linkTargets ? assistantMessageLinkTargets(segment, linkTargets) : undefined, closed ? undefined : { transient: true }, ); if (closed) component?.markTranscriptBlockFinalized(); } // Update working message with the intent of the LAST intent-bearing // streamed tool call. Scanning in reverse skips the redundant // intermediate setWorkingMessage calls a forward pass would make // (only the last one survives the flush anyway). const blocks = this.ctx.streamingMessage.content; for (let index = blocks.length - 1; index >= 0; index--) { const block = blocks[index]; if (block?.type !== "toolCall") continue; const args = block.arguments; if (!args || typeof args !== "object") continue; let intent: string | undefined; if (INTENT_FIELD in args) { intent = normalizeIntent(args[INTENT_FIELD]); } else { const tool = this.ctx.viewSession.getToolByName(block.name); if (typeof tool?.intent !== "function") continue; try { intent = normalizeIntent(tool.intent(args as never)); } catch { // intent function must never break the UI } } if (!intent) continue; this.#updateWorkingMessageFromIntent(intent); break; } this.ctx.ui.requestRender(); } } async #handleMessageEnd(event: Extract): Promise { // The persistence slot exists before message_end notification, unlike // tool_execution_end. Resolve HUD identity only after canonical append. if (event.message.role === "toolResult" && event.message.toolName === "todo" && !event.message.isError) { const details = event.message.details as { op?: string; phases?: TodoPhase[] } | undefined; if (details?.op !== "view" && details?.phases) { const owner = this.ctx.viewSession; const sessionId = owner.sessionManager.getSessionId(); const sessionFile = owner.sessionManager.getSessionFile(); await owner.settleInFlightMessagePersistence(); if ( this.ctx.viewSession === owner && owner.sessionManager.getSessionId() === sessionId && owner.sessionManager.getSessionFile() === sessionFile ) { this.ctx.setTodos(owner.getTodoPhases()); } } } if (event.message.role === "user") { // Live steering stays listed until the agent appends it, which follows // message_start: drop its Steering chip now. this.ctx.updatePendingMessagesDisplay(); return; } const unlockedThinkingVisibility = event.message.role === "assistant" && this.ctx.noteDisplayableThinkingContent(event.message); if (unlockedThinkingVisibility && this.ctx.streamingComponent) { this.ctx.streamingComponent.setHideThinkingBlock(this.ctx.effectiveHideThinkingBlock); this.#streamingReveal.resyncVisibility(); } if (event.message.role === "assistant" && cfgSpeechEnabled.get(settings)) { if (event.message.stopReason === "aborted") { // Esc / Ctrl+C / interrupt: stop speaking now and drop the trailing partial. vocalizer.clear(); } else { const mode = cfgSpeechMode.get(settings); // Speak the last partial sentence of a completed message; yield mode // instead speaks the whole final message at turn end. if (mode === "assistant" || mode === "all") vocalizer.flush(); } } if (this.ctx.streamingComponent && event.message.role === "assistant") { const generation = this.#dispatchGeneration; this.ctx.streamingMessage = event.message; this.#streamingReveal.stop(); this.#toolArgsReveal.flushAll(); let errorMessage: string | undefined; const aborted = this.ctx.streamingMessage.stopReason === "aborted"; const silentlyAborted = aborted && isSilentAbort(this.ctx.streamingMessage); if (aborted && !silentlyAborted) { // Resolve the operator-facing label: a user-interrupt (Esc) abort // carries USER_INTERRUPT_LABEL on errorMessage (threaded through the // AbortController), which is preserved verbatim; any other abort with // no threaded reason falls back to the retry-aware generic label. // AgentSession.#handleAgentEvent already stamped the SilentAbort flag // for expected internal transitions (plan-compact, TTSR rule // interruption) before this controller ran, so reaching this branch // implies the abort was NOT a silent internal transition. errorMessage = resolveAbortLabel(this.ctx.streamingMessage, this.ctx.viewSession.retryAttempt); this.ctx.streamingMessage.errorMessage = errorMessage; } const displayMessage: AssistantMessage = silentlyAborted ? { // Silence the streaming render by downgrading stopReason to "stop" for // display only — does NOT mutate the persisted message's stopReason // (the structural SilentAbort flag drives replay-side suppression). ...this.ctx.streamingMessage, stopReason: "stop", } : this.ctx.streamingMessage; const displayTimeline = splitAssistantMessageToolTimeline(displayMessage); const linkTargets = await refreshAssistantMessageLinkTargets(this.ctx, [displayMessage]); if (generation !== this.#dispatchGeneration || !this.ctx.streamingComponent) return; if (!this.ctx.streamingComponent.isTranscriptBlockFinalized()) { this.ctx.streamingComponent.setLinkTargets( assistantMessageLinkTargets(displayTimeline.beforeTools, linkTargets), ); } // The final snapshot can carry a per-index id change no delta ever // showed — agent-loop mints and re-keys tool-call ids at `done`, after // the last `message_update` — so replay the same reconciliation // `#handleMessageUpdate` runs per delta. Without it the pre-mint card // is never re-keyed: tool_execution_start mounts a second card under // the minted id and the streamed one ghosts until sealed. this.#reconcileStreamedToolCallIds(this.ctx.streamingMessage.content); this.ctx.streamingComponent.updateContent(displayTimeline.beforeTools); if (this.ctx.streamingMessage.stopReason !== "aborted" && this.ctx.streamingMessage.stopReason !== "error") { for (const [toolCallId, component] of this.ctx.pendingTools.entries()) { component.setArgsComplete(toolCallId); } } else { // The turn ended without running these calls. What happens next // decides whether their cards should vanish or stay: // • TTSR rewind — known NOW via `isTtsrAbortPending` — re-runs the // turn and re-streams fresh cards, so retract the uncommitted // never-run cards here and swallow the synthetic completions // agent-loop emits for them, else the call renders twice (#6879). // • A plain terminal error/abort, or an auto-retry (whose // supersession is only known later at `auto_retry_start`), must // NOT be retracted here: agent-loop emits a synthetic // `tool_execution_end` right after this that settles each card // into a visible aborted/error result. Leave them so the terminal // failure stays visible; `#handleAutoRetryStart` removes them only // if a retry actually supersedes them (Codex review on #6881). const supersededByRewind = this.ctx.streamingMessage.stopReason === "aborted" && this.ctx.viewSession.isTtsrAbortPending; if (supersededByRewind) { this.ctx.streamingComponent.markRewound(); for (const [toolCallId, component] of Array.from(this.ctx.pendingTools.entries())) { if (this.#backgroundCallIds.has(toolCallId)) continue; if ( !(component instanceof ToolExecutionComponent) && !(component instanceof ReadToolGroupComponent) && !(component instanceof ReplyWriteComponent) ) { continue; } if (this.ctx.chatContainer.canRemoveBlock(component)) { this.#retractToolCardEntry(toolCallId, component); this.#retractedToolCallIds.add(toolCallId); } else { component.seal(); } } } // These calls will never run this attempt, so the tracked waiting // poll cannot be displaced anymore — freeze it in place. this.#resolveDisplaceablePoll(); } // Surface a prompt-cache invalidation: if the previous turn cached a // meaningful prefix and this request read none of it back, flag the turn. const usage = event.message.usage; if (usage.cacheRead + usage.cacheWrite + usage.input > 0) { if (cfgDisplayCacheMissMarker.get(settings)) { const invalidation = detectCacheInvalidation(this.ctx.lastAssistantUsage, usage); if (invalidation) this.ctx.streamingComponent.setCacheInvalidation(invalidation); } this.ctx.lastAssistantUsage = usage; } this.ctx.streamingComponent.setServedModelMismatch(this.ctx.servedModelTracker.check(event.message)); this.ctx.streamingComponent.markTranscriptBlockFinalized(); let lastPostToolAssistantComponent: AssistantMessageComponent | undefined; for (const [toolCallId, segment] of displayTimeline.afterToolCalls) { const component = this.#upsertPostToolAssistantSegment( toolCallId, segment, displayTimeline.contentOffsets.get(toolCallId), assistantMessageLinkTargets(segment, linkTargets), ); component?.markTranscriptBlockFinalized(); if (component) lastPostToolAssistantComponent = component; } this.#lastAssistantComponent = lastPostToolAssistantComponent ?? this.ctx.streamingComponent; const turnUsage = this.#turnUsage.add(event.message, this.#turnStartedAt); if (turnUsage) this.#lastAssistantComponent.setTurnUsage(turnUsage); if (cfgDisplayShowTokenUsage.get(settings) && assistantUsageIsBilled(event.message.usage)) { const readCallIds = groupedReadUsageCallIds(event.message); const turnElapsed = cfgDisplayShowTurnTime.get(settings) ? turnElapsedMs(this.#turnStartedAt, event.message) : undefined; const usageAttached = readCallIds !== undefined && (this.#lastReadGroup?.attachUsage( readCallIds, event.message.usage, event.message.duration, event.message.ttft, event.message.timestamp, turnElapsed, ) ?? false); if (!usageAttached) { this.#resetReadGroup(); this.ctx.chatContainer.addChild( createUsageRowBlock( event.message.usage, event.message.duration, event.message.ttft, event.message.timestamp, turnElapsed, ), ); } } if (displayMessage === event.message) { this.ctx.transcriptMessageComponents.set(event.message, this.ctx.streamingComponent); } this.ctx.streamingComponent = undefined; this.ctx.streamingMessage = undefined; // Pin a turn-ending provider error above the editor so it survives // transcript scroll and suppress its duplicate inline row. Empty-output // errors are known intermediate attempts: hide them entirely while // session recovery continues, but retain the component so a terminal // retry-cap event can promote its final error into the one banner. if (event.message.stopReason === "error" && event.message.errorMessage && !isSilentAbort(event.message)) { const recoverableEmptyOutput = !event.message.errorMessage.startsWith("Retry budget exhausted") && AIError.is(AIError.classifyMessage(event.message), AIError.Flag.EmptyResponse); this.#lastAssistantComponent?.setErrorPinned(true); this.#pinnedErrorComponent = this.#lastAssistantComponent; this.#pinnedErrorMessage = event.message; this.#restorePinnedErrorInline = !recoverableEmptyOutput; if (!recoverableEmptyOutput) this.ctx.showPinnedError(event.message.errorMessage); } this.ctx.statusLine.invalidate(); this.ctx.ui.requestRender(); } this.ctx.ui.requestRender(); } async #handleToolExecutionStart(event: Extract): Promise { if (this.#retractedToolCallIds.has(event.toolCallId)) return; this.#ensureWorkingLoaderWhileStreaming(); this.#updateWorkingMessageFromIntent(event.intent); const tool = this.ctx.viewSession.getToolByName(event.toolName); const renderToolName = toolRenderName(event.toolName, tool); const blocked = this.#blockingPrompt(renderToolName, event.args); if (blocked) { this.#blockingPrompts.set(event.toolCallId, blocked); setRunStatus(blocked); } this.#resolveDisplaceablePoll(renderToolName); if (!this.ctx.pendingTools.has(event.toolCallId) && !this.#toolTimelineComponents.has(event.toolCallId)) { const stale = this.#priorTurnToolComponents.get(event.toolCallId); if (stale) { this.#priorTurnToolComponents.delete(event.toolCallId); // Never evict a shared read-group card: it may still carry sibling reads. if (stale instanceof ToolExecutionComponent && this.ctx.chatContainer.children.includes(stale)) { this.ctx.chatContainer.removeChild(stale); } } if (renderToolName === "read" && readArgsCollapseIntoGroup(event.args)) { this.#trackReadToolCall(event.toolCallId, event.args); const group = this.#getReadGroup(); group.updateArgs(event.args, event.toolCallId); this.ctx.pendingTools.set(event.toolCallId, group); this.#toolTimelineComponents.set(event.toolCallId, group); this.#settleHeldCompletionIfPresent(event.toolCallId, group); this.#startToolApprovalPreview(event.toolCallId); this.ctx.ui.requestRender(); return; } this.#resetReadGroup(); const component = createToolCallBlock(this.ctx, { name: renderToolName, args: renderArgsWithIntent(event.args, event.intent), tool, id: event.toolCallId, cwd: this.ctx.sessionManager.getCwd(), }); const pendingPreview = this.#pendingStreamPreviews.get(event.toolCallId); if (pendingPreview !== undefined) { component.updateStreamPreview?.(pendingPreview); this.#pendingStreamPreviews.delete(event.toolCallId); } component.setArgsComplete(event.toolCallId); component.setExecutionStarted(event.toolCallId); this.#executionStartedCallIds.add(event.toolCallId); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(component); this.ctx.pendingTools.set(event.toolCallId, component); this.#toolTimelineComponents.set(event.toolCallId, component); this.#settleHeldCompletionIfPresent(event.toolCallId, component); this.ctx.ui.requestRender(); } else { // The tool is about to run, so its arguments are final and validated. // A pending component created while args streamed (message_update) may // still show a mid-reveal prefix — or, when the closing full-args // `message_update` never lands (smooth-streaming off leaving the // throttled `arguments` stale, an owned-dialect projector, or a // superseded/aborted turn that still executes the call), a stale body // the result render then freezes at its `…` placeholder. Reconcile the // authoritative args here and drop any live reveal so a late tick can't // re-truncate them: tool_execution_start is the one event every // execution path emits with the full args immediately before the result. // Also reuses a card already settled in `#toolTimelineComponents` (when // `restoreAssistant` consumed a buffered result ahead of a queued start). // That card stays out of `pendingTools`: call ids are unique within a // run and the timeline is cleared at agent_start/agent_end, so a // timeline-only hit cannot be a fresh execution — its queued // update/end duplicate the settled result and are dropped as such. this.#toolArgsReveal.finish(event.toolCallId); if (renderToolName === "read" && readArgsCollapseIntoGroup(event.args)) { this.#trackReadToolCall(event.toolCallId, event.args); } const component = this.ctx.pendingTools.get(event.toolCallId) ?? this.#toolTimelineComponents.get(event.toolCallId); if (component) { component.updateArgs(renderArgsWithIntent(event.args, event.intent), event.toolCallId); component.setArgsComplete(event.toolCallId); component.setExecutionStarted(event.toolCallId); this.#executionStartedCallIds.add(event.toolCallId); this.ctx.ui.requestRender(); } } this.#startToolApprovalPreview(event.toolCallId); } /** * The `blocked` status of a tool call that waits on the user before it * completes: an `ask` question, or an approval prompt. The extension wrapper * waits on `uiContext.select(...)` after emitting `tool_execution_start`, so * an approval-mode / per-tool `prompt` policy is user-blocking — the run * reads `blocked`, not `working`. Mirrors the wrapper's `resolveApproval` * inputs (approvalMode + tools.approval, grants, restrictions) and prompt * text, all read from the viewed session — a focused subagent runs under its * own settings, conversation, and session approvals; uses `resolveApproval` * rather than `requiresApproval` so a `deny` policy does not throw in the * render path. */ #blockingPrompt(toolName: string, args: unknown): RunStatus | undefined { if (toolName === "ask") { const html = recoverAskHtml(args); const form = html === undefined ? undefined : parseHtmlForm(html); const question = form?.fields.find(field => field.name); return { state: "blocked", kind: "question", msg: form && question ? fieldQuestions(form).get(question) : undefined, }; } const session = this.ctx.viewSession; const tool = session.getToolByName(toolName); if (!tool) return undefined; const sessionSettings = session.settings; const mode = cfgToolsApprovalMode.get(sessionSettings); const userPolicies: Record = cfgToolsApproval.get(sessionSettings); const grants = mode === "always-ask-cached" ? session.getApprovalGrants() : undefined; const approval = resolveApproval( tool, args, mode, userPolicies, grants, approvalRestrictions(sessionSettings, session.sessionManager.getCwd()), ); if (approval.policy !== "prompt") return undefined; // Approved for this session: the wrapper runs it without asking. const scope = sessionApprovalScope(approval, tool.name); if (scope && session.hasSessionApproval(scope.key)) return undefined; return { state: "blocked", kind: "permission", msg: formatApprovalPrompt(tool, args, approval.reason) }; } async #handleToolExecutionUpdate( event: Extract, ): Promise { this.#ensureWorkingLoaderWhileStreaming(); this.#showToolReplies(event.toolCallId, event.partialResult.details); const component = this.ctx.pendingTools.get(event.toolCallId); if (component) { const isFinalAsyncState = isSettledJobFrame(event.partialResult.details); // A final async snapshot is terminal only for a parked background // block (the call already returned and was kept alive for its jobs). // While the call is still executing — a mixed blocking+async task // call whose jobs settle before its blocking subset — treat it as a // partial frame: `tool_execution_end` still owns the terminal result. const isTerminal = isFinalAsyncState && this.#backgroundCallIds.has(event.toolCallId); if (isTerminal) { settleWithJobFrame(component, event.partialResult, event.toolCallId); this.ctx.pendingTools.delete(event.toolCallId); this.#backgroundCallIds.delete(event.toolCallId); } else { if (isFinalAsyncState) this.#settledBeforeReturn.set(event.toolCallId, event); const isError = asyncJobState(event.partialResult.details) === "failed"; component.updateResult({ ...event.partialResult, isError }, true, event.toolCallId); } this.ctx.ui.requestRender(); } } #settleHeldCompletionIfPresent(toolCallId: string, component: ToolExecutionHandle): void { const event = this.#orphanedToolCompletions.get(toolCallId); if (!event) return; this.#orphanedToolCompletions.delete(toolCallId); this.#settleHeldCompletion(component, event); } /** * Attach a held completion to the component that was created for it after * the fact (see {@link #orphanedToolCompletions}) and settle the card. * * Deliberately NOT a re-entry into `#handleToolExecutionEnd`: every * user-facing side effect of that handler — the todo panel refresh, the * failure warning, plan approval, the terminal-title transition — already * ran when the completion first arrived. Replaying the whole handler would * show the same warning twice and re-push an identical `setTodos`. Only the * component half was missed, because the component did not exist yet. */ #settleHeldCompletion( component: ToolExecutionHandle, event: Extract, ): void { if (event.toolName === "read") this.#inlineReadToolImages(event.toolCallId, event.result); this.#applyToolCompletion(component, event); if (event.toolName === "read") this.#clearReadToolCall(event.toolCallId); if ( component instanceof ToolExecutionComponent && component.isDisplaceableBlock() && event.toolName === "todo" && component.canBeDisplacedBy("todo") ) { // Mirrors the displacement bookkeeping in `#handleToolExecutionEnd`: // a successful snapshot supersedes the previous live panel. const previous = this.#displaceableTodoComponent; if (previous && previous !== component && previous.isDisplaceableBlock()) { this.#displaceableTodoComponent = undefined; this.ctx.chatContainer.removeChild(previous); previous.seal(); } this.#displaceableTodoComponent = component; } this.ctx.ui.requestRender(); } #applyToolCompletion( component: ToolExecutionHandle, event: Extract, ): void { const settled = this.#settledBeforeReturn.get(event.toolCallId)?.partialResult; this.#settledBeforeReturn.delete(event.toolCallId); const parks = parksAsBackground(event.toolName, event.result.details); if (parks && settled) { // The job finished before the call returned: its settled frame, not the // `running` start result, is the card's final state. settleWithJobFrame(component, settled, event.toolCallId); this.ctx.pendingTools.delete(event.toolCallId); return; } component.updateResult({ ...event.result, isError: event.isError }, parks, event.toolCallId); if (parks) { component.parkAsBackground(); this.#backgroundCallIds.add(event.toolCallId); } else { this.ctx.pendingTools.delete(event.toolCallId); this.#backgroundCallIds.delete(event.toolCallId); } } async #handleToolExecutionEnd(event: Extract): Promise { // `createAbortedToolResult` emits start/end after an error/aborted // assistant message. The matching card was deliberately retracted at // message_end; consume the completion instead of recreating/updating UI. if (this.#retractedToolCallIds.delete(event.toolCallId)) return; this.#executionStartedCallIds.delete(event.toolCallId); this.#showToolReplies(event.toolCallId, event.result.details); // A synthetic aborted/error completion (agent-loop's placeholder for a // never-run call on a terminal error/abort) settles the card in place so a // terminal failure stays visible. Remember it so `#handleAutoRetryStart` // can remove it if an auto-retry then supersedes the turn (Codex review on // #6881). Capture the live component now — the settle path below drops it // from `pendingTools` but leaves it in the transcript. const syntheticFailureDetails = event.result.details as { __synthetic?: boolean; source?: string } | undefined; const syntheticFailureCard = syntheticFailureDetails?.__synthetic === true && (syntheticFailureDetails.source === "assistant_stop_error" || syntheticFailureDetails.source === "assistant_stop_aborted") ? this.ctx.pendingTools.get(event.toolCallId) : undefined; // A transient overlay (auto-compaction / auto-retry / handoff) that ran // between this tool's start and end could have detached the working // loader. `tool_execution_update` already reconciles this so the spinner // reappears mid-tool; mirror it here so subagent (`task`) completions — // which only fire `tool_execution_end`, never `_update` — do not leave // the UI looking idle while the session keeps streaming (#3857). this.#ensureWorkingLoaderWhileStreaming(); // Return to `working` only when the LAST outstanding user-blocking prompt // resolves: with queued approval prompts (always-ask/write), the first tool // to finish must not clear the blocked signal while another prompt still // waits — the run reports that prompt instead. `ask` ids are in the map too // (added at tool_execution_start), so the delete also covers them without // leaking ids until turn end. if (this.#blockingPrompts.delete(event.toolCallId)) { setRunStatus(this.#blockingPrompts.values().next().value ?? { state: "working" }); } if (event.toolName === "read") { if (this.#inlineReadToolImages(event.toolCallId, event.result)) { const component = this.ctx.pendingTools.get(event.toolCallId); if (component) { component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId); this.ctx.pendingTools.delete(event.toolCallId); } this.#clearReadToolCall(event.toolCallId); this.ctx.ui.requestRender(); } else { const component = this.ctx.pendingTools.get(event.toolCallId); if (!component) { // A persisted result can win a mid-stream transcript rebuild // before this live completion handler runs. Rebuild removes the // pending handle and replay owns the completed card, but the // original timeline entry remains as proof that a card already // existed. Do not create a fallback read group beside replay // (#6879). A completion that outran every streamed card is held // and settled when the card is created, matching non-read tools. if (this.#toolTimelineComponents.has(event.toolCallId)) { this.#clearReadToolCall(event.toolCallId); return; } this.#orphanedToolCompletions.set(event.toolCallId, event); } else { component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId); this.ctx.pendingTools.delete(event.toolCallId); this.#clearReadToolCall(event.toolCallId); this.ctx.ui.requestRender(); } } } else { const component = this.ctx.pendingTools.get(event.toolCallId); if (component) { this.#applyToolCompletion(component, event); if (component instanceof ToolExecutionComponent && component.isDisplaceableBlock()) { if (event.toolName === "wait" && component.canBeDisplacedBy("wait")) { // Remember the waiting poll so the next `wait` call can displace it. this.#displaceablePollComponent = component; } else if (event.toolName === "todo" && component.canBeDisplacedBy("todo")) { // Successful todo update supersedes the prior live snapshot. A failed // follow-up never reaches this branch (canBeDisplacedBy("todo") returns // false for errored results), so the last-good panel stays on screen. const previous = this.#displaceableTodoComponent; if (previous && previous !== component && previous.isDisplaceableBlock()) { this.#displaceableTodoComponent = undefined; this.ctx.chatContainer.removeChild(previous); previous.seal(); } this.#displaceableTodoComponent = component; } } this.ctx.ui.requestRender(); } else if (!this.#toolTimelineComponents.has(event.toolCallId)) { // No component yet: the async streamed-block handler lost the race // to this completion. Hold the result instead of dropping it. Any // tool can finish inside that scheduling window; user-facing side // effects below still run now, and `#settleHeldCompletion` later // updates only the card. this.#orphanedToolCompletions.set(event.toolCallId, event); } } if (syntheticFailureCard) this.#syntheticFailureCards.set(event.toolCallId, syntheticFailureCard); if (event.toolName === "todo" && !event.isError) { const details = event.result.details as { op?: string; phases?: TodoPhase[] } | undefined; if (details?.op !== "view" && details?.phases) this.ctx.setTodos(details.phases); } if (event.toolName === "eval" && hasNestedTodo(event.result.details)) { this.ctx.setTodos(this.ctx.viewSession.getTodoPhases()); } if (event.toolName === "todo" && event.isError) { const textContent = event.result.content.find( (content: { type: string; text?: string }) => content.type === "text", )?.text; // This text can be a provider error copied verbatim off the wire (the // Cursor todo bridge forwards the server's string), so it may carry // ANSI escapes, other C0/C1 controls, tabs, newlines, or a line far // wider than the terminal. `showWarning` renders through a plain `Text`, // which strips none of that — an escape reaches the terminal // and can repaint outside the row. `sanitizeText` drops the control // sequences (and returns the same reference when there are none), // then `previewLine` collapses the remaining whitespace and bounds // the width. Sanitizing first matters: truncating before stripping // can cut an escape mid-sequence and leave a dangling introducer. // // This is the render boundary, not the persisted result: the stored // error stays full-fidelity for the transcript and for replays. const detail = textContent ? previewLine(sanitizeText(textContent), TRUNCATE_LENGTHS.LINE) : ""; this.ctx.showWarning( `Todo update failed${detail ? `: ${detail}` : ". Progress may be stale until todo succeeds."}`, { hideWithToolActivity: true }, ); } // Plan approval rides a `write` to xd://propose: the dispatch metadata on // the write details carries the approval payload as `inner`. if (!event.isError) { const dispatch = writeDeviceDispatch(event.toolName, event.result); const details = dispatch?.tool === PROPOSE_DEVICE_NAME && dispatch.mode === "execute" ? dispatch.inner : undefined; if ( details && typeof details === "object" && "planFilePath" in details && "title" in details && "planExists" in details && typeof details.planFilePath === "string" && typeof details.title === "string" && typeof details.planExists === "boolean" ) { // Dispatch the approval WITHOUT blocking the serialized event // dispatch chain. `handlePlanApproval` -> `#approvePlan` awaits // `session.prompt` for the ENTIRE approved-execution turn; awaiting // it here (this handler runs inside `#runSerialized`) would hold the // single dispatch link for the whole run, so the run's own // agent_start / message_start / tool / coalesced message_update // events queue behind it on `#dispatchTail` and the chat stays blank // until execution finishes (issue #7684, follow-up to #5688 which // only closed the overlay). Detaching frees the link the moment this // handler returns; the approval overlay and the turn's live events // then render. The approval flow surfaces its own failures via // `showError`, so only an unexpected rejection is logged here. void this.ctx .handlePlanApproval({ planFilePath: details.planFilePath, title: details.title, planExists: details.planExists, }) .catch(err => { logger.warn("Plan approval dispatch failed", { error: err instanceof Error ? err.message : String(err), }); }); } } } async #handleAgentEnd(event: Extract): Promise { // A superseded agent_end: the agent is already streaming a fresh turn, so // this event belongs to a turn that has already been replaced. The session // dispatches to listeners fire-and-forget across an async extension-emit hop // (#emitSessionEvent), so an interrupted turn's agent_end can land AFTER the // resumed turn's agent_start (e.g. any post-turn agent.continue()). Running // the turn-end teardown now would stop the loader the live turn just created, // leaving "Working…" gone while the agent keeps running. The live turn owns // the loader and finalizes it at its own agent_end (isStreaming === false by // then). Mirrors the collab guest's !isStreaming loader reconciler. if (this.ctx.session.isStreaming) { // A focused worker can end while the main session streams; its run is // over even though main's is not. Tear down only what the viewed worker // owns (its loader); main-owned work waits for main's own settle. const view = this.ctx.viewSession; if (view !== this.ctx.session && !view.isStreaming && this.ctx.loadingAnimation) { this.ctx.loadingAnimation.stop(); this.ctx.loadingAnimation = undefined; this.ctx.statusContainer.disposeChildren(); } this.#runActive = view.isStreaming; this.#syncComposerActivity(); return; } // A non-terminal settle (`isTerminal: false`) is a scheduling pause, not the // end of the run: the agent's own continuation (reminder, retry, queued // steer/follow-up, IRC wake) follows, or background work may re-wake it. // Skip the idle title/loader teardown; the continuation's terminal // `agent_end` performs it, or the settle watch does when that continuation // never starts (an abort cancels it, background work ends without a wake). // Still flush a deferred model switch — the plan-mode reconciler queues it // to apply once the current stream ends, and `#finishAgentEnd` is otherwise // its only flush site, so the automatic continuation would otherwise run // on the old model/thinking level until the terminal settle. if (event.isTerminal === false) { // `awaitingAsyncWork`: the model handed control back and only a // background-job result can resume it. The title tracks the model, so it // goes idle now — before any await, so a wake landing mid-flush keeps the // `working` its `agent_start` sets. The OSC 7501 record stays `working`: // the run is not over, it resumes or settles without the user. if (event.awaitingAsyncWork === true) { setTerminalTitleState("idle"); this.#showAsyncWait(event); } void this.#finishWhenRunSettles(event); await this.ctx.flushPendingModelSwitch(); // Reaching here means the first guard passed, so `isStreaming` is already // false: a command issued from now on mounts immediately. Leaving earlier // panels queued would render them out of order, minutes later, after the // user was told they were only waiting for the turn. The transcript is // quiescent at a settle, which is the condition #4806 wanted. this.ctx.flushPendingCommandOutput(); return; } setRunStatus(this.#settledRunStatus(event)); await this.#finishAgentEnd(event); // This settle may belong to an extension-started turn while the main // input loop remains asleep. Do not await session-idle from its own event. if (this.ctx.shutdownRequested) this.ctx.requestShutdown(); } /** * Terminal teardown for a non-terminal settle whose continuation never * starts. Its terminal `agent_end` is not guaranteed: an abort cancels a * scheduled retry or compaction continuation before its `agent_start`, and a * cancelled background job enqueues no wake (acknowledged/watched ones are * suppressed). Mirrors `RpcSessionSettleWatcher`: wait out retries, scheduled * continuations, and owner-scoped background work, then — if no new run * started and the session is quiet — run the same teardown a terminal * `agent_end` would. A continuation that does start bumps the epoch and its * own `agent_end` finalizes the run instead; {@link stopAwaitingAsyncWork} * retires the watch and tears down itself. */ async #finishWhenRunSettles(event: Extract): Promise { const epoch = this.#runEpoch; if (this.#settleWatchEpoch === epoch) return; this.#settleWatchEpoch = epoch; const session = this.ctx.session; // No `hasAdmittedSubmission` gate: this very settle is emitted while the // prompt that produced it is still admitted, and a new submission starts a // run whose `agent_start` bumps the epoch anyway. const superseded = () => this.#runEpoch !== epoch || this.#settleWatchEpoch !== epoch || this.ctx.session !== session || session.isStreaming; try { while (!superseded()) { await session.waitForIdle(); if (superseded() || !session.hasPendingAsyncWork()) break; await session.settleAsyncWork(); } await this.#runSerialized(async () => { if ( superseded() || session.hasPendingAsyncWork() || session.hasPostPromptWork || session.queuedMessageCount > 0 ) { return; } setRunStatus(this.#settledRunStatus(event)); await this.#finishAgentEnd(event); if (this.ctx.shutdownRequested) this.ctx.requestShutdown(); }); } catch (error) { logger.warn("Non-terminal settle teardown failed", { error: String(error) }); } finally { if (this.#settleWatchEpoch === epoch) this.#settleWatchEpoch = undefined; } } /** * The run handed control back and only background work can resume it: the * working row says so and names the jobs, instead of repeating the last tool * call's intent. The label is live, so a job cancelled without a wake * (`/jobs kill`) leaves the row at once. A user prompt's own completion * tears its loader down, so mount one for the wait; the settle watch or the * next run takes it over. */ #showAsyncWait(event: Extract): void { const session = this.ctx.session; if (this.ctx.viewSession !== session || !session.hasPendingAsyncWork()) return; this.#asyncWaitEnd = event; this.ctx.ensureLoadingAnimation(); this.ctx.setWorkingMessage(() => formatAsyncWaitLabel(session.getAsyncJobSnapshot()?.running.map(job => job.id) ?? []), ); this.ctx.ui.requestRender(); } /** The run only waits on background work, and the interrupt key would end that wait. */ isAwaitingAsyncWork(): boolean { const session = this.ctx.session; return this.#asyncWaitEnd !== undefined && !session.isStreaming && session.hasPendingAsyncWork(); } /** * End a wait on background work (Esc while {@link isAwaitingAsyncWork}). A * user interrupt keeps a later job result from starting a turn — it lands in * context for the next prompt — and the jobs keep running. Nothing the * settle watch waits for can resume this run any more, so tear it down now * instead of when the last job finishes, and say what is still running. */ async stopAwaitingAsyncWork(): Promise { const event = this.#asyncWaitEnd; if (!event) return; this.#asyncWaitEnd = undefined; this.#settleWatchEpoch = undefined; const session = this.ctx.session; try { // Ending this wait means "stay quiet until I prompt"; latch before awaiting the // abort so a job finishing in between cannot start a turn. session.suppressAutoResumeUntilPrompt(); await session.abort({ reason: USER_INTERRUPT_LABEL }); await this.#runSerialized(async () => { if (this.ctx.session !== session || session.isStreaming) return; setRunStatus({ state: "idle" }); await this.#finishAgentEnd(event, { interrupted: true }); const running = session.getAsyncJobSnapshot()?.running.length ?? 0; if (running > 0) { this.ctx.showStatus( `Stopped waiting · ${running} background ${running === 1 ? "job" : "jobs"} still running (/jobs)`, ); } }); } catch (error) { logger.warn("Ending the background-job wait failed", { error: String(error) }); } } /** * The run status a settled turn leaves: `done` with a result to read, * `error` with the failure, or `idle` when there is nothing new — the user * interrupted, or a pending auto-retry will start the turn over (a cancelled * retry leaves the user at the prompt). Reads the turn's own `agent_end` * messages for the reason `sendErrorNotification` does. */ #settledRunStatus(event: Extract): RunStatus { if (this.#retryPending) return { state: "idle" }; const last = event.messages.findLast((message): message is AssistantMessage => message.role === "assistant"); if (last?.stopReason === "error") return { state: "error", msg: last.errorMessage }; if (!last || last.stopReason === "aborted") return { state: "idle" }; return { state: "done" }; } async #finishAgentEnd( event: Extract, options?: { interrupted?: boolean }, ): Promise { this.#asyncWaitEnd = undefined; this.#setTerminalProgress(false); this.ctx.statusLine.markActivityEnd(); this.#lastAgentEndAt = Date.now(); this.#streamingReveal.stop(); this.#toolArgsReveal.flushAll(); if (this.ctx.loadingAnimation) { this.ctx.loadingAnimation.stop(); this.ctx.loadingAnimation = undefined; this.ctx.statusContainer.disposeChildren(); } // A stale end landing after the viewed session started a new turn must // not mark that live turn idle. this.#runActive = this.ctx.viewSession.isStreaming; this.#syncComposerActivity(); await this.ctx.flushPendingModelSwitch(); this.#sealAbandonedForegroundTools(); this.#blockingPrompts.clear(); this.#readToolCallArgs.clear(); this.#readToolCallAssistantComponents.clear(); this.#priorTurnToolComponents = new Map(this.#toolTimelineComponents); this.#toolTimelineComponents.clear(); this.#streamedToolCallIdByIndex.clear(); this.#pendingStreamPreviews.clear(); this.#retractedToolCallIds.clear(); this.#executionStartedCallIds.clear(); this.#syntheticFailureCards.clear(); this.#orphanedToolCompletions.clear(); this.#postToolAssistantComponents.clear(); this.#toolReplyBlocks.clear(); this.#resetReadGroup(); // The turn is over: nothing else lands this turn, so the waiting poll is // final history — seal it instead of letting its spinner tick while idle. this.#resolveDisplaceablePoll(); this.#resolveDisplaceableTodo(); this.ctx.flushPendingCommandOutput(); this.#lastAssistantComponent = undefined; // When the interrupted/failed turn died on a tool call, this replaces the // torn-down "Working…" row with the "F5 to Retry" affordance. this.ctx.syncRetryHintRow(); this.ctx.ui.requestRender(); // The run yielded: a `/compact` queued behind it runs now. A run the user stopped (Esc, including // the loop-mode pause) did not yield: the request goes back to the editor like other queued input. if (this.ctx.yieldCompaction) { const last = event.messages.findLast((message): message is AssistantMessage => message.role === "assistant"); const userStopped = options?.interrupted || (last?.stopReason === "aborted" && isUserInterruptAbort(last)); if (userStopped) this.ctx.restoreCompactionQueueToEditor("Run stopped: queued compaction returned to the editor"); else this.ctx.runYieldCompaction(); } // The user stopped the run themselves: no prediction or toast for it. if (options?.interrupted) return; if (this.#settledRunStatus(event).state === "done") this.composerPrediction.request(); this.sendErrorNotification(event); this.sendCompletionNotification(event); } /** * Tear down the live "Working…" loader: stop its animation timer AND clear the * reference. A transient overlay (auto-compaction / auto-retry) can remove the * loader from the container while leaving `ctx.loadingAnimation` set, so the * resumed turn's `agent_start` → * `ensureLoadingAnimation()` (guarded by `if (!this.loadingAnimation)`) skipped * re-adding it and the spinner vanished while the agent kept streaming. Nulling * the reference here lets the next `agent_start` recreate and re-attach it. */ #stopWorkingLoader(): void { if (this.ctx.loadingAnimation) { this.ctx.loadingAnimation.stop(); this.ctx.loadingAnimation = undefined; } } /** * Restore the live "Working…" loader when a streaming event lands after a * transient status overlay cleared the container. Focus mode dispatches events * for `viewSession`, so key the reconciler on that session, not the main one. */ #ensureWorkingLoaderWhileStreaming(): void { if (!this.ctx.viewSession.isStreaming) return; if (this.ctx.autoCompactionLoader || this.ctx.retryLoader) return; this.ctx.ensureLoadingAnimation(); } /** * Trailing Esc hint for live maintenance loaders. While a subagent is * focused, Esc returns to main instead of cancelling its maintenance * (#2819), so the loader drops the hint entirely rather than advertise a * cancel that no longer happens. Includes the leading space so the focused * label carries no dangling whitespace. */ #maintenanceEscHint(): string { return this.ctx.focusedAgentId ? "" : ` (${appKey(this.ctx.keybindings, "app.interrupt")} to cancel)`; } async #handleAutoCompactionStart( event: Extract, ): Promise { this.composerPrediction.cancel(); if (!this.#terminalProgressActive) { this.#setTerminalProgress(true); this.#compactionOwnsProgress = this.#terminalProgressActive; } this.#stopWorkingLoader(); this.ctx.statusContainer.disposeChildren(); const reasonText = event.reason === "overflow" ? "Context overflow detected, " : event.reason === "incomplete" ? "Response incomplete, " : event.reason === "idle" ? "Idle " : ""; const actionLabel = event.action === "remote" ? "Auto server compaction" : event.action === "handoff" ? "Auto-handoff" : event.action === "shake" ? "Auto-shake" : event.action === "snapcompact" ? "Auto-snapcompact" : "Auto context-full maintenance"; this.ctx.autoCompactionLoader = new Loader( this.ctx.ui, spinner => theme.fg("accent", spinner), text => theme.fg("muted", text), `${reasonText}${actionLabel}…${this.#maintenanceEscHint()}`, getSymbolTheme().spinnerFrames, ); const compactionStartMs = Date.now(); this.ctx.autoCompactionLoader.setWorkingRow( () => ({ label: `${reasonText}${actionLabel}…`, startedAt: compactionStartMs, variant: { kind: "compaction" }, interruptKey: this.ctx.maintenanceInterruptKey(), }), () => this.ctx.interruptFromPointer(), ); this.ctx.statusContainer.addChild(this.ctx.autoCompactionLoader); this.#maintenanceActive = true; this.#syncComposerActivity(); this.ctx.ui.requestRender(); } async #handleAutoCompactionEnd(event: Extract): Promise { if (this.#compactionOwnsProgress) { this.#compactionOwnsProgress = false; this.#setTerminalProgress(false); } if (this.ctx.autoCompactionLoader) { this.ctx.autoCompactionLoader.stop(); this.ctx.autoCompactionLoader = undefined; this.ctx.statusContainer.disposeChildren(); } this.#maintenanceActive = false; this.#syncComposerActivity(); const isHandoffAction = event.action === "handoff"; const isRemoteAction = event.action === "remote"; const isShakeAction = event.action === "shake"; const isSnapcompactAction = event.action === "snapcompact"; if (event.aborted) { this.ctx.showStatus( isHandoffAction ? "Auto-handoff cancelled" : isRemoteAction ? "Auto server compaction cancelled" : isShakeAction ? "Auto-shake cancelled" : isSnapcompactAction ? "Auto-snapcompact cancelled" : "Auto context-full maintenance cancelled", ); } else if (isShakeAction) { // Shake produces no CompactionResult; rebuild on success, suppress benign skips. // The fallback path (`errorMessage` set, `skipped` false) means shake reclaimed // some tokens before deciding the threshold still wasn't cleared — rebuild so // the chat reflects the dropped regions even though a context-full pass follows. if (event.errorMessage) { if (!event.skipped) { this.ctx.rebuildChatFromMessages(); this.ctx.statusLine.invalidate(); this.ctx.ui.requestRender(true, { clearScrollback: true }); } this.ctx.showWarning(event.errorMessage); } else if (!event.skipped) { this.ctx.lastAssistantUsage = undefined; this.ctx.rebuildChatFromMessages(); this.ctx.statusLine.invalidate(); this.ctx.ui.requestRender(true, { clearScrollback: true }); this.ctx.showStatus("Auto-shake completed"); } } else if (event.result) { this.ctx.lastAssistantUsage = undefined; this.ctx.rebuildChatFromMessages({ reuseSettledComponents: true }); this.ctx.statusLine.invalidate(); // rebuildChatFromMessages clears the container's emission ledger, // so every block re-emits on this frame while the previous copy is // still in native scrollback; the differential resync only ever // adds rows, so without a clear the collapse-disabled arm // duplicates the full transcript (#12140). Pair the rebuild with a // forced scrollback-clearing repaint in both arms — the pairing // transcript-container documents for ledger resets — so compaction // replaces history instead of appending a second copy. The force // matters too: post-clear the frame looks unchanged to the diff. this.ctx.ui.requestRender(true, { clearScrollback: true }); } else if (event.errorMessage) { this.ctx.showWarning(event.errorMessage); } else if (isHandoffAction) { this.ctx.clearTransientSessionUi(); this.ctx.lastAssistantUsage = undefined; await this.ctx.renderInitialMessages(); this.ctx.statusLine.invalidate(); await this.ctx.reloadTodos(); this.ctx.ui.requestRender(true, { clearScrollback: true }); this.ctx.showStatus("Auto-handoff completed"); } else if (event.skipped) { // Benign skip: no model selected, no candidate models available, or nothing // to compact yet. Not a failure — suppress the warning. } else if (isSnapcompactAction) { this.ctx.showWarning("Auto-snapcompact maintenance failed; continuing without maintenance"); } else if (isRemoteAction) { this.ctx.showWarning("Auto server compaction failed; continuing without maintenance"); } else { this.ctx.showWarning("Auto context-full maintenance failed; continuing without maintenance"); } await this.ctx.flushCompactionQueue({ willRetry: event.willRetry }); this.#ensureWorkingLoaderWhileStreaming(); this.ctx.ui.requestRender(); } async #handleAutoRetryStart(event: Extract): Promise { this.#retryPending = true; this.#trackRetrySupersededAssistantComponent(this.#lastAssistantComponent); // A retry supersedes the just-failed turn: its assistant + tool calls are // pruned from context and re-streamed. Remove the cards that a synthetic // aborted/error completion settled in place at message_end so the retry's // fresh cards don't render the same call twice (#6879). Only uncommitted // cards are removable; one already on the scrollback tape stays as history. for (const [toolCallId, component] of this.#syntheticFailureCards) { if (this.ctx.chatContainer.canRemoveBlock(component)) { this.#retractToolCardEntry(toolCallId, component); } } this.#syntheticFailureCards.clear(); this.#stopWorkingLoader(); this.ctx.statusContainer.disposeChildren(); if (AIError.is(event.errorId, AIError.Flag.ThinkingLoop)) { // The retry path drops the failed assistant from runtime context. Do not // restore its inline Error row; just unpin the fixed-region banner so the // retry UI is the visible state. this.#pinnedErrorComponent = undefined; this.#pinnedErrorMessage = undefined; this.#restorePinnedErrorInline = true; this.ctx.clearPinnedError(); } const retryStartMs = Date.now(); const retryLabel = `Retrying (${event.attempt}/${event.maxAttempts})`; this.ctx.retryLoader = new Loader( this.ctx.ui, spinner => theme.fg("warning", spinner), text => theme.fg("muted", text), () => { const remaining = Math.max(0, event.delayMs - (Date.now() - retryStartMs)); return `${retryLabel} in ${formatDuration(remaining)}…${this.#maintenanceEscHint()}`; }, getSymbolTheme().spinnerFrames, ); this.ctx.retryLoader.setWorkingRow( () => ({ label: `Retrying · attempt ${event.attempt} of ${event.maxAttempts}`, startedAt: retryStartMs, variant: { kind: "retry", attempt: event.attempt, max: event.maxAttempts, delayMs: event.delayMs }, interruptKey: this.ctx.maintenanceInterruptKey(), }), () => this.ctx.interruptFromPointer(), ); this.ctx.statusContainer.addChild(this.ctx.retryLoader); this.ctx.ui.requestRender(); } async #handleAutoRetryEnd(event: Extract): Promise { this.#retryPending = false; if (this.ctx.retryLoader) { this.ctx.retryLoader.stop(); this.ctx.retryLoader = undefined; this.ctx.statusContainer.disposeChildren(); } const pinnedError = this.#pinnedErrorMessage?.errorMessage; const terminalFailurePinned = !event.success && this.#pinnedErrorComponent !== undefined && pinnedError !== undefined && pinnedError === event.finalError; let stalePinnedErrorCleared = false; if (!event.success && this.#pinnedErrorComponent && !terminalFailurePinned) { this.#pinnedErrorComponent.setErrorPinned(false); this.#pinnedErrorComponent = undefined; this.#pinnedErrorMessage = undefined; this.#restorePinnedErrorInline = true; this.ctx.clearPinnedError(); stalePinnedErrorCleared = true; } let appliedRetryUpdate = false; for (const retryError of event.retryErrors ?? []) { const component = this.#takeRetrySupersededAssistantComponent(retryError.persistenceKey); if (!component) continue; component.applyRetryRecovery(retryError.retryRecovery); if (!terminalFailurePinned && this.#pinnedErrorComponent === component) { this.#pinnedErrorComponent = undefined; this.#pinnedErrorMessage = undefined; this.#restorePinnedErrorInline = true; } appliedRetryUpdate = true; } if ( !terminalFailurePinned && !stalePinnedErrorCleared && (appliedRetryUpdate || (event.retryErrors?.length ?? 0) > 0) ) { this.ctx.clearPinnedError(); } this.#clearRetrySupersededAssistantComponents(); if (!event.success) { if (terminalFailurePinned) { const terminalError = this.#restorePinnedErrorInline ? `Retry failed after ${event.attempt} attempts: ${event.finalError || pinnedError || "Unknown error"}` : (pinnedError ?? event.finalError); if (terminalError) this.ctx.showPinnedError(terminalError); this.#restorePinnedErrorInline = true; } else { this.ctx.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`); } } this.#ensureWorkingLoaderWhileStreaming(); this.ctx.ui.requestRender(); } async #handleRetryFallbackApplied( event: Extract, ): Promise { const reason = event.reason ? `\n${previewLine(sanitizeText(event.reason), TRUNCATE_LENGTHS.LINE * PREVIEW_LIMITS.COLLAPSED_LINES)}` : ""; this.ctx.showWarning(`Fallback: ${event.from} -> ${event.to}${reason}`); } async #handleRetryFallbackSucceeded( event: Extract, ): Promise { this.ctx.showStatus(`Fallback succeeded on ${event.model}`); } async #handleTtsrTriggered(event: Extract): Promise { // Consecutive notifications (e.g. per-tool matches from one assistant // message) merge into the previous block instead of stacking. Mutating an // existing block is only safe while none of its rows have entered native // scrollback — committed rows are immutable visual history and a grown // block would shift them. const previous = this.#lastTtsrNotification; if ( previous && this.ctx.chatContainer.children.at(-1) === previous && this.ctx.chatContainer.canRemoveBlock(previous) ) { previous.inner.addRules(event.rules); previous.setBlock({ type: "event", id: previous.block.id, event: { type: "ttsr_triggered", rules: previous.inner.rules }, }); this.ctx.ui.requestRender(); return; } const notification = new TtsrNotificationComponent(event.rules); const component = new PresentedBlock(notification, { type: "event", id: eventBlockId("ttsr"), event: { type: "ttsr_triggered", rules: notification.rules }, }); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.present(component); this.#lastTtsrNotification = component; } async #handleTodoReminder(event: Extract): Promise { const { todos, attempt, maxAttempts } = event; const component = new PresentedBlock(new TodoReminderComponent(todos, attempt, maxAttempts), { type: "event", id: eventBlockId("todo-reminder"), event: { type: "todo_reminder", todos, attempt, maxAttempts }, }); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.present(component); } async #handleTodoAutoClear(_event: Extract): Promise { await this.ctx.reloadTodos(); } sendErrorNotification(event: Extract): void { // A running async job or queued delivery will wake the session again, so // its current agent_end is a scheduling pause rather than a user-visible // terminal failure. AgentSession marks that stable outcome before // dispatching the deferred event; do not infer it from mutable job state. if (event.isTerminal === false) return; // `AgentSession` defers and coalesces the wire-level `agent_end` while a // prompt is still in flight (see `#emitSessionEvent` in agent-session.ts): // every mid-retry attempt's own settle is superseded, so this method often // sees only ONE `agent_end` for an entire multi-attempt retry saga — which // can be the final failure, not an intermediate one. Gate purely on the // retry lifecycle (`auto_retry_start`/`auto_retry_end`, which are never // deferred) rather than consuming this flag against whichever `agent_end` // happens to arrive first: `#retryPending` is true only for the actual // window a retry is outstanding, so the settled failure that survives // coalescing is never mistaken for a mid-retry blip. if (this.#retryPending) return; // Warp structured OSC 777 already drives native completion UX when the // protocol is negotiated — avoid a second legacy desktop/OSC-9 toast. if (isWarpCliAgentProtocolActive()) return; const notify = cfgErrorNotify.get(settings); if (notify === "off") return; // Read the turn's own outcome from `agent_end.messages`, not the mutable // active context: a classifier-refusal failure is final (stopReason === // "error") but gets pruned from `viewSession`'s active context before this // handler runs (see `#removeAssistantMessageFromActiveContext` in // agent-session.ts), so `getLastAssistantMessage()` would see a stale or // absent assistant and silently drop the notification. const last = event.messages.findLast((message): message is AssistantMessage => message.role === "assistant"); if (last?.stopReason !== "error") return; const sessionName = this.ctx.sessionManager.getSessionName(); TERMINAL.sendNotification({ title: sessionName || "omp", body: "Stopped with error", type: "error", actions: "focus", }); } sendCompletionNotification(event: Extract): void { const notify = cfgCompletionNotify.get(settings); if (notify === "off") return; // Warp structured OSC 777 already drives native completion UX when the // protocol is negotiated — avoid a second legacy desktop/OSC-9 toast. if (isWarpCliAgentProtocolActive()) return; // Read the turn's own outcome from `agent_end.messages`, not the mutable // active context (see `sendErrorNotification` above for why `viewSession`'s // snapshot can be stale): an aborted or errored turn is not "Task // complete", and using the same event `sendErrorNotification` just read // keeps the two notifications mutually exclusive for one settled turn. const last = event.messages.findLast((message): message is AssistantMessage => message.role === "assistant"); if (last?.stopReason === "aborted" || last?.stopReason === "error") return; const sessionName = this.ctx.sessionManager.getSessionName(); TERMINAL.sendNotification({ title: sessionName || "omp", body: "Complete", type: "completion", actions: "focus", }); } }