singularity-forge/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts
Jeremy b488961609 fix(tui): clear pinned output on message_end to prevent duplicate display
The pinned "Latest Output" zone was only cleared at agent_end, but during
flows with form elicitation (e.g. discuss-phase), there is a gap between
message_end and agent_end where the agent waits for user input. During this
gap, the same content was visible in both the chat history and the pinned
zone. Clear the pinned zone at message_end when the assistant message is
finalized in the chat container.
2026-04-11 17:41:50 -05:00

554 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) {
// Collect the latest text block(s) from the assistant message
let latestText = "";
for (let i = contentBlocks.length - 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";
}
for (const [, component] of host.pendingTools.entries()) {
component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true });
}
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;
}
}