import type { ContentBlock, ToolResultBlockParam, ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; type BetaContentBlock = ContentBlock | ToolResultBlockParam; import * as React from 'react'; import { ConfigurableShortcutHint } from 'src/components/ConfigurableShortcutHint.js'; import { CtrlOToExpand, SubAgentProvider } from 'src/components/CtrlOToExpand.js'; import { Byline, KeyboardShortcutHint } from '@anthropic/ink'; import type { z } from 'zod/v4'; import { AgentProgressLine } from 'src/components/AgentProgressLine.js'; import { FallbackToolUseErrorMessage } from 'src/components/FallbackToolUseErrorMessage.js'; import { FallbackToolUseRejectedMessage } from 'src/components/FallbackToolUseRejectedMessage.js'; import { Markdown } from 'src/components/Markdown.js'; import { Message as MessageComponent } from 'src/components/Message.js'; import { MessageResponse } from 'src/components/MessageResponse.js'; import { ToolUseLoader } from 'src/components/ToolUseLoader.js'; import { Box, Text } from '@anthropic/ink'; import { getDumpPromptsPath } from 'src/services/api/dumpPrompts.js'; import { findToolByName, type Tools } from 'src/Tool.js'; import type { Message, ProgressMessage } from 'src/types/message.js'; import type { AgentToolProgress } from 'src/types/tools.js'; import { count } from 'src/utils/array.js'; import { getSearchOrReadFromContent, getSearchReadSummaryText } from 'src/utils/collapseReadSearch.js'; import { getDisplayPath } from 'src/utils/file.js'; import { formatDuration, formatNumber } from 'src/utils/format.js'; import { buildSubagentLookups, createAssistantMessage, EMPTY_LOOKUPS } from 'src/utils/messages.js'; import type { ModelAlias } from 'src/utils/model/aliases.js'; import { getMainLoopModel, parseUserSpecifiedModel, renderModelName } from 'src/utils/model/model.js'; import type { Theme, ThemeName } from 'src/utils/theme.js'; import type { outputSchema, Progress, RemoteLaunchedOutput } from './AgentTool.js'; import { inputSchema } from './AgentTool.js'; import { getAgentColor } from './agentColorManager.js'; import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'; import { BetaUsage } from '@anthropic-ai/sdk/resources/beta.mjs'; const MAX_PROGRESS_MESSAGES_TO_SHOW = 3; /** * Guard: checks if progress data has a `message` field (agent_progress or * skill_progress). Other progress types (e.g. bash_progress forwarded from * sub-agents) lack this field and must be skipped by UI helpers. */ function hasProgressMessage(data: Progress): data is AgentToolProgress { if (!('message' in data)) { return false; } const msg = (data as AgentToolProgress).message; return msg != null && typeof msg === 'object' && 'type' in msg; } /** * Check if a progress message is a search/read/REPL operation (tool use or result). * Returns { isSearch, isRead, isREPL } if it's a collapsible operation, null otherwise. * * For tool_result messages, uses the provided `toolUseByID` map to find the * corresponding tool_use block instead of relying on `normalizedMessages`. */ function getSearchOrReadInfo( progressMessage: ProgressMessage, tools: Tools, toolUseByID: Map, ): { isSearch: boolean; isRead: boolean; isREPL: boolean } | null { if (!hasProgressMessage(progressMessage.data)) { return null; } const message = progressMessage.data.message; // Check tool_use (assistant message) if (message.type === 'assistant') { return getSearchOrReadFromContent(message.message.content[0], tools); } // Check tool_result (user message) - find corresponding tool use from the map if (message.type === 'user') { const content = message.message.content[0]; if (content?.type === 'tool_result') { const toolUse = toolUseByID.get(content.tool_use_id); if (toolUse) { return getSearchOrReadFromContent(toolUse, tools); } } } return null; } type SummaryMessage = { type: 'summary'; searchCount: number; readCount: number; replCount: number; uuid: string; isActive: boolean; // true if still in progress (last message was tool_use, not tool_result) }; type ProcessedMessage = { type: 'original'; message: ProgressMessage } | SummaryMessage; /** * Process progress messages to group consecutive search/read operations into summaries. * For ants only - returns original messages for non-ants. * @param isAgentRunning - If true, the last group is always marked as active (in progress) */ function processProgressMessages( messages: ProgressMessage[], tools: Tools, isAgentRunning: boolean, ): ProcessedMessage[] { // Only process for ants if (process.env.USER_TYPE !== 'ant') { return messages .filter( (m): m is ProgressMessage => hasProgressMessage(m.data) && m.data.message.type !== 'user', ) .map(m => ({ type: 'original', message: m })); } const result: ProcessedMessage[] = []; let currentGroup: { searchCount: number; readCount: number; replCount: number; startUuid: string; } | null = null; function flushGroup(isActive: boolean): void { if (currentGroup && (currentGroup.searchCount > 0 || currentGroup.readCount > 0 || currentGroup.replCount > 0)) { result.push({ type: 'summary', searchCount: currentGroup.searchCount, readCount: currentGroup.readCount, replCount: currentGroup.replCount, uuid: `summary-${currentGroup.startUuid}`, isActive, }); } currentGroup = null; } const agentMessages = messages.filter((m): m is ProgressMessage => hasProgressMessage(m.data)); // Build tool_use lookup incrementally as we iterate const toolUseByID = new Map(); for (const msg of agentMessages) { // Track tool_use blocks as we see them if (msg.data.message.type === 'assistant') { for (const c of msg.data.message.message.content) { if (c.type === 'tool_use') { toolUseByID.set(c.id, c as ToolUseBlockParam); } } } const info = getSearchOrReadInfo(msg, tools, toolUseByID); if (info && (info.isSearch || info.isRead || info.isREPL)) { // This is a search/read/REPL operation - add to current group if (!currentGroup) { currentGroup = { searchCount: 0, readCount: 0, replCount: 0, startUuid: msg.uuid, }; } // Only count tool_result messages (not tool_use) to avoid double counting if (msg.data.message.type === 'user') { if (info.isSearch) { currentGroup.searchCount++; } else if (info.isREPL) { currentGroup.replCount++; } else if (info.isRead) { currentGroup.readCount++; } } } else { // Non-search/read/REPL message - flush current group (completed) and add this message flushGroup(false); // Skip user tool_result messages — subagent progress messages lack // toolUseResult, so UserToolSuccessMessage returns null and the // height=1 Box in renderToolUseProgressMessage shows as a blank line. if (msg.data.message.type !== 'user') { result.push({ type: 'original', message: msg }); } } } // Flush any remaining group - it's active if the agent is still running flushGroup(isAgentRunning); return result; } const ESTIMATED_LINES_PER_TOOL = 9; const TERMINAL_BUFFER_LINES = 7; type Output = z.input>; export function AgentPromptDisplay({ prompt, dim: _dim = false, }: { prompt: string; theme?: ThemeName; // deprecated, kept for compatibility - Markdown uses useTheme internally dim?: boolean; // deprecated, kept for compatibility - dimColor cannot be applied to Box (Markdown returns Box) }): React.ReactNode { return ( Prompt: {prompt} ); } export function AgentResponseDisplay({ content, }: { content: { type: string; text: string }[]; theme?: ThemeName; // deprecated, kept for compatibility - Markdown uses useTheme internally }): React.ReactNode { return ( Response: {content.map((block: { type: string; text: string }, index: number) => ( {block.text} ))} ); } type VerboseAgentTranscriptProps = { progressMessages: ProgressMessage[]; tools: Tools; verbose: boolean; }; function VerboseAgentTranscript({ progressMessages, tools, verbose }: VerboseAgentTranscriptProps): React.ReactNode { const { lookups: agentLookups, inProgressToolUseIDs } = buildSubagentLookups( progressMessages .filter((pm): pm is ProgressMessage => hasProgressMessage(pm.data)) .map(pm => pm.data), ); // Filter out user tool_result messages that lack toolUseResult. // Subagent progress messages don't carry the parsed tool output, // so UserToolSuccessMessage returns null and MessageResponse renders // a bare ⎿ with no content. const filteredMessages = progressMessages.filter((pm): pm is ProgressMessage => { if (!hasProgressMessage(pm.data)) { return false; } const msg = pm.data.message; if (msg.type === 'user' && msg.toolUseResult === undefined) { return false; } return true; }); return ( <> {filteredMessages.map(progressMessage => ( ))} ); } export function renderToolResultMessage( data: Output, progressMessagesForMessage: ProgressMessage[], { tools, verbose, theme, isTranscriptMode = false, }: { tools: Tools; verbose: boolean; theme: ThemeName; isTranscriptMode?: boolean; }, ): React.ReactNode { // Remote-launched agents (ant-only) use a private output type not in the // public schema. Narrow via the internal discriminant. const internal = data as Output | RemoteLaunchedOutput; if (internal.status === 'remote_launched') { return ( Remote agent launched{' '} · {internal.taskId} · {internal.sessionUrl} ); } if (data.status === 'async_launched') { const { prompt } = data; return ( Backgrounded agent {!isTranscriptMode && ( {' ('} {prompt && ( )} {')'} )} {isTranscriptMode && prompt && ( )} ); } if (data.status !== 'completed') { return null; } const { agentId, totalDurationMs, totalToolUseCount, totalTokens, usage, content, prompt } = data; const result = [ totalToolUseCount === 1 ? '1 tool use' : `${totalToolUseCount} tool uses`, formatNumber(totalTokens) + ' tokens', formatDuration(totalDurationMs), ]; const completionMessage = `Done (${result.join(' · ')})`; const finalAssistantMessage = createAssistantMessage({ content: completionMessage, usage: { ...usage, inference_geo: null, iterations: null, speed: null } as unknown as BetaUsage, }); return ( {process.env.USER_TYPE === 'ant' && ( [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} )} {isTranscriptMode && prompt && ( )} {isTranscriptMode ? ( ) : null} {isTranscriptMode && content && content.length > 0 && ( )} {!isTranscriptMode && ( {' '} )} ); } export function renderToolUseMessage({ description, prompt, }: Partial<{ description: string; prompt: string; }>): React.ReactNode { if (!description || !prompt) { return null; } return description; } export function renderToolUseTag( input: Partial<{ description: string; prompt: string; subagent_type: string; model?: ModelAlias; }>, ): React.ReactNode { const tags: React.ReactNode[] = []; if (input.model) { const mainModel = getMainLoopModel(); const agentModel = parseUserSpecifiedModel(input.model); if (agentModel !== mainModel) { tags.push( {renderModelName(agentModel)} , ); } } if (tags.length === 0) { return null; } return <>{tags}; } const INITIALIZING_TEXT = 'Initializing…'; export function renderToolUseProgressMessage( progressMessages: ProgressMessage[], { tools, verbose, terminalSize, inProgressToolCallCount, isTranscriptMode = false, }: { tools: Tools; verbose: boolean; terminalSize?: { columns: number; rows: number }; inProgressToolCallCount?: number; isTranscriptMode?: boolean; }, ): React.ReactNode { if (!progressMessages.length) { return ( {INITIALIZING_TEXT} ); } // Checks to see if we should show a super condensed progress message summary. // This prevents flickers when the terminal size is too small to render all the dynamic content const toolToolRenderLinesEstimate = (inProgressToolCallCount ?? 1) * ESTIMATED_LINES_PER_TOOL + TERMINAL_BUFFER_LINES; const shouldUseCondensedMode = !isTranscriptMode && terminalSize && terminalSize.rows && terminalSize.rows < toolToolRenderLinesEstimate; const getProgressStats = () => { const toolUseCount = count(progressMessages, msg => { if (!hasProgressMessage(msg.data)) { return false; } const message = msg.data.message; return message.message.content.some((content: BetaContentBlock) => content.type === 'tool_use'); }); const latestAssistant = progressMessages.findLast( (msg): msg is ProgressMessage => hasProgressMessage(msg.data) && msg.data.message.type === 'assistant', ); let tokens = null; if (latestAssistant?.data.message.type === 'assistant') { const usage = latestAssistant.data.message.message.usage; tokens = (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + usage.input_tokens + usage.output_tokens; } return { toolUseCount, tokens }; }; if (shouldUseCondensedMode) { const { toolUseCount, tokens } = getProgressStats(); return ( In progress… · {toolUseCount} tool {toolUseCount === 1 ? 'use' : 'uses'} {tokens && ` · ${formatNumber(tokens)} tokens`} ·{' '} ); } // Process messages to group consecutive search/read operations into summaries (ants only) // isAgentRunning=true since this is the progress view while the agent is still running const processedMessages = processProgressMessages(progressMessages, tools, true); // For display, take the last few processed messages const displayedMessages = isTranscriptMode ? processedMessages : processedMessages.slice(-MAX_PROGRESS_MESSAGES_TO_SHOW); // Count hidden tool uses specifically (not all messages) to match the // final "Done (N tool uses)" count. Each tool use generates multiple // progress messages (tool_use + tool_result + text), so counting all // hidden messages inflates the number shown to the user. const hiddenMessages = isTranscriptMode ? [] : processedMessages.slice(0, Math.max(0, processedMessages.length - MAX_PROGRESS_MESSAGES_TO_SHOW)); const hiddenToolUseCount = count(hiddenMessages, m => { if (m.type === 'summary') { return m.searchCount + m.readCount + m.replCount > 0; } const data = m.message.data; if (!hasProgressMessage(data)) { return false; } return data.message.message.content.some((content: BetaContentBlock) => content.type === 'tool_use'); }); const firstData = progressMessages[0]?.data; const prompt = firstData && hasProgressMessage(firstData) ? firstData.prompt : undefined; // After grouping, displayedMessages can be empty when the only progress so // far is an assistant tool_use for a search/read op (grouped but not yet // counted, since counts increment on tool_result). Fall back to the // initializing text so MessageResponse doesn't render a bare ⎿. if (displayedMessages.length === 0 && !(isTranscriptMode && prompt)) { return ( {INITIALIZING_TEXT} ); } const { lookups: subagentLookups, inProgressToolUseIDs: collapsedInProgressIDs } = buildSubagentLookups( progressMessages .filter((pm): pm is ProgressMessage => hasProgressMessage(pm.data)) .map(pm => pm.data), ); return ( {isTranscriptMode && prompt && ( )} {displayedMessages.map(processed => { if (processed.type === 'summary') { // Render summary for grouped search/read/REPL operations using shared formatting const summaryText = getSearchReadSummaryText( processed.searchCount, processed.readCount, processed.isActive, processed.replCount, ); return ( {summaryText} ); } // Render original message without height=1 wrapper so null // content (tool not found, renderToolUseMessage returns null) // doesn't leave a blank line. Tool call headers are single-line // anyway so truncation isn't needed. return ( ); })} {hiddenToolUseCount > 0 && ( +{hiddenToolUseCount} more tool {hiddenToolUseCount === 1 ? 'use' : 'uses'} )} ); } export function renderToolUseRejectedMessage( _input: { description: string; prompt: string; subagent_type: string }, { progressMessagesForMessage, tools, verbose, isTranscriptMode, }: { columns: number; messages: Message[]; style?: 'condensed'; theme: ThemeName; progressMessagesForMessage: ProgressMessage[]; tools: Tools; verbose: boolean; isTranscriptMode?: boolean; }, ): React.ReactNode { // Get agentId from progress messages if available (agent was running before rejection) const firstData = progressMessagesForMessage[0]?.data; const agentId = firstData && hasProgressMessage(firstData) ? firstData.agentId : undefined; return ( <> {process.env.USER_TYPE === 'ant' && agentId && ( [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} )} {renderToolUseProgressMessage(progressMessagesForMessage, { tools, verbose, isTranscriptMode, })} ); } export function renderToolUseErrorMessage( result: ToolResultBlockParam['content'], { progressMessagesForMessage, tools, verbose, isTranscriptMode, }: { progressMessagesForMessage: ProgressMessage[]; tools: Tools; verbose: boolean; isTranscriptMode?: boolean; }, ): React.ReactNode { return ( <> {renderToolUseProgressMessage(progressMessagesForMessage, { tools, verbose, isTranscriptMode, })} ); } function calculateAgentStats(progressMessages: ProgressMessage[]): { toolUseCount: number; tokens: number | null; } { const toolUseCount = count(progressMessages, msg => { if (!hasProgressMessage(msg.data)) { return false; } const message = msg.data.message; return ( message.type === 'user' && message.message.content.some((content: BetaContentBlock) => content.type === 'tool_result') ); }); const latestAssistant = progressMessages.findLast( (msg): msg is ProgressMessage => hasProgressMessage(msg.data) && msg.data.message.type === 'assistant', ); let tokens = null; if (latestAssistant?.data.message.type === 'assistant') { const usage = latestAssistant.data.message.message.usage; tokens = (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + usage.input_tokens + usage.output_tokens; } return { toolUseCount, tokens }; } export function renderGroupedAgentToolUse( toolUses: Array<{ param: ToolUseBlockParam; isResolved: boolean; isError: boolean; isInProgress: boolean; progressMessages: ProgressMessage[]; result?: { param: ToolResultBlockParam; output: Output; }; }>, options: { shouldAnimate: boolean; tools: Tools; }, ): React.ReactNode | null { const { shouldAnimate, tools } = options; // Calculate stats for each agent const agentStats = toolUses.map(({ param, isResolved, isError, progressMessages, result }) => { const stats = calculateAgentStats(progressMessages); const lastToolInfo = extractLastToolInfo(progressMessages, tools); const parsedInput = inputSchema().safeParse(param.input); // teammate_spawned is not part of the exported Output type (cast through unknown // for dead code elimination), so check via string comparison on the raw value const isTeammateSpawn = (result?.output?.status as string) === 'teammate_spawned'; // For teammate spawns, show @name with type in parens and description as status let agentType: string; let description: string | undefined; let color: keyof Theme | undefined; let descriptionColor: keyof Theme | undefined; let taskDescription: string | undefined; if (isTeammateSpawn && parsedInput.success && parsedInput.data.name) { agentType = `@${parsedInput.data.name}`; const subagentType = parsedInput.data.subagent_type; description = isCustomSubagentType(subagentType) ? subagentType : undefined; taskDescription = parsedInput.data.description; // Use the custom agent definition's color on the type, not the name descriptionColor = isCustomSubagentType(subagentType) ? getAgentColor(subagentType) : undefined; } else { agentType = parsedInput.success ? userFacingName(parsedInput.data) : 'Agent'; description = parsedInput.success ? parsedInput.data.description : undefined; color = parsedInput.success ? userFacingNameBackgroundColor(parsedInput.data) : undefined; taskDescription = undefined; } // Check if this was launched as a background agent OR backgrounded mid-execution const launchedAsAsync = parsedInput.success && 'run_in_background' in parsedInput.data && parsedInput.data.run_in_background === true; const outputStatus = (result?.output as { status?: string } | undefined)?.status; const backgroundedMidExecution = outputStatus === 'async_launched' || outputStatus === 'remote_launched'; const isAsync = launchedAsAsync || backgroundedMidExecution || isTeammateSpawn; const name = parsedInput.success ? parsedInput.data.name : undefined; return { id: param.id, agentType, description, toolUseCount: stats.toolUseCount, tokens: stats.tokens, isResolved, isError, isAsync, color, descriptionColor, lastToolInfo, taskDescription, name, }; }); const anyUnresolved = toolUses.some(t => !t.isResolved); const anyError = toolUses.some(t => t.isError); const allComplete = !anyUnresolved; // Check if all agents are the same type const allSameType = agentStats.length > 0 && agentStats.every(stat => stat.agentType === agentStats[0]?.agentType); const commonType = allSameType && agentStats[0]?.agentType !== 'Agent' ? agentStats[0]?.agentType : null; // Check if all resolved agents are async (background) const allAsync = agentStats.every(stat => stat.isAsync); return ( {allComplete ? ( allAsync ? ( <> {toolUses.length} background agents launched{' '} ) : ( <> {toolUses.length} {commonType ? `${commonType} agents` : 'agents'} finished ) ) : ( <> Running {toolUses.length} {commonType ? `${commonType} agents` : 'agents'}… )}{' '} {!allAsync && } {agentStats.map((stat, index) => ( ))} ); } export function userFacingName( input: | Partial<{ description: string; prompt: string; subagent_type: string; name: string; team_name: string; }> | undefined, ): string { if (input?.subagent_type && input.subagent_type !== GENERAL_PURPOSE_AGENT.agentType) { // Display "worker" agents as "Agent" for cleaner UI if (input.subagent_type === 'worker') { return 'Agent'; } return input.subagent_type; } return 'Agent'; } export function userFacingNameBackgroundColor( input: Partial<{ description: string; prompt: string; subagent_type: string }> | undefined, ): keyof Theme | undefined { if (!input?.subagent_type) { return undefined; } // Get the color for this agent return getAgentColor(input.subagent_type); } export function extractLastToolInfo(progressMessages: ProgressMessage[], tools: Tools): string | null { // Build tool_use lookup from all progress messages (needed for reverse iteration) const toolUseByID = new Map(); for (const pm of progressMessages) { if (!hasProgressMessage(pm.data)) { continue; } if (pm.data.message.type === 'assistant') { for (const c of pm.data.message.message.content) { if (c.type === 'tool_use') { toolUseByID.set(c.id, c as ToolUseBlockParam); } } } } // Count trailing consecutive search/read operations from the end let searchCount = 0; let readCount = 0; for (let i = progressMessages.length - 1; i >= 0; i--) { const msg = progressMessages[i]!; if (!hasProgressMessage(msg.data)) { continue; } const info = getSearchOrReadInfo(msg, tools, toolUseByID); if (info && (info.isSearch || info.isRead)) { // Only count tool_result messages to avoid double counting if (msg.data.message.type === 'user') { if (info.isSearch) { searchCount++; } else if (info.isRead) { readCount++; } } } else { break; } } if (searchCount + readCount >= 2) { return getSearchReadSummaryText(searchCount, readCount, true); } // Find the last tool_result message const lastToolResult = progressMessages.findLast((msg): msg is ProgressMessage => { if (!hasProgressMessage(msg.data)) { return false; } const message = msg.data.message; return message.type === 'user' && message.message.content.some((c: BetaContentBlock) => c.type === 'tool_result'); }); if (lastToolResult?.data.message.type === 'user') { const toolResultBlock = lastToolResult.data.message.message.content.find( (c: BetaContentBlock) => c.type === 'tool_result', ); if (toolResultBlock?.type === 'tool_result') { // Look up the corresponding tool_use — already indexed above const toolUseBlock = toolUseByID.get(toolResultBlock.tool_use_id); if (toolUseBlock) { const tool = findToolByName(tools, toolUseBlock.name); if (!tool) { return toolUseBlock.name; // Fallback to raw name } const input = toolUseBlock.input as Record; const parsedInput = tool.inputSchema.safeParse(input); // Get user-facing tool name const userFacingToolName = tool.userFacingName(parsedInput.success ? parsedInput.data : undefined); // Try to get summary from the tool itself if (tool.getToolUseSummary) { const summary = tool.getToolUseSummary(parsedInput.success ? parsedInput.data : undefined); if (summary) { return `${userFacingToolName}: ${summary}`; } } // Default: just show user-facing tool name return userFacingToolName; } } } return null; } function isCustomSubagentType(subagentType: string | undefined): subagentType is string { return !!subagentType && subagentType !== GENERAL_PURPOSE_AGENT.agentType && subagentType !== 'worker'; }