Files
tkmind_go/ui/text/src/toolcall.tsx
T

280 lines
8.8 KiB
TypeScript

import React from "react";
import { Box, Text } from "ink";
import type {
ToolCallContent,
ToolCallStatus,
ToolKind,
} from "@agentclientprotocol/sdk";
import { CRANBERRY, TEAL, GOLD, TEXT_SECONDARY, TEXT_DIM } from "./colors.js";
export interface ToolCallInfo {
toolCallId: string;
title: string;
status: ToolCallStatus;
kind?: ToolKind;
rawInput?: unknown;
rawOutput?: unknown;
content?: ToolCallContent[];
locations?: Array<{ path: string; line?: number | null }>;
}
const CEDAR = "#6B5344";
const KIND_ICONS: Record<string, string> = {
read: "📖",
edit: "✏️",
delete: "🗑",
move: "📦",
search: "🔍",
execute: "▶",
think: "💭",
fetch: "🌐",
switch_mode: "🔀",
other: "⚙",
};
const STATUS_INDICATORS: Record<string, { icon: string; color: string }> = {
pending: { icon: "○", color: TEXT_DIM },
in_progress: { icon: "◑", color: GOLD },
completed: { icon: "●", color: TEAL },
failed: { icon: "✗", color: CRANBERRY },
};
function formatJsonCompact(value: unknown, maxWidth: number): string[] {
if (value === undefined || value === null) return [];
let raw: string;
try {
raw = JSON.stringify(value, null, 2);
} catch {
raw = String(value);
}
const lines = raw.split("\n");
const result: string[] = [];
for (const line of lines) {
if (line.length <= maxWidth) {
result.push(line);
} else {
let remaining = line;
while (remaining.length > maxWidth) {
result.push(remaining.slice(0, maxWidth));
remaining = remaining.slice(maxWidth);
}
if (remaining) result.push(remaining);
}
}
return result;
}
function extractTextFromContent(content: ToolCallContent[]): string[] {
const lines: string[] = [];
for (const item of content) {
if (item.type === "content" && item.content) {
const block = item.content as any;
if (block.type === "text" && block.text) {
lines.push(...block.text.split("\n"));
}
} else if (item.type === "diff") {
const diff = item as any;
lines.push(`diff: ${diff.path || "unknown"}`);
} else if (item.type === "terminal") {
const term = item as any;
lines.push(`terminal: ${term.terminalId || "unknown"}`);
}
}
return lines;
}
function summarizeContent(info: ToolCallInfo): string {
const parts: string[] = [];
if (info.locations && info.locations.length > 0) {
for (const loc of info.locations) {
parts.push(loc.path + (loc.line ? `:${loc.line}` : ""));
}
}
if (info.content && info.content.length > 0) {
const textLines = extractTextFromContent(info.content);
if (textLines.length > 0) {
const first = textLines[0]!.trim();
if (first.length > 60) {
parts.push(first.slice(0, 57) + "…");
} else if (first) {
parts.push(first);
}
}
}
if (parts.length === 0 && info.rawOutput !== undefined && info.rawOutput !== null) {
const raw = String(
typeof info.rawOutput === "string" ? info.rawOutput : JSON.stringify(info.rawOutput),
);
const firstLine = raw.split("\n")[0] ?? "";
if (firstLine.length > 60) {
parts.push(firstLine.slice(0, 57) + "…");
} else if (firstLine) {
parts.push(firstLine);
}
}
return parts.join(" · ");
}
const MAX_PREVIEW_LINES = 8;
export function findFeaturedToolCallId(
toolCallOrder: string[],
toolCalls: Map<string, ToolCallInfo>,
): string | undefined {
for (let i = toolCallOrder.length - 1; i >= 0; i--) {
const tc = toolCalls.get(toolCallOrder[i]!);
if (tc && (tc.status === "pending" || tc.status === "in_progress")) {
return toolCallOrder[i]!;
}
}
return toolCallOrder[toolCallOrder.length - 1];
}
export function buildToolCallCardLines(
info: ToolCallInfo,
indent: number,
totalWidth: number,
expanded: boolean,
keyPrefix: string = "card",
): React.ReactNode[] {
const cardWidth = Math.min(totalWidth - indent - 2, 72);
const innerWidth = cardWidth - 2;
const contentWidth = innerWidth - 2;
const kindIcon = KIND_ICONS[info.kind ?? "other"] ?? "⚙";
const statusInfo = STATUS_INDICATORS[info.status] ?? STATUS_INDICATORS.pending!;
const borderColor = info.status === "failed" ? CRANBERRY : CEDAR;
const dimBorder = info.status !== "failed";
const hasInput = info.rawInput !== undefined && info.rawInput !== null;
const hasOutput = info.rawOutput !== undefined && info.rawOutput !== null;
const hasContent = info.content && info.content.length > 0;
const hasLocations = info.locations && info.locations.length > 0;
const inputLines = hasInput ? formatJsonCompact(info.rawInput, contentWidth - 6) : [];
const outputLines = hasOutput ? formatJsonCompact(info.rawOutput, contentWidth - 6) : [];
const contentLines = hasContent ? extractTextFromContent(info.content!) : [];
const shownInput = expanded ? inputLines : inputLines.slice(0, MAX_PREVIEW_LINES);
const shownOutput = expanded ? outputLines : outputLines.slice(0, MAX_PREVIEW_LINES);
const shownContent = expanded ? contentLines : contentLines.slice(0, MAX_PREVIEW_LINES);
const hasTruncated =
inputLines.length > MAX_PREVIEW_LINES ||
outputLines.length > MAX_PREVIEW_LINES ||
contentLines.length > MAX_PREVIEW_LINES;
const bodyRows: Array<{ text: string; color?: string; italic?: boolean }> = [];
const runningText = info.status === "in_progress" ? " running…" : "";
const tabHint = hasTruncated && !expanded ? "tab ↔" : "";
bodyRows.push({ text: "__HEADER__" });
if (hasLocations) {
for (const loc of info.locations!) {
bodyRows.push({ text: ` 📁 ${loc.path}${loc.line ? `:${loc.line}` : ""}`, color: TEXT_DIM });
}
}
function addSection(label: string, lines: string[], totalCount: number) {
if (lines.length === 0) return;
bodyRows.push({ text: ` ▸ ${label}:`, color: TEXT_DIM });
for (const line of lines) {
bodyRows.push({ text: ` ${line}`, color: TEXT_DIM });
}
if (!expanded && totalCount > MAX_PREVIEW_LINES) {
const remaining = totalCount - MAX_PREVIEW_LINES;
bodyRows.push({ text: ` ▸ ${remaining} more lines (tab to expand)`, color: GOLD, italic: true });
}
}
addSection("input", shownInput, inputLines.length);
addSection("output", shownOutput, outputLines.length);
addSection("content", shownContent, contentLines.length);
const result: React.ReactNode[] = [];
const topBorder = "╭" + "─".repeat(innerWidth) + "╮";
const botBorder = "╰" + "─".repeat(innerWidth) + "╯";
result.push(
<Box key={`${keyPrefix}-top`} marginLeft={indent} height={1}>
<Text color={borderColor} dimColor={dimBorder}>{topBorder}</Text>
</Box>,
);
for (let i = 0; i < bodyRows.length; i++) {
const row = bodyRows[i]!;
if (row.text === "__HEADER__") {
result.push(
<Box key={`${keyPrefix}-row-${i}`} marginLeft={indent} width={cardWidth} height={1}>
<Text color={borderColor} dimColor={dimBorder}>│ </Text>
<Box flexGrow={1} justifyContent="space-between">
<Box>
<Text color={statusInfo.color}>{statusInfo.icon} </Text>
<Text>{kindIcon} </Text>
<Text color={TEXT_SECONDARY} bold>{info.title}</Text>
{runningText ? <Text color={TEXT_DIM} italic>{runningText}</Text> : null}
</Box>
{tabHint ? <Text color={TEXT_DIM} italic>{tabHint}</Text> : null}
</Box>
<Text color={borderColor} dimColor={dimBorder}> │</Text>
</Box>,
);
continue;
}
result.push(
<Box key={`${keyPrefix}-row-${i}`} marginLeft={indent} width={cardWidth} height={1}>
<Text color={borderColor} dimColor={dimBorder}>│</Text>
<Box flexGrow={1}>
<Text color={row.color} italic={row.italic}> {row.text}</Text>
</Box>
<Text color={borderColor} dimColor={dimBorder}>│</Text>
</Box>,
);
}
result.push(
<Box key={`${keyPrefix}-bot`} marginLeft={indent} height={1}>
<Text color={borderColor} dimColor={dimBorder}>{botBorder}</Text>
</Box>,
);
return result;
}
export function ToolCallCompact({
info,
indent,
width,
}: {
info: ToolCallInfo;
indent: number;
width: number;
}) {
const statusInfo = STATUS_INDICATORS[info.status] ?? STATUS_INDICATORS.pending!;
const kindIcon = KIND_ICONS[info.kind ?? "other"] ?? "⚙";
const summary = summarizeContent(info);
const maxSummaryWidth = width - indent - 12 - info.title.length;
const trimmedSummary =
summary.length > maxSummaryWidth && maxSummaryWidth > 3
? summary.slice(0, maxSummaryWidth - 1) + "…"
: summary;
return (
<Box marginLeft={indent} height={1}>
<Text color={statusInfo.color}>{statusInfo.icon} </Text>
<Text>{kindIcon} </Text>
<Text color={TEXT_SECONDARY}>{info.title}</Text>
{trimmedSummary ? (
<Text color={TEXT_DIM}> — {trimmedSummary}</Text>
) : null}
</Box>
);
}