import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core"; import { calculatePromptTokens } from "@oh-my-pi/pi-agent-core/compaction/compaction"; import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai"; import { type Component, Loader, TERMINAL } from "@oh-my-pi/pi-tui"; import { settings } from "../../config/settings"; import { getFileSnapshotStore } from "../../edit/file-snapshot-store"; import { AssistantMessageComponent } from "../../modes/components/assistant-message"; import { ReadToolGroupComponent, readArgsHaveTarget, readArgsTargetInternalUrl, } from "../../modes/components/read-tool-group"; import { TodoReminderComponent } from "../../modes/components/todo-reminder"; import { ToolExecutionComponent } from "../../modes/components/tool-execution"; import { TtsrNotificationComponent } from "../../modes/components/ttsr-notification"; import { getSymbolTheme, theme } from "../../modes/theme/theme"; import type { InteractiveModeContext, TodoPhase } from "../../modes/types"; import type { PlanApprovalDetails } from "../../plan-mode/approved-plan"; import type { AgentSessionEvent } from "../../session/agent-session"; import { isSilentAbort, readPendingDisplayTag, resolveAbortLabel } from "../../session/messages"; import type { ResolveToolDetails } from "../../tools/resolve"; import { interruptHint } from "../shared"; import { StreamingRevealController } from "./streaming-reveal"; type AgentSessionEventKind = AgentSessionEvent["type"]; const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000; /** * Loader label shown the instant a user interrupt (Esc) is requested, kept until * the agent turn fully unwinds. Esc fires the abort synchronously, but the loop * only stops the spinner at `agent_end`, which it cannot reach until every * in-flight tool settles its abort in `executeToolCalls` (`Promise.allSettled`). * Swapping the steady "Working…" for this acknowledges the keypress instead of * reading as an ignored Esc for the seconds a slow tool takes to tear down. */ export const INTERRUPTING_WORKING_MESSAGE = "Interrupting…"; // Events that change foreground streaming state, or that reset a turn. The TUI // eager native-scrollback rebuild mode is recomputed only on these so unrelated // IRC/notices/status refreshes do not toggle scrollback replay policy. const STREAM_RENDER_MODE_EVENTS: Record = { agent_start: true, agent_end: true, message_start: true, message_end: true, tool_execution_start: true, tool_execution_update: true, tool_execution_end: true, }; type AgentSessionEventHandlers = { [E in AgentSessionEventKind]: (event: Extract) => Promise; }; export class EventController { #lastReadGroup: ReadToolGroupComponent | 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; #backgroundToolCallIds = new Set(); #assistantMessageStreaming = false; #agentTurnActive = false; #interrupting = false; #readToolCallArgs = new Map>(); #readToolCallAssistantComponents = 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; #idleCompactionTimer?: NodeJS.Timeout; #ircExpiryTimers = new Map(); #streamingReveal: StreamingRevealController; #handlers: AgentSessionEventHandlers; constructor(private ctx: InteractiveModeContext) { this.#streamingReveal = new StreamingRevealController({ getSmoothStreaming: () => this.ctx.settings.get("display.smoothStreaming"), getHideThinkingBlock: () => this.ctx.hideThinkingBlock, requestRender: () => this.ctx.ui.requestRender(), }); this.#handlers = { agent_start: e => this.#handleAgentStart(e), agent_end: e => this.#handleAgentEnd(e), turn_start: async () => {}, turn_end: async () => {}, 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_execution_end: e => this.#handleToolExecutionEnd(e), auto_compaction_start: e => this.#handleAutoCompactionStart(e), auto_compaction_end: e => this.#handleAutoCompactionEnd(e), auto_retry_start: e => this.#handleAutoRetryStart(e), auto_retry_end: e => this.#handleAutoRetryEnd(e), 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), thinking_level_changed: async () => { this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); this.ctx.ui.requestRender(); }, goal_updated: async () => {}, } satisfies AgentSessionEventHandlers; } dispose(): void { this.#streamingReveal.stop(); this.#cancelIdleCompaction(); for (const timer of this.#ircExpiryTimers.values()) { clearTimeout(timer); } this.#ircExpiryTimers.clear(); } #resetReadGroup(): void { this.#lastReadGroup?.finalize(); this.#lastReadGroup = undefined; } #getReadGroup(): ReadToolGroupComponent { if (!this.#lastReadGroup) { const group = new ReadToolGroupComponent({ showContentPreview: this.ctx.settings.get("read.toolResultPreview"), }); 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); } } #clearReadToolCall(toolCallId: string): void { this.#readToolCallArgs.delete(toolCallId); this.#readToolCallAssistantComponents.delete(toolCallId); } #inlineReadToolImages( toolCallId: string, result: { content: Array<{ type: string; data?: string; mimeType?: string }> }, ): boolean { if (!settings.get("terminal.showImages")) return false; 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 })); if (images.length === 0) return false; assistantComponent.setToolResultImages(toolCallId, images); return true; } #updateWorkingMessageFromIntent(intent: unknown): void { if (this.#interrupting) return; // Streamed JSON can deliver non-string `_i` (object, number, boolean) before // schema validation; `?.` only guards null/undefined, so guard the type too. if (typeof intent !== "string") return; const trimmed = intent.trim(); if (!trimmed || trimmed === this.#lastIntent) return; this.#lastIntent = trimmed; this.ctx.setWorkingMessage(`${trimmed}${interruptHint()}`); } /** * Acknowledge a user interrupt (Esc) immediately: switch the loader to * `INTERRUPTING_WORKING_MESSAGE` and freeze intent-driven working-message * updates for the rest of the turn so a late `tool_execution_start` intent * cannot repaint a "Working…/" line over the acknowledgment. Reset at * the next `agent_start`. No-op outside an active turn or if already set. */ notifyInterrupting(): void { if (!this.#agentTurnActive || this.#interrupting) return; this.#interrupting = true; this.ctx.setWorkingMessage(INTERRUPTING_WORKING_MESSAGE); } subscribeToAgent(): void { this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => { await this.handleEvent(event); }); } async handleEvent(event: AgentSessionEvent): Promise { if (!this.ctx.isInitialized) { await this.ctx.init(); } this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise; await run(event); // While an assistant turn is active, visible status chrome and foreground // transcript blocks can re-render after rows have entered native scrollback // (idle Working loader, Markdown fences, wrapping, tool previews). Let the // TUI use its foreground live-region path instead of idle deferral, which // can otherwise leave the loader/status frame frozen until the next input. // Background-running tools after the turn ends are excluded so late async // updates keep the no-yank deferral; agent_start/agent_end bracket the // foreground turn. if (STREAM_RENDER_MODE_EVENTS[event.type]) { this.#refreshToolRenderMode(); } } #refreshToolRenderMode(): void { let foregroundToolActive = this.#agentTurnActive || this.#assistantMessageStreaming; if (!foregroundToolActive) { for (const toolCallId of this.ctx.pendingTools.keys()) { if (!this.#backgroundToolCallIds.has(toolCallId)) { foregroundToolActive = true; break; } } } this.ctx.ui.setEagerNativeScrollbackRebuild(foregroundToolActive); } async #handleAgentStart(_event: Extract): Promise { this.#agentTurnActive = true; this.#interrupting = false; this.#lastIntent = undefined; this.#readToolCallArgs.clear(); this.#readToolCallAssistantComponents.clear(); this.#resetReadGroup(); this.#assistantMessageStreaming = false; this.#lastAssistantComponent = undefined; // Restore the previous turn's inline error in the transcript before dropping // the banner, so the error stays in history once the banner is gone. this.#pinnedErrorComponent?.setErrorPinned(false); this.#pinnedErrorComponent = undefined; this.ctx.clearPinnedError(); if (this.ctx.retryEscapeHandler) { this.ctx.editor.onEscape = this.ctx.retryEscapeHandler; this.ctx.retryEscapeHandler = undefined; } if (this.ctx.retryLoader) { this.ctx.retryLoader.stop(); this.ctx.retryLoader = undefined; this.ctx.statusContainer.clear(); } this.#cancelIdleCompaction(); this.#refreshToolRenderMode(); this.ctx.ensureLoadingAnimation(); this.ctx.ui.requestRender(); } async #handleMessageStart(event: Extract): Promise { if (event.message.role === "hookMessage" || event.message.role === "custom") { const signature = `${event.message.role}:${event.message.customType}:${event.message.timestamp}`; if (this.#renderedCustomMessages.has(signature)) { return; } this.#renderedCustomMessages.add(signature); this.#resetReadGroup(); this.ctx.addMessageToChat(event.message); // Tag-keyed pending-bar refresh: when AgentSession.#handleAgentEvent // spliced this dequeued custom message out of #steeringMessages / // #followUpMessages (it ran before this emit), the array state is // already correct — pendingMessagesContainer just needs to be // re-rendered to match. Gated on tag presence so non-queued customs // (ttsr-injection, irc:*, async-result, hookMessage) skip the // rebuild; their dispatch path never registered a pending chip. // Mirrors the user-role refresh at the bottom of this function. if (event.message.role === "custom" && readPendingDisplayTag(event.message.details)) { this.ctx.updatePendingMessagesDisplay(); } this.ctx.ui.requestRender(); } else if (event.message.role === "user") { const textContent = this.ctx.getUserMessageText(event.message); 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 = `${textContent}\u0000${imageCount}`; this.#resetReadGroup(); const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature; const wasLocallySubmitted = this.ctx.locallySubmittedUserSignatures.delete(signature) || wasOptimistic; if (!wasOptimistic) { // 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); } if (wasOptimistic) { this.ctx.optimisticUserMessageSignature = undefined; } // Clear the editor only when the submission did not originate from a // local submission (optimistic or queued-while-streaming). Both local // paths already cleared the editor at submit time; clearing again here // would race with the user typing the next prompt while the previous // large redraw lands and erase their in-progress draft (#783). if (!event.message.synthetic) { if (!wasLocallySubmitted) { this.ctx.editor.setText(""); } this.ctx.updatePendingMessagesDisplay(); } this.ctx.ui.requestRender(); } else if (event.message.role === "fileMention") { this.#resetReadGroup(); this.ctx.addMessageToChat(event.message); this.ctx.ui.requestRender(); } else if (event.message.role === "assistant") { this.#assistantMessageStreaming = true; this.#lastVisibleBlockCount = 0; this.ctx.streamingComponent = new AssistantMessageComponent( undefined, this.ctx.hideThinkingBlock, () => this.ctx.ui.requestRender(), this.ctx.session.extensionRunner?.getAssistantThinkingRenderers(), this.ctx.ui.imageBudget, ); this.ctx.streamingMessage = event.message; this.ctx.chatContainer.addChild(this.ctx.streamingComponent); this.#streamingReveal.begin(this.ctx.streamingComponent, this.ctx.streamingMessage); this.ctx.ui.requestRender(); } } async #handleIrcMessage(event: Extract): Promise { 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.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); for (const component of components) { this.ctx.chatContainer.removeChild(component); } this.ctx.ui.requestRender(); }, IRC_MESSAGE_VISIBLE_TTL_MS); timer.unref?.(); this.#ircExpiryTimers.set(signature, timer); } 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); } } async #handleMessageUpdate(event: Extract): Promise { if (this.ctx.streamingComponent && event.message.role === "assistant") { this.ctx.streamingMessage = event.message; this.#streamingReveal.setTarget(this.ctx.streamingMessage); const visibleBlockCount = this.ctx.streamingMessage.content.filter( content => (content.type === "text" && content.text.trim().length > 0) || (content.type === "thinking" && content.thinking.trim().length > 0), ).length; if (visibleBlockCount > this.#lastVisibleBlockCount) { this.#resetReadGroup(); this.#lastVisibleBlockCount = visibleBlockCount; } for (const content of this.ctx.streamingMessage.content) { if (content.type !== "toolCall") continue; if (content.name === "read") { if (!readArgsHaveTarget(content.arguments)) { // Args still streaming — defer until path is parseable so we can route to the // read group (regular files) vs ToolExecutionComponent (internal URLs). // Creating either component now would lock the read into the wrong shape. continue; } if (!readArgsTargetInternalUrl(content.arguments)) { this.#trackReadToolCall(content.id, content.arguments); const component = this.ctx.pendingTools.get(content.id); if (component) { component.updateArgs(content.arguments, content.id); } else { const group = this.#getReadGroup(); group.updateArgs(content.arguments, content.id); this.ctx.pendingTools.set(content.id, group); } continue; } // Internal URL read falls through to ToolExecutionComponent below. } // Preserve the raw partial JSON 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. const renderArgs = "partialJson" in content ? { ...content.arguments, __partialJson: content.partialJson } : content.arguments; if (!this.ctx.pendingTools.has(content.id)) { this.#resetReadGroup(); const tool = this.ctx.session.getToolByName(content.name); const component = new ToolExecutionComponent( content.name, renderArgs, { snapshots: getFileSnapshotStore(this.ctx.session), showImages: settings.get("terminal.showImages"), editFuzzyThreshold: settings.get("edit.fuzzyThreshold"), editAllowFuzzy: settings.get("edit.fuzzyMatch"), }, tool, this.ctx.ui, this.ctx.sessionManager.getCwd(), content.id, ); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(component); this.ctx.pendingTools.set(content.id, component); } else { const component = this.ctx.pendingTools.get(content.id); if (component) { component.updateArgs(renderArgs, content.id); } } } // Update working message with intent from streamed tool arguments for (const content of this.ctx.streamingMessage.content) { if (content.type !== "toolCall") continue; const args = content.arguments; if (!args || typeof args !== "object") continue; if (INTENT_FIELD in args) { this.#updateWorkingMessageFromIntent(args[INTENT_FIELD]); continue; } const tool = this.ctx.session.getToolByName(content.name); if (typeof tool?.intent !== "function") continue; try { const derived = tool.intent(args as never)?.trim(); if (derived) { this.#updateWorkingMessageFromIntent(derived); } } catch { // intent function must never break the UI } } this.ctx.ui.requestRender(); } } async #handleMessageEnd(event: Extract): Promise { if (event.message.role === "user") return; if (event.message.role === "assistant") { this.#assistantMessageStreaming = false; } if (this.ctx.streamingComponent && event.message.role === "assistant") { this.ctx.streamingMessage = event.message; this.#streamingReveal.stop(); let errorMessage: string | undefined; const aborted = this.ctx.streamingMessage.stopReason === "aborted"; const silentlyAborted = aborted && isSilentAbort(this.ctx.streamingMessage.errorMessage); const ttsrSilenced = aborted && this.ctx.session.isTtsrAbortPending; if (aborted && !silentlyAborted && !ttsrSilenced) { // 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 SILENT_ABORT_MARKER for // the plan-compact transition before this controller ran, so reaching // this branch implies the abort was NOT a silent internal transition. errorMessage = resolveAbortLabel(this.ctx.streamingMessage.errorMessage, this.ctx.session.retryAttempt); this.ctx.streamingMessage.errorMessage = errorMessage; } if (silentlyAborted || ttsrSilenced) { // Silence the streaming render by downgrading stopReason to "stop" for // display only — does NOT mutate the persisted message's stopReason // (the marker on errorMessage drives replay-side suppression). const msgWithoutAbort = { ...this.ctx.streamingMessage, stopReason: "stop" as const }; this.ctx.streamingComponent.updateContent(msgWithoutAbort); } else { this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage); } 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 (abort/error/TTSR rewind), // so they will never produce a result. Seal them so they stop animating // and freeze instead of pinning the transcript live region while a retry // streams fresh blocks below them. Background tools keep updating. for (const [toolCallId, component] of this.ctx.pendingTools.entries()) { if (!this.#backgroundToolCallIds.has(toolCallId) && component instanceof ToolExecutionComponent) { component.seal(); } } } this.#lastAssistantComponent = this.ctx.streamingComponent; this.#lastAssistantComponent.setUsageInfo(event.message.usage); this.#lastAssistantComponent.markTranscriptBlockFinalized(); this.ctx.streamingComponent = undefined; this.ctx.streamingMessage = undefined; // Pin a turn-ending provider error (e.g. Anthropic content-filter block) // above the editor so it survives transcript scroll. Cleared at the next // turn's agent_start. Suppress the transcript's inline `Error: …` line for // the same message while pinned so the error isn't rendered twice. if ( event.message.stopReason === "error" && event.message.errorMessage && !isSilentAbort(event.message.errorMessage) ) { this.#lastAssistantComponent?.setErrorPinned(true); this.#pinnedErrorComponent = this.#lastAssistantComponent; this.ctx.showPinnedError(event.message.errorMessage); } this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); } this.ctx.ui.requestRender(); } async #handleToolExecutionStart(event: Extract): Promise { this.#updateWorkingMessageFromIntent(event.intent); if (!this.ctx.pendingTools.has(event.toolCallId)) { if (event.toolName === "read" && readArgsHaveTarget(event.args) && !readArgsTargetInternalUrl(event.args)) { this.#trackReadToolCall(event.toolCallId, event.args); const component = this.ctx.pendingTools.get(event.toolCallId); if (component) { component.updateArgs(event.args, event.toolCallId); } else { const group = this.#getReadGroup(); group.updateArgs(event.args, event.toolCallId); this.ctx.pendingTools.set(event.toolCallId, group); } this.ctx.ui.requestRender(); return; } this.#resetReadGroup(); const tool = this.ctx.session.getToolByName(event.toolName); const component = new ToolExecutionComponent( event.toolName, event.args, { snapshots: getFileSnapshotStore(this.ctx.session), showImages: settings.get("terminal.showImages"), editFuzzyThreshold: settings.get("edit.fuzzyThreshold"), editAllowFuzzy: settings.get("edit.fuzzyMatch"), }, tool, this.ctx.ui, this.ctx.sessionManager.getCwd(), event.toolCallId, ); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(component); this.ctx.pendingTools.set(event.toolCallId, component); this.ctx.ui.requestRender(); } } async #handleToolExecutionUpdate( event: Extract, ): Promise { const component = this.ctx.pendingTools.get(event.toolCallId); if (component) { const asyncState = (event.partialResult.details as { async?: { state?: string } } | undefined)?.async?.state; const isFinalAsyncState = asyncState === "completed" || asyncState === "failed"; component.updateResult( { ...event.partialResult, isError: asyncState === "failed" }, !isFinalAsyncState, event.toolCallId, ); if (isFinalAsyncState) { this.ctx.pendingTools.delete(event.toolCallId); this.#backgroundToolCallIds.delete(event.toolCallId); } this.ctx.ui.requestRender(); } } async #handleToolExecutionEnd(event: Extract): Promise { 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); } const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state; if (asyncState === "running") { this.#backgroundToolCallIds.add(event.toolCallId); } else { this.#backgroundToolCallIds.delete(event.toolCallId); this.#clearReadToolCall(event.toolCallId); } this.ctx.ui.requestRender(); } else { let component = this.ctx.pendingTools.get(event.toolCallId); if (!component) { const group = this.#getReadGroup(); const args = this.#readToolCallArgs.get(event.toolCallId); if (args) { group.updateArgs(args, event.toolCallId); } component = group; this.ctx.pendingTools.set(event.toolCallId, group); } const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state; const isBackgroundRunning = asyncState === "running"; component.updateResult({ ...event.result, isError: event.isError }, isBackgroundRunning, event.toolCallId); if (isBackgroundRunning) { this.#backgroundToolCallIds.add(event.toolCallId); } else { this.ctx.pendingTools.delete(event.toolCallId); this.#backgroundToolCallIds.delete(event.toolCallId); this.#clearReadToolCall(event.toolCallId); } this.ctx.ui.requestRender(); } } else { const component = this.ctx.pendingTools.get(event.toolCallId); if (component) { const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state; const isBackgroundRunning = asyncState === "running"; component.updateResult({ ...event.result, isError: event.isError }, isBackgroundRunning, event.toolCallId); if (isBackgroundRunning) { this.#backgroundToolCallIds.add(event.toolCallId); } else { this.ctx.pendingTools.delete(event.toolCallId); this.#backgroundToolCallIds.delete(event.toolCallId); } this.ctx.ui.requestRender(); } } // Update todo display when todo tool completes if (event.toolName === "todo" && !event.isError) { const details = event.result.details as { phases?: TodoPhase[] } | undefined; if (details?.phases) { this.ctx.setTodos(details.phases); } } else if (event.toolName === "todo" && event.isError) { const textContent = event.result.content.find( (content: { type: string; text?: string }) => content.type === "text", )?.text; this.ctx.showWarning( `Todo update failed${textContent ? `: ${textContent}` : ". Progress may be stale until todo succeeds."}`, ); } if (event.toolName === "resolve" && !event.isError) { const details = event.result.details as ResolveToolDetails | undefined; if (details?.sourceToolName === "plan_approval" && details.action === "apply") { const planDetails = details.sourceResultDetails as PlanApprovalDetails | undefined; if (planDetails) { await this.ctx.handlePlanApproval(planDetails); } } } } async #handleAgentEnd(_event: Extract): Promise { this.#agentTurnActive = false; this.#assistantMessageStreaming = false; this.#streamingReveal.stop(); if (this.ctx.loadingAnimation) { this.ctx.loadingAnimation.stop(); this.ctx.loadingAnimation = undefined; this.ctx.statusContainer.clear(); } if (this.ctx.streamingComponent) { this.ctx.chatContainer.removeChild(this.ctx.streamingComponent); this.ctx.streamingComponent = undefined; this.ctx.streamingMessage = undefined; } await this.ctx.flushPendingModelSwitch(); for (const toolCallId of Array.from(this.ctx.pendingTools.keys())) { if (!this.#backgroundToolCallIds.has(toolCallId)) { // A foreground tool still pending at turn end never delivered a result; // seal it so it freezes (and stops animating) rather than lingering in // the transcript live region as a streaming preview until the next thaw. const component = this.ctx.pendingTools.get(toolCallId); if (component instanceof ToolExecutionComponent) component.seal(); this.ctx.pendingTools.delete(toolCallId); } } this.#backgroundToolCallIds = new Set( Array.from(this.#backgroundToolCallIds).filter(toolCallId => this.ctx.pendingTools.has(toolCallId)), ); this.#readToolCallArgs.clear(); this.#readToolCallAssistantComponents.clear(); this.#resetReadGroup(); this.#lastAssistantComponent = undefined; this.ctx.ui.requestRender(); this.#scheduleIdleCompaction(); this.sendCompletionNotification(); } async #handleAutoCompactionStart( event: Extract, ): Promise { this.#cancelIdleCompaction(); this.ctx.autoCompactionEscapeHandler = this.ctx.editor.onEscape; this.ctx.editor.onEscape = () => { this.ctx.session.abortCompaction(); }; this.ctx.statusContainer.clear(); const reasonText = event.reason === "overflow" ? "Context overflow detected, " : event.reason === "incomplete" ? "Response incomplete, " : event.reason === "idle" ? "Idle " : ""; const actionLabel = event.action === "handoff" ? "Auto-handoff" : event.action === "shake" ? "Auto-shake" : "Auto context-full maintenance"; this.ctx.autoCompactionLoader = new Loader( this.ctx.ui, spinner => theme.fg("accent", spinner), text => theme.fg("muted", text), `${reasonText}${actionLabel}… (esc to cancel)`, getSymbolTheme().spinnerFrames, ); this.ctx.statusContainer.addChild(this.ctx.autoCompactionLoader); this.ctx.ui.requestRender(); } async #handleAutoCompactionEnd(event: Extract): Promise { this.#cancelIdleCompaction(); if (this.ctx.autoCompactionEscapeHandler) { this.ctx.editor.onEscape = this.ctx.autoCompactionEscapeHandler; this.ctx.autoCompactionEscapeHandler = undefined; } if (this.ctx.autoCompactionLoader) { this.ctx.autoCompactionLoader.stop(); this.ctx.autoCompactionLoader = undefined; this.ctx.statusContainer.clear(); } const isHandoffAction = event.action === "handoff"; const isShakeAction = event.action === "shake"; if (event.aborted) { this.ctx.showStatus( isHandoffAction ? "Auto-handoff cancelled" : isShakeAction ? "Auto-shake cancelled" : "Auto context-full maintenance cancelled", ); } else if (isShakeAction) { // Shake produces no CompactionResult; rebuild on success, suppress benign skips. if (event.errorMessage) { this.ctx.showWarning(event.errorMessage); } else if (!event.skipped) { this.ctx.rebuildChatFromMessages(); this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); this.ctx.showStatus("Auto-shake completed"); } } else if (event.result) { this.ctx.rebuildChatFromMessages(); this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); } else if (event.errorMessage) { this.ctx.showWarning(event.errorMessage); } else if (isHandoffAction) { this.ctx.chatContainer.clear(); this.ctx.rebuildChatFromMessages(); this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); await this.ctx.reloadTodos(); 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 { this.ctx.showWarning("Auto context-full maintenance failed; continuing without maintenance"); } await this.ctx.flushCompactionQueue({ willRetry: event.willRetry }); this.ctx.ui.requestRender(); } async #handleAutoRetryStart(event: Extract): Promise { this.ctx.retryEscapeHandler = this.ctx.editor.onEscape; this.ctx.editor.onEscape = () => { this.ctx.session.abortRetry(); }; this.ctx.statusContainer.clear(); const delaySeconds = Math.round(event.delayMs / 1000); this.ctx.retryLoader = new Loader( this.ctx.ui, spinner => theme.fg("warning", spinner), text => theme.fg("muted", text), `Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s… (esc to cancel)`, getSymbolTheme().spinnerFrames, ); this.ctx.statusContainer.addChild(this.ctx.retryLoader); this.ctx.ui.requestRender(); } async #handleAutoRetryEnd(event: Extract): Promise { if (this.ctx.retryEscapeHandler) { this.ctx.editor.onEscape = this.ctx.retryEscapeHandler; this.ctx.retryEscapeHandler = undefined; } if (this.ctx.retryLoader) { this.ctx.retryLoader.stop(); this.ctx.retryLoader = undefined; this.ctx.statusContainer.clear(); } if (!event.success) { this.ctx.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`); } this.ctx.ui.requestRender(); } async #handleRetryFallbackApplied( event: Extract, ): Promise { this.ctx.showWarning(`Fallback: ${event.from} -> ${event.to}`); } async #handleRetryFallbackSucceeded( event: Extract, ): Promise { this.ctx.showStatus(`Fallback succeeded on ${event.model}`); } async #handleTtsrTriggered(event: Extract): Promise { const component = new TtsrNotificationComponent(event.rules); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.present(component); } async #handleTodoReminder(event: Extract): Promise { const component = new TodoReminderComponent(event.todos, event.attempt, event.maxAttempts); this.ctx.present(component); } async #handleTodoAutoClear(_event: Extract): Promise { await this.ctx.reloadTodos(); } #cancelIdleCompaction(): void { if (this.#idleCompactionTimer) { clearTimeout(this.#idleCompactionTimer); this.#idleCompactionTimer = undefined; } } #scheduleIdleCompaction(): void { this.#cancelIdleCompaction(); // Don't schedule idle work while context maintenance is already running; the // maintenance flow may reset the session before this timer fires. if (this.ctx.session.isCompacting) return; const idleSettings = settings.getGroup("compaction"); if (!idleSettings.idleEnabled) return; // Only if input is empty if (this.ctx.editor.getText().trim()) return; const threshold = idleSettings.idleThresholdTokens; if (threshold <= 0) return; if (this.#currentContextTokens() < threshold) return; const timeoutMs = Math.max(60, Math.min(3600, idleSettings.idleTimeoutSeconds)) * 1000; this.#idleCompactionTimer = setTimeout(() => { this.#idleCompactionTimer = undefined; // Re-check conditions before firing. Pruning may have run between arming // the timer and now, dropping usage back below the idle threshold. if (this.ctx.session.isStreaming) return; if (this.ctx.session.isCompacting) return; if (this.ctx.editor.getText().trim()) return; if (this.#currentContextTokens() < threshold) return; void this.ctx.session.runIdleCompaction(); }, timeoutMs); this.#idleCompactionTimer.unref?.(); } #currentContextTokens(): number { const lastAssistant = this.ctx.session.agent.state.messages .slice() .reverse() .find((m): m is AssistantMessage => m.role === "assistant" && m.stopReason !== "aborted"); return lastAssistant?.usage ? calculatePromptTokens(lastAssistant.usage) : 0; } sendCompletionNotification(): void { const notify = settings.get("completion.notify"); if (notify === "off") return; // Skip when the turn was aborted (e.g. ask cancelled with Ctrl+C) or // errored — those are not "Task complete" events. Mirrors the gate // already used by #currentContextTokens, #handleMessageEnd, and the // retry / TTSR / compaction skip paths across agent-session.ts. const last = this.ctx.session.getLastAssistantMessage?.(); if (last?.stopReason === "aborted" || last?.stopReason === "error") return; const sessionName = this.ctx.sessionManager.getSessionName(); TERMINAL.sendNotification({ title: sessionName || "Oh My Pi", body: "Complete", type: "completion", actions: "focus", }); } }