singularity-forge/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts
Jeremy dc84694c65 fix(tui): stop pinned latest-output mirror from duplicating streaming text
The pinned `Working · Latest Output` border above the editor mirrors
the assistant's latest text block while tools run, so prose stays
visible after a tool's output scrolls it off-screen. The mirror walked
content blocks from the end and picked the last text block — but when
the assistant streams a *new* text block after a tool call (sequence
`[text1, tool1, text2_streaming]`), it picked `text2`, which was also
being streamed live into the chat container. Result: identical tokens
rendered in two places at once.

Restrict the search to text blocks whose index is strictly less than
the index of the most recent tool call. Text after the last tool call
stays in the chat container only; earlier prose (e.g. `text1`) remains
mirrored the entire time the new text streams, so context isn't lost
and the loading-animation handoff is undisturbed.

Fixes #4120

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 08:16:16 -05:00

570 lines
20 KiB
TypeScript

import { Loader, Markdown, Spacer, Text } from "@gsd/pi-tui";
import type { InteractiveModeEvent, InteractiveModeStateHost } from "../interactive-mode-state.js";
import { theme } from "../theme/theme.js";
import { AssistantMessageComponent } from "../components/assistant-message.js";
import { ToolExecutionComponent } from "../components/tool-execution.js";
import { DynamicBorder } from "../components/dynamic-border.js";
import { appKey } from "../components/keybinding-hints.js";
// Tracks the last processed content index to avoid re-scanning all blocks on every message_update
let lastProcessedContentIndex = 0;
function hasVisibleAssistantContent(message: { content: Array<any> }): boolean {
return message.content.some(
(c) =>
(c.type === "text" && typeof c.text === "string" && c.text.trim().length > 0)
|| (c.type === "thinking" && typeof c.thinking === "string" && c.thinking.trim().length > 0),
);
}
function hasAssistantToolBlocks(message: { content: Array<any> }): boolean {
return message.content.some((c) => c.type === "toolCall" || c.type === "serverToolUse");
}
// Tracks the latest assistant text for the pinned message zone
let lastPinnedText = "";
// Whether any tool execution has been added in this assistant turn (triggers pinned display)
let hasToolsInTurn = false;
// Reference to the pinned border so we can toggle its label between working/idle
let pinnedBorder: DynamicBorder | undefined;
// Reference to the pinned markdown component below the border
let pinnedTextComponent: Markdown | undefined;
export async function handleAgentEvent(host: InteractiveModeStateHost & {
init: () => Promise<void>;
getMarkdownThemeWithSettings: () => any;
addMessageToChat: (message: any, options?: any) => void;
formatWebSearchResult: (content: unknown) => string;
getRegisteredToolDefinition: (toolName: string) => any;
checkShutdownRequested: () => Promise<void>;
rebuildChatFromMessages: () => void;
flushCompactionQueue: (options?: { willRetry?: boolean }) => Promise<void>;
showStatus: (message: string) => void;
showError: (message: string) => void;
updatePendingMessagesDisplay: () => void;
updateTerminalTitle: () => void;
updateEditorBorderColor: () => void;
pendingMessagesContainer: { clear: () => void };
}, event: InteractiveModeEvent): Promise<void> {
if (!host.isInitialized) {
await host.init();
}
host.footer.invalidate();
// Reset content index tracker and pinned state when a new assistant message starts
if (event.type === "message_start" && event.message.role === "assistant") {
lastProcessedContentIndex = 0;
lastPinnedText = "";
hasToolsInTurn = false;
if (pinnedBorder) pinnedBorder.stopSpinner();
pinnedBorder = undefined;
pinnedTextComponent = undefined;
host.pinnedMessageContainer.clear();
}
switch (event.type) {
case "session_state_changed":
switch (event.reason) {
case "new_session":
case "switch_session":
case "fork":
host.streamingComponent = undefined;
host.streamingMessage = undefined;
host.pendingTools.clear();
host.pendingMessagesContainer.clear();
host.pinnedMessageContainer.clear();
lastPinnedText = "";
hasToolsInTurn = false;
if (pinnedBorder) pinnedBorder.stopSpinner();
pinnedBorder = undefined;
pinnedTextComponent = undefined;
host.compactionQueuedMessages = [];
host.rebuildChatFromMessages();
host.updatePendingMessagesDisplay();
host.updateTerminalTitle();
host.updateEditorBorderColor();
host.ui.requestRender();
return;
case "set_session_name":
host.updateTerminalTitle();
host.ui.requestRender();
return;
case "set_model":
case "set_thinking_level":
host.updateEditorBorderColor();
host.ui.requestRender();
return;
default:
host.ui.requestRender();
return;
}
case "agent_start":
if (host.retryEscapeHandler) {
host.defaultEditor.onEscape = host.retryEscapeHandler;
host.retryEscapeHandler = undefined;
}
if (host.retryLoader) {
host.retryLoader.stop();
host.retryLoader = undefined;
}
if (host.loadingAnimation) {
host.loadingAnimation.stop();
}
host.statusContainer.clear();
host.loadingAnimation = new Loader(
host.ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
host.defaultWorkingMessage,
);
host.statusContainer.addChild(host.loadingAnimation);
if (host.pendingWorkingMessage !== undefined) {
if (host.pendingWorkingMessage) {
host.loadingAnimation.setMessage(host.pendingWorkingMessage);
}
host.pendingWorkingMessage = undefined;
}
host.ui.requestRender();
break;
case "message_start":
if (event.message.role === "custom") {
host.addMessageToChat(event.message);
host.ui.requestRender();
} else if (event.message.role === "user") {
host.addMessageToChat(event.message);
host.updatePendingMessagesDisplay();
host.ui.requestRender();
} else if (event.message.role === "assistant") {
host.streamingMessage = event.message;
// External-tool providers can stream multiple assistant turns through
// one response. Delay component creation until visible assistant text
// arrives so tool outputs keep chronological ordering.
host.ui.requestRender();
}
break;
case "message_update":
if (event.message.role === "assistant") {
host.streamingMessage = event.message;
const innerEvent = event.assistantMessageEvent;
let externalToolResult:
| { toolCallId: string; content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; details: Record<string, unknown>; isError: boolean }
| undefined;
if (innerEvent.type === "toolcall_end" && innerEvent.toolCall) {
const tc = innerEvent.toolCall as any;
const ext = tc.externalResult;
if (ext) {
externalToolResult = {
toolCallId: tc.id,
content: ext.content ?? [{ type: "text", text: "" }],
details: ext.details ?? {},
isError: ext.isError ?? false,
};
}
} else if (innerEvent.type === "server_tool_use") {
const idx = typeof innerEvent.contentIndex === "number" ? innerEvent.contentIndex : -1;
const block = idx >= 0 ? (host.streamingMessage.content[idx] as any) : undefined;
const ext = block?.externalResult;
if (block?.id && ext) {
externalToolResult = {
toolCallId: block.id,
content: ext.content ?? [{ type: "text", text: "" }],
details: ext.details ?? {},
isError: ext.isError ?? false,
};
}
}
const contentBlocks = host.streamingMessage.content;
// Some adapters reuse a single assistant lifecycle while internally
// spanning multiple provider turns. When a new turn starts, content
// length can shrink back to 0/1; reset scan index to avoid skipping.
if (lastProcessedContentIndex >= contentBlocks.length) {
lastProcessedContentIndex = 0;
}
for (let i = lastProcessedContentIndex; i < contentBlocks.length; i++) {
const content = contentBlocks[i];
if (content.type === "toolCall") {
if (!host.pendingTools.has(content.id)) {
const component = new ToolExecutionComponent(
content.name,
content.arguments,
{ showImages: host.settingsManager.getShowImages() },
host.getRegisteredToolDefinition(content.name),
host.ui,
);
component.setExpanded(host.toolOutputExpanded);
host.chatContainer.addChild(component);
host.pendingTools.set(content.id, component);
} else {
host.pendingTools.get(content.id)?.updateArgs(content.arguments);
}
} else if (content.type === "serverToolUse") {
if (!host.pendingTools.has(content.id)) {
const component = new ToolExecutionComponent(
content.name,
content.input ?? {},
{ showImages: host.settingsManager.getShowImages() },
undefined,
host.ui,
);
component.setExpanded(host.toolOutputExpanded);
host.chatContainer.addChild(component);
host.pendingTools.set(content.id, component);
}
} else if (content.type === "webSearchResult") {
const component = host.pendingTools.get(content.toolUseId);
if (component) {
if (process.env.PI_OFFLINE === "1") {
component.updateResult({
content: [{ type: "text", text: "Web search disabled (offline mode)" }],
isError: false,
});
} else {
const searchContent = content.content;
const isError = searchContent && typeof searchContent === "object" && "type" in (searchContent as any) && (searchContent as any).type === "web_search_tool_result_error";
component.updateResult({
content: [{ type: "text", text: host.formatWebSearchResult(searchContent) }],
isError: !!isError,
});
}
}
}
}
// When the stream adapter signals a completed tool call with an
// external result (from Claude Code SDK), update the pending
// ToolExecutionComponent immediately so output is visible in
// real-time instead of waiting for the session to end.
if (externalToolResult) {
const component = host.pendingTools.get(externalToolResult.toolCallId);
if (component) {
component.updateResult({
content: externalToolResult.content,
details: externalToolResult.details,
isError: externalToolResult.isError,
});
}
}
// Render assistant text/thinking after tool components so mixed
// streams keep chronological ordering in the chat container.
const hasToolBlocks = hasAssistantToolBlocks(host.streamingMessage);
if (!host.streamingComponent && hasVisibleAssistantContent(host.streamingMessage)) {
host.streamingComponent = new AssistantMessageComponent(
undefined,
host.hideThinkingBlock,
host.getMarkdownThemeWithSettings(),
host.settingsManager.getTimestampFormat(),
);
host.chatContainer.addChild(host.streamingComponent);
}
if (host.streamingComponent) {
if (hasToolBlocks) {
host.chatContainer.removeChild(host.streamingComponent);
host.chatContainer.addChild(host.streamingComponent);
}
host.streamingComponent.updateContent(host.streamingMessage);
}
// Update index: fully processed blocks won't need re-scanning.
// Keep the last block's index (it may still be accumulating data),
// so we re-check it next time but skip all earlier ones.
if (contentBlocks.length > 0) {
lastProcessedContentIndex = Math.max(0, contentBlocks.length - 1);
}
// Pinned message: mirror the latest assistant text above the editor
// when tool executions push it out of the viewport.
const hasTools = contentBlocks.some(
(c: any) => c.type === "toolCall" || c.type === "serverToolUse",
);
if (hasTools) hasToolsInTurn = true;
if (hasToolsInTurn) {
// Mirror the latest text block that precedes the most recent tool
// call. Text blocks that come *after* the last tool call are still
// streaming live into the chat container, so mirroring them would
// duplicate the same tokens in two places at once.
let lastToolIdx = -1;
for (let i = contentBlocks.length - 1; i >= 0; i--) {
const c = contentBlocks[i] as any;
if (c.type === "toolCall" || c.type === "serverToolUse") {
lastToolIdx = i;
break;
}
}
let latestText = "";
for (let i = lastToolIdx - 1; i >= 0; i--) {
const c = contentBlocks[i] as any;
if (c.type === "text" && c.text?.trim()) {
latestText = c.text.trim();
break;
}
}
if (latestText && latestText !== lastPinnedText) {
lastPinnedText = latestText;
if (!pinnedBorder) {
// First time: create border + text component
host.pinnedMessageContainer.clear();
pinnedBorder = new DynamicBorder(
(str: string) => theme.fg("dim", str),
"Working · Latest Output",
);
pinnedBorder.startSpinner(host.ui, (str: string) => theme.fg("accent", str));
host.pinnedMessageContainer.addChild(pinnedBorder);
pinnedTextComponent = new Markdown(latestText, 1, 0, host.getMarkdownThemeWithSettings());
// Cap pinned content to ~40% of terminal height so tall output
// doesn't exceed the viewport and cause render flashing.
pinnedTextComponent.maxLines = Math.max(3, Math.floor(host.ui.terminal.rows * 0.4));
host.pinnedMessageContainer.addChild(pinnedTextComponent);
// Hide the separate status loader — the pinned zone replaces it
if (host.loadingAnimation) {
host.loadingAnimation.stop();
host.loadingAnimation = undefined;
}
host.statusContainer.clear();
} else {
// Update existing markdown component in-place
pinnedTextComponent?.setText(latestText);
// Refresh maxLines in case terminal was resized
if (pinnedTextComponent) {
pinnedTextComponent.maxLines = Math.max(3, Math.floor(host.ui.terminal.rows * 0.4));
}
}
}
}
host.ui.requestRender();
}
break;
case "message_end":
if (event.message.role === "user") break;
if (event.message.role === "assistant") {
host.streamingMessage = event.message;
let errorMessage: string | undefined;
if (host.streamingMessage.stopReason === "aborted") {
const retryAttempt = host.session.retryAttempt;
errorMessage = retryAttempt > 0
? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
: "Operation aborted";
host.streamingMessage.errorMessage = errorMessage;
}
const shouldRenderAssistant = hasVisibleAssistantContent(host.streamingMessage)
|| (
(host.streamingMessage.stopReason === "aborted" || host.streamingMessage.stopReason === "error")
&& !hasAssistantToolBlocks(host.streamingMessage)
);
if (!host.streamingComponent && shouldRenderAssistant) {
host.streamingComponent = new AssistantMessageComponent(
undefined,
host.hideThinkingBlock,
host.getMarkdownThemeWithSettings(),
host.settingsManager.getTimestampFormat(),
);
host.chatContainer.addChild(host.streamingComponent);
}
if (host.streamingComponent) {
host.streamingComponent.updateContent(host.streamingMessage);
}
if (host.streamingMessage.stopReason === "aborted" || host.streamingMessage.stopReason === "error") {
if (!errorMessage) {
errorMessage = host.streamingMessage.errorMessage || "Error";
}
const pendingComponents = Array.from(host.pendingTools.values());
if (pendingComponents.length > 0) {
const [first, ...rest] = pendingComponents;
first.completeWithError(errorMessage);
for (const component of rest) {
component.completeWithError();
}
}
host.pendingTools.clear();
} else {
for (const [, component] of host.pendingTools.entries()) {
component.setArgsComplete();
}
}
host.streamingComponent = undefined;
host.streamingMessage = undefined;
// Clear pinned output once the message is finalized in the chat
// container — prevents duplicate display when the agent continues
// (e.g. form elicitation) after the assistant message ends.
if (pinnedBorder) pinnedBorder.stopSpinner();
host.pinnedMessageContainer.clear();
lastPinnedText = "";
hasToolsInTurn = false;
pinnedBorder = undefined;
pinnedTextComponent = undefined;
host.footer.invalidate();
}
host.ui.requestRender();
break;
case "tool_execution_start":
if (!host.pendingTools.has(event.toolCallId)) {
const component = new ToolExecutionComponent(
event.toolName,
event.args,
{ showImages: host.settingsManager.getShowImages() },
host.getRegisteredToolDefinition(event.toolName),
host.ui,
);
component.setExpanded(host.toolOutputExpanded);
host.chatContainer.addChild(component);
host.pendingTools.set(event.toolCallId, component);
host.ui.requestRender();
}
break;
case "tool_execution_update": {
const component = host.pendingTools.get(event.toolCallId);
if (component) {
component.updateResult({ ...event.partialResult, isError: false }, true);
host.ui.requestRender();
}
break;
}
case "tool_execution_end": {
const component = host.pendingTools.get(event.toolCallId);
if (component) {
component.updateResult({ ...event.result, isError: event.isError });
host.pendingTools.delete(event.toolCallId);
host.ui.requestRender();
}
break;
}
case "agent_end":
if (host.loadingAnimation) {
host.loadingAnimation.stop();
host.loadingAnimation = undefined;
host.statusContainer.clear();
}
if (host.streamingComponent) {
host.chatContainer.removeChild(host.streamingComponent);
host.streamingComponent = undefined;
host.streamingMessage = undefined;
}
host.pendingTools.clear();
// Pinned output is only useful while work is actively streaming.
// Keep chat history as the single source after completion.
if (pinnedBorder) {
pinnedBorder.stopSpinner();
}
host.pinnedMessageContainer.clear();
lastPinnedText = "";
hasToolsInTurn = false;
pinnedBorder = undefined;
pinnedTextComponent = undefined;
await host.checkShutdownRequested();
host.ui.requestRender();
break;
case "auto_compaction_start":
host.autoCompactionEscapeHandler = host.defaultEditor.onEscape;
host.defaultEditor.onEscape = () => host.session.abortCompaction();
host.statusContainer.clear();
host.autoCompactionLoader = new Loader(
host.ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
`${event.reason === "overflow" ? "Context overflow detected, " : ""}Auto-compacting... (${appKey(host.keybindings, "interrupt")} to cancel)`,
);
host.statusContainer.addChild(host.autoCompactionLoader);
host.ui.requestRender();
break;
case "auto_compaction_end":
if (host.autoCompactionEscapeHandler) {
host.defaultEditor.onEscape = host.autoCompactionEscapeHandler;
host.autoCompactionEscapeHandler = undefined;
}
if (host.autoCompactionLoader) {
host.autoCompactionLoader.stop();
host.autoCompactionLoader = undefined;
host.statusContainer.clear();
}
if (event.aborted) {
host.showStatus("Auto-compaction cancelled");
} else if (event.result) {
host.chatContainer.clear();
host.rebuildChatFromMessages();
host.addMessageToChat({
role: "compactionSummary",
tokensBefore: event.result.tokensBefore,
summary: event.result.summary,
timestamp: Date.now(),
});
host.footer.invalidate();
} else if (event.errorMessage) {
host.chatContainer.addChild(new Spacer(1));
host.chatContainer.addChild(new Text(theme.fg("error", event.errorMessage), 1, 0));
}
void host.flushCompactionQueue({ willRetry: event.willRetry });
host.ui.requestRender();
break;
case "auto_retry_start":
host.retryEscapeHandler = host.defaultEditor.onEscape;
host.defaultEditor.onEscape = () => host.session.abortRetry();
host.statusContainer.clear();
host.retryLoader = new Loader(
host.ui,
(spinner) => theme.fg("warning", spinner),
(text) => theme.fg("muted", text),
`Retrying (${event.attempt}/${event.maxAttempts}) in ${Math.round(event.delayMs / 1000)}s... (${appKey(host.keybindings, "interrupt")} to cancel)`,
);
host.statusContainer.addChild(host.retryLoader);
host.ui.requestRender();
break;
case "auto_retry_end":
if (host.retryEscapeHandler) {
host.defaultEditor.onEscape = host.retryEscapeHandler;
host.retryEscapeHandler = undefined;
}
if (host.retryLoader) {
host.retryLoader.stop();
host.retryLoader = undefined;
host.statusContainer.clear();
}
if (!event.success) {
host.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
}
host.ui.requestRender();
break;
case "fallback_provider_switch":
host.showStatus(`Switched from ${event.from}${event.to} (${event.reason})`);
host.ui.requestRender();
break;
case "fallback_provider_restored":
host.showStatus(`Restored to ${event.provider}`);
host.ui.requestRender();
break;
case "fallback_chain_exhausted":
host.showError(event.reason);
host.ui.requestRender();
break;
case "image_overflow_recovery":
host.showStatus(
`Removed ${event.strippedCount} older image(s) to comply with API limits. Retrying...`,
);
host.ui.requestRender();
break;
}
}