mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-21 15:55:50 +00:00
1006 lines
33 KiB
TypeScript
1006 lines
33 KiB
TypeScript
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<Progress>,
|
|
tools: Tools,
|
|
toolUseByID: Map<string, ToolUseBlockParam>,
|
|
): { 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<AgentToolProgress> } | 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<Progress>[],
|
|
tools: Tools,
|
|
isAgentRunning: boolean,
|
|
): ProcessedMessage[] {
|
|
// Only process for ants
|
|
if (process.env.USER_TYPE !== 'ant') {
|
|
return messages
|
|
.filter(
|
|
(m): m is ProgressMessage<AgentToolProgress> => 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<AgentToolProgress> => hasProgressMessage(m.data));
|
|
|
|
// Build tool_use lookup incrementally as we iterate
|
|
const toolUseByID = new Map<string, ToolUseBlockParam>();
|
|
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<ReturnType<typeof outputSchema>>;
|
|
|
|
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 (
|
|
<Box flexDirection="column">
|
|
<Text color="success" bold>
|
|
Prompt:
|
|
</Text>
|
|
<Box paddingLeft={2}>
|
|
<Markdown>{prompt}</Markdown>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export function AgentResponseDisplay({
|
|
content,
|
|
}: {
|
|
content: { type: string; text: string }[];
|
|
theme?: ThemeName; // deprecated, kept for compatibility - Markdown uses useTheme internally
|
|
}): React.ReactNode {
|
|
return (
|
|
<Box flexDirection="column">
|
|
<Text color="success" bold>
|
|
Response:
|
|
</Text>
|
|
{content.map((block: { type: string; text: string }, index: number) => (
|
|
<Box key={index} paddingLeft={2} marginTop={index === 0 ? 0 : 1}>
|
|
<Markdown>{block.text}</Markdown>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
type VerboseAgentTranscriptProps = {
|
|
progressMessages: ProgressMessage<Progress>[];
|
|
tools: Tools;
|
|
verbose: boolean;
|
|
};
|
|
|
|
function VerboseAgentTranscript({ progressMessages, tools, verbose }: VerboseAgentTranscriptProps): React.ReactNode {
|
|
const { lookups: agentLookups, inProgressToolUseIDs } = buildSubagentLookups(
|
|
progressMessages
|
|
.filter((pm): pm is ProgressMessage<AgentToolProgress> => 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<AgentToolProgress> => {
|
|
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 => (
|
|
<MessageResponse key={progressMessage.uuid} height={1}>
|
|
<MessageComponent
|
|
message={progressMessage.data.message}
|
|
lookups={agentLookups}
|
|
addMargin={false}
|
|
tools={tools}
|
|
commands={[]}
|
|
verbose={verbose}
|
|
inProgressToolUseIDs={inProgressToolUseIDs}
|
|
progressMessagesForMessage={[]}
|
|
shouldAnimate={false}
|
|
shouldShowDot={false}
|
|
isTranscriptMode={false}
|
|
isStatic={true}
|
|
/>
|
|
</MessageResponse>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function renderToolResultMessage(
|
|
data: Output,
|
|
progressMessagesForMessage: ProgressMessage<Progress>[],
|
|
{
|
|
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 (
|
|
<Box flexDirection="column">
|
|
<MessageResponse height={1}>
|
|
<Text>
|
|
Remote agent launched{' '}
|
|
<Text dimColor>
|
|
· {internal.taskId} · {internal.sessionUrl}
|
|
</Text>
|
|
</Text>
|
|
</MessageResponse>
|
|
</Box>
|
|
);
|
|
}
|
|
if (data.status === 'async_launched') {
|
|
const { prompt } = data;
|
|
return (
|
|
<Box flexDirection="column">
|
|
<MessageResponse height={1}>
|
|
<Text>
|
|
Backgrounded agent
|
|
{!isTranscriptMode && (
|
|
<Text dimColor>
|
|
{' ('}
|
|
<Byline>
|
|
<KeyboardShortcutHint shortcut="↓" action="manage" />
|
|
{prompt && (
|
|
<ConfigurableShortcutHint
|
|
action="app:toggleTranscript"
|
|
context="Global"
|
|
fallback="ctrl+o"
|
|
description="expand"
|
|
/>
|
|
)}
|
|
</Byline>
|
|
{')'}
|
|
</Text>
|
|
)}
|
|
</Text>
|
|
</MessageResponse>
|
|
{isTranscriptMode && prompt && (
|
|
<MessageResponse>
|
|
<AgentPromptDisplay prompt={prompt} theme={theme} />
|
|
</MessageResponse>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Box flexDirection="column">
|
|
{process.env.USER_TYPE === 'ant' && (
|
|
<MessageResponse>
|
|
<Text color="warning">[ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))}</Text>
|
|
</MessageResponse>
|
|
)}
|
|
{isTranscriptMode && prompt && (
|
|
<MessageResponse>
|
|
<AgentPromptDisplay prompt={prompt} theme={theme} />
|
|
</MessageResponse>
|
|
)}
|
|
{isTranscriptMode ? (
|
|
<SubAgentProvider>
|
|
<VerboseAgentTranscript progressMessages={progressMessagesForMessage} tools={tools} verbose={verbose} />
|
|
</SubAgentProvider>
|
|
) : null}
|
|
{isTranscriptMode && content && content.length > 0 && (
|
|
<MessageResponse>
|
|
<AgentResponseDisplay content={content} theme={theme} />
|
|
</MessageResponse>
|
|
)}
|
|
<MessageResponse height={1}>
|
|
<MessageComponent
|
|
message={finalAssistantMessage}
|
|
lookups={EMPTY_LOOKUPS}
|
|
addMargin={false}
|
|
tools={tools}
|
|
commands={[]}
|
|
verbose={verbose}
|
|
inProgressToolUseIDs={new Set()}
|
|
progressMessagesForMessage={[]}
|
|
shouldAnimate={false}
|
|
shouldShowDot={false}
|
|
isTranscriptMode={false}
|
|
isStatic={true}
|
|
/>
|
|
</MessageResponse>
|
|
{!isTranscriptMode && (
|
|
<Text dimColor>
|
|
{' '}
|
|
<CtrlOToExpand />
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
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(
|
|
<Box key="model" flexWrap="nowrap" marginLeft={1}>
|
|
<Text dimColor>{renderModelName(agentModel)}</Text>
|
|
</Box>,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (tags.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return <>{tags}</>;
|
|
}
|
|
|
|
const INITIALIZING_TEXT = 'Initializing…';
|
|
|
|
export function renderToolUseProgressMessage(
|
|
progressMessages: ProgressMessage<Progress>[],
|
|
{
|
|
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 (
|
|
<MessageResponse height={1}>
|
|
<Text dimColor>{INITIALIZING_TEXT}</Text>
|
|
</MessageResponse>
|
|
);
|
|
}
|
|
|
|
// 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<AgentToolProgress> =>
|
|
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 (
|
|
<MessageResponse height={1}>
|
|
<Text dimColor>
|
|
In progress… · <Text bold>{toolUseCount}</Text> tool {toolUseCount === 1 ? 'use' : 'uses'}
|
|
{tokens && ` · ${formatNumber(tokens)} tokens`} ·{' '}
|
|
<ConfigurableShortcutHint
|
|
action="app:toggleTranscript"
|
|
context="Global"
|
|
fallback="ctrl+o"
|
|
description="expand"
|
|
parens
|
|
/>
|
|
</Text>
|
|
</MessageResponse>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<MessageResponse height={1}>
|
|
<Text dimColor>{INITIALIZING_TEXT}</Text>
|
|
</MessageResponse>
|
|
);
|
|
}
|
|
|
|
const { lookups: subagentLookups, inProgressToolUseIDs: collapsedInProgressIDs } = buildSubagentLookups(
|
|
progressMessages
|
|
.filter((pm): pm is ProgressMessage<AgentToolProgress> => hasProgressMessage(pm.data))
|
|
.map(pm => pm.data),
|
|
);
|
|
|
|
return (
|
|
<MessageResponse>
|
|
<Box flexDirection="column">
|
|
<SubAgentProvider>
|
|
{isTranscriptMode && prompt && (
|
|
<Box marginBottom={1}>
|
|
<AgentPromptDisplay prompt={prompt} />
|
|
</Box>
|
|
)}
|
|
{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 (
|
|
<Box key={processed.uuid} height={1} overflow="hidden">
|
|
<Text dimColor>{summaryText}</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
// 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 (
|
|
<MessageComponent
|
|
key={processed.message.uuid}
|
|
message={processed.message.data.message}
|
|
lookups={subagentLookups}
|
|
addMargin={false}
|
|
tools={tools}
|
|
commands={[]}
|
|
verbose={verbose}
|
|
inProgressToolUseIDs={collapsedInProgressIDs}
|
|
progressMessagesForMessage={[]}
|
|
shouldAnimate={false}
|
|
shouldShowDot={false}
|
|
style="condensed"
|
|
isTranscriptMode={false}
|
|
isStatic={true}
|
|
/>
|
|
);
|
|
})}
|
|
</SubAgentProvider>
|
|
{hiddenToolUseCount > 0 && (
|
|
<Text dimColor>
|
|
+{hiddenToolUseCount} more tool {hiddenToolUseCount === 1 ? 'use' : 'uses'} <CtrlOToExpand />
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</MessageResponse>
|
|
);
|
|
}
|
|
|
|
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<Progress>[];
|
|
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 && (
|
|
<MessageResponse>
|
|
<Text color="warning">[ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))}</Text>
|
|
</MessageResponse>
|
|
)}
|
|
{renderToolUseProgressMessage(progressMessagesForMessage, {
|
|
tools,
|
|
verbose,
|
|
isTranscriptMode,
|
|
})}
|
|
<FallbackToolUseRejectedMessage />
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function renderToolUseErrorMessage(
|
|
result: ToolResultBlockParam['content'],
|
|
{
|
|
progressMessagesForMessage,
|
|
tools,
|
|
verbose,
|
|
isTranscriptMode,
|
|
}: {
|
|
progressMessagesForMessage: ProgressMessage<Progress>[];
|
|
tools: Tools;
|
|
verbose: boolean;
|
|
isTranscriptMode?: boolean;
|
|
},
|
|
): React.ReactNode {
|
|
return (
|
|
<>
|
|
{renderToolUseProgressMessage(progressMessagesForMessage, {
|
|
tools,
|
|
verbose,
|
|
isTranscriptMode,
|
|
})}
|
|
<FallbackToolUseErrorMessage result={result} verbose={verbose} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function calculateAgentStats(progressMessages: ProgressMessage<Progress>[]): {
|
|
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<AgentToolProgress> =>
|
|
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<Progress>[];
|
|
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 (
|
|
<Box flexDirection="column" marginTop={1}>
|
|
<Box flexDirection="row">
|
|
<ToolUseLoader shouldAnimate={shouldAnimate && anyUnresolved} isUnresolved={anyUnresolved} isError={anyError} />
|
|
<Text>
|
|
{allComplete ? (
|
|
allAsync ? (
|
|
<>
|
|
<Text bold>{toolUses.length}</Text> background agents launched{' '}
|
|
<Text dimColor>
|
|
<KeyboardShortcutHint shortcut="↓" action="manage" parens />
|
|
</Text>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Text bold>{toolUses.length}</Text> {commonType ? `${commonType} agents` : 'agents'} finished
|
|
</>
|
|
)
|
|
) : (
|
|
<>
|
|
Running <Text bold>{toolUses.length}</Text> {commonType ? `${commonType} agents` : 'agents'}…
|
|
</>
|
|
)}{' '}
|
|
</Text>
|
|
{!allAsync && <CtrlOToExpand />}
|
|
</Box>
|
|
{agentStats.map((stat, index) => (
|
|
<AgentProgressLine
|
|
key={stat.id}
|
|
agentType={stat.agentType}
|
|
description={stat.description}
|
|
descriptionColor={stat.descriptionColor}
|
|
taskDescription={stat.taskDescription}
|
|
toolUseCount={stat.toolUseCount}
|
|
tokens={stat.tokens}
|
|
color={stat.color}
|
|
isLast={index === agentStats.length - 1}
|
|
isResolved={stat.isResolved}
|
|
isError={stat.isError}
|
|
isAsync={stat.isAsync}
|
|
shouldAnimate={shouldAnimate}
|
|
lastToolInfo={stat.lastToolInfo}
|
|
hideType={allSameType}
|
|
name={stat.name}
|
|
/>
|
|
))}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
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<Progress>[], tools: Tools): string | null {
|
|
// Build tool_use lookup from all progress messages (needed for reverse iteration)
|
|
const toolUseByID = new Map<string, ToolUseBlockParam>();
|
|
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<AgentToolProgress> => {
|
|
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<string, unknown>;
|
|
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';
|
|
}
|