/**
* MessageList — types and CompletedMessage component
*
* CompletedMessage is React.memo'd to prevent re-renders during streaming.
* Telemetry footer: tokens, cost estimate, tool count.
* Minimal Box usage — Text-first for reliable rendering.
*/
import React, { useMemo } from "react";
import { Box, Text } from "ink";
import { ToolIndicator } from "./ToolIndicator.js";
import { MarkdownText } from "./MarkdownText.js";
import { CompletedSubagentTree, type CompletedSubagentInfo } from "./SubagentPanel.js";
import { colors } from "../shared/Theme.js";
import { contentWidth } from "../shared/markdown.js";
import { MODEL_PRICING } from "../../shared/agent-core.js";
// ============================================================================
// TYPES
// ============================================================================
export interface ToolCall {
name: string;
status: "running" | "success" | "error";
result?: string;
input?: Record<string, unknown>;
durationMs?: number;
}
export interface ChatMessage {
role: "user" | "assistant";
text: string;
images?: string[]; // Image file names attached to user messages
toolCalls?: ToolCall[];
completedSubagents?: CompletedSubagentInfo[];
usage?: {
input_tokens: number;
output_tokens: number;
thinking_tokens?: number;
model?: string;
costUsd?: number;
cache_read_tokens?: number;
cache_creation_tokens?: number;
};
}
// ============================================================================
// HELPERS
// ============================================================================
function estimateCost(input: number, output: number, model?: string, precomputedCost?: number): string {
// Use precomputed cost if available (accurate for all providers)
let cost = precomputedCost;
if (cost == null) {
// Fall back to model-specific pricing, then Sonnet as default
const pricing = (model && MODEL_PRICING[model])
|| MODEL_PRICING[Object.keys(MODEL_PRICING).find(k => model?.startsWith(k)) ?? ""]
|| MODEL_PRICING["claude-sonnet-4-20250514"];
cost = (input * pricing.inputPer1M + output * pricing.outputPer1M) / 1_000_000;
}
if (cost < 0.001) return "<$0.001";
if (cost < 0.01) return `$${cost.toFixed(4)}`;
return `$${cost.toFixed(3)}`;
}
function formatTokens(n: number): string {
if (n < 1000) return String(n);
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
return `${Math.round(n / 1000)}k`;
}
function totalToolDuration(toolCalls: ToolCall[]): number {
return toolCalls.reduce((sum, tc) => sum + (tc.durationMs || 0), 0);
}
function formatMs(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 10000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.round(ms / 1000)}s`;
}
// ============================================================================
// TOOL CALL GROUPING — collapse consecutive identical tool calls
// ============================================================================
interface ToolCallGroup {
tool: ToolCall;
count: number;
}
/**
* Group consecutive tool calls with the same name and input into groups.
* Identical consecutive calls collapse into one entry with count > 1.
* Caches previous key to avoid redundant JSON.stringify calls.
*/
function groupConsecutiveTools(toolCalls: ToolCall[]): ToolCallGroup[] {
const groups: ToolCallGroup[] = [];
let prevKey = "";
for (const tc of toolCalls) {
const key = tc.name + "|" + tc.status + "|" + JSON.stringify(tc.input || {});
if (key === prevKey && groups.length > 0) {
groups[groups.length - 1].count++;
} else {
groups.push({ tool: tc, count: 1 });
prevKey = key;
}
}
return groups;
}
// ============================================================================
// COMPLETED MESSAGE — memoized, never re-renders during streaming
// ============================================================================
export const CompletedMessage = React.memo(function CompletedMessage({ msg, index, toolsExpanded }: {
msg: ChatMessage;
index: number;
toolsExpanded: boolean;
}) {
const cw = Math.max(20, contentWidth());
const toolGroups = useMemo(
() => msg.toolCalls ? groupConsecutiveTools(msg.toolCalls) : [],
[msg.toolCalls]
);
return (
<Box flexDirection="column">
{/* Turn separator before user messages (except first) */}
{msg.role === "user" && index > 0 && (
<>
<Text>{" "}</Text>
<Text color={colors.separator}>{"─".repeat(cw)}</Text>
</>
)}
{msg.role === "user" ? (
<Box flexDirection="column">
<Text>{" "}</Text>
{msg.images && msg.images.length > 0 && (
<Box marginLeft={2}>
{msg.images.map((name, i) => (
<Text key={i}>
<Text color={colors.indigo}>[</Text>
<Text color={colors.secondary}>{name}</Text>
<Text color={colors.indigo}>]</Text>
<Text> </Text>
</Text>
))}
</Box>
)}
<Text>
<Text color={colors.brand} bold>{"❯ "}</Text>
<Text color={colors.user}>{msg.text}</Text>
</Text>
</Box>
) : (
<Box flexDirection="column">
{/* Tool calls — consecutive identical calls collapsed with × N count */}
{toolGroups.length > 0 && (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
{toolGroups.map((group, j) => (
<ToolIndicator
key={j}
id={`done-${index}-${j}`}
name={group.tool.name}
status={group.tool.status}
result={group.tool.result}
input={group.tool.input}
durationMs={group.tool.durationMs}
expanded={toolsExpanded}
count={group.count}
/>
))}
</Box>
)}
{/* Completed subagent summary tree */}
{msg.completedSubagents && msg.completedSubagents.length > 0 && (
<CompletedSubagentTree agents={msg.completedSubagents} />
)}
{/* Response text — blank line above for breathing room */}
{msg.text && (
<Box flexDirection="column" marginLeft={2}>
<Text>{" "}</Text>
<MarkdownText text={msg.text} />
</Box>
)}
{/* Telemetry footer — small gap above */}
{msg.usage && (
<>
<Text>{" "}</Text>
<Text>
{" "}
<Text color={colors.quaternary}>
{formatTokens(msg.usage.input_tokens)}
<Text color={colors.indigo}>{"\u2191"}</Text>
{" "}{formatTokens(msg.usage.output_tokens)}
<Text color={colors.purple}>{"\u2193"}</Text>
{msg.usage.thinking_tokens ? (
<>
{" "}{formatTokens(msg.usage.thinking_tokens)}
<Text color={colors.warning}>T</Text>
</>
) : null}
{msg.usage.cache_read_tokens ? (
<>
{" "}{formatTokens(msg.usage.cache_read_tokens)}
<Text color={colors.success}>C</Text>
</>
) : null}
</Text>
<Text color={colors.quaternary}> {estimateCost(msg.usage.input_tokens, msg.usage.output_tokens, msg.usage.model, msg.usage.costUsd)}</Text>
{msg.toolCalls && msg.toolCalls.length > 0 ? (
<Text color={colors.quaternary}>
{" "}{msg.toolCalls.length} tool{msg.toolCalls.length !== 1 ? "s" : ""}
{" "}{formatMs(totalToolDuration(msg.toolCalls))}
</Text>
) : null}
</Text>
</>
)}
</Box>
)}
</Box>
);
});