refactor: tighten canonical tool-call typing and display mapping
This commit is contained in:
@@ -46,13 +46,6 @@ import {
|
||||
Check,
|
||||
CheckSquare,
|
||||
X,
|
||||
Wrench,
|
||||
Pencil,
|
||||
Eye,
|
||||
SquareTerminal,
|
||||
Search,
|
||||
Brain,
|
||||
Bot,
|
||||
Copy,
|
||||
TriangleAlertIcon,
|
||||
Scissors,
|
||||
@@ -72,9 +65,8 @@ import type { TodoEntry } from "@/types/stream";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
buildToolCallDisplayModel,
|
||||
formatToolCallError,
|
||||
type ToolCallKind,
|
||||
} from "@/utils/tool-call-display";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
||||
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
|
||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||
@@ -110,29 +102,6 @@ function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) {
|
||||
return disableOuterSpacing ?? contextValue;
|
||||
}
|
||||
|
||||
function hexToRgba(hexColor: string, alpha: number) {
|
||||
const clampedAlpha = Math.max(0, Math.min(1, alpha));
|
||||
const hex = hexColor.replace("#", "");
|
||||
const normalized =
|
||||
hex.length === 3
|
||||
? hex
|
||||
.split("")
|
||||
.map((char) => `${char}${char}`)
|
||||
.join("")
|
||||
: hex;
|
||||
|
||||
if (!/^[\da-fA-F]{6}$/.test(normalized)) {
|
||||
return `rgba(255, 255, 255, ${clampedAlpha})`;
|
||||
}
|
||||
|
||||
const intValue = Number.parseInt(normalized, 16);
|
||||
const red = (intValue >> 16) & 255;
|
||||
const green = (intValue >> 8) & 255;
|
||||
const blue = intValue & 255;
|
||||
|
||||
return `rgba(${red}, ${green}, ${blue}, ${clampedAlpha})`;
|
||||
}
|
||||
|
||||
const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
@@ -445,9 +414,8 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
shimmerOverlay: {
|
||||
position: "absolute",
|
||||
top: 3,
|
||||
bottom: 3,
|
||||
borderRadius: 999,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1044,7 +1012,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
if (isLoading) {
|
||||
shimmer.value = -1;
|
||||
shimmer.value = withRepeat(
|
||||
withTiming(1, { duration: 3200, easing: Easing.linear }),
|
||||
withTiming(1, { duration: 2400, easing: Easing.bezier(0.4, 0, 0.6, 1) }),
|
||||
-1
|
||||
);
|
||||
} else {
|
||||
@@ -1053,15 +1021,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
const shimmerBandWidth = 14;
|
||||
const shimmerGradientColors = useMemo(
|
||||
() => ({
|
||||
edge: hexToRgba(theme.colors.foreground, 0),
|
||||
soft: hexToRgba(theme.colors.foreground, 0.08),
|
||||
core: hexToRgba(theme.colors.foreground, 0.24),
|
||||
}),
|
||||
[theme.colors.foreground]
|
||||
);
|
||||
const shimmerBandWidth = 18;
|
||||
const shimmerStyle = useAnimatedStyle(() => {
|
||||
const travel = badgeWidth + shimmerBandWidth;
|
||||
return {
|
||||
@@ -1148,13 +1108,13 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
x2="1"
|
||||
y2="0"
|
||||
>
|
||||
<Stop offset="0" stopColor={shimmerGradientColors.edge} />
|
||||
<Stop offset="0.38" stopColor={shimmerGradientColors.edge} />
|
||||
<Stop offset="0.48" stopColor={shimmerGradientColors.soft} />
|
||||
<Stop offset="0.5" stopColor={shimmerGradientColors.core} />
|
||||
<Stop offset="0.52" stopColor={shimmerGradientColors.soft} />
|
||||
<Stop offset="0.62" stopColor={shimmerGradientColors.edge} />
|
||||
<Stop offset="1" stopColor={shimmerGradientColors.edge} />
|
||||
<Stop offset="0" stopColor={theme.colors.surface1} stopOpacity="0" />
|
||||
<Stop offset="0.42" stopColor={theme.colors.surface1} stopOpacity="0" />
|
||||
<Stop offset="0.48" stopColor={theme.colors.surface1} stopOpacity="0.35" />
|
||||
<Stop offset="0.5" stopColor={theme.colors.surface1} stopOpacity="1" />
|
||||
<Stop offset="0.52" stopColor={theme.colors.surface1} stopOpacity="0.35" />
|
||||
<Stop offset="0.58" stopColor={theme.colors.surface1} stopOpacity="0" />
|
||||
<Stop offset="1" stopColor={theme.colors.surface1} stopOpacity="0" />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
<Rect width="100%" height="100%" fill="url(#shimmerGrad)" />
|
||||
@@ -1191,9 +1151,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
|
||||
interface ToolCallProps {
|
||||
toolName: string;
|
||||
args: any;
|
||||
result?: any;
|
||||
error?: any;
|
||||
args: unknown | null;
|
||||
result?: unknown | null;
|
||||
error?: unknown | null;
|
||||
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
||||
detail?: ToolCallDetail;
|
||||
cwd?: string;
|
||||
@@ -1204,15 +1164,6 @@ interface ToolCallProps {
|
||||
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
// Icon mapping for tool kinds
|
||||
const toolKindIcons: Record<string, any> = {
|
||||
edit: Pencil,
|
||||
read: Eye,
|
||||
execute: SquareTerminal,
|
||||
search: Search,
|
||||
thinking: Brain,
|
||||
agent: Bot,
|
||||
};
|
||||
const TOOL_CALL_LOG_TAG = "[ToolCall]";
|
||||
const TOOL_CALL_COMMIT_THRESHOLD_MS = 16;
|
||||
|
||||
@@ -1241,14 +1192,24 @@ export const ToolCall = memo(function ToolCall({
|
||||
UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
const displayModel = useMemo(
|
||||
() => buildToolCallDisplayModel({ name: toolName, detail, metadata, cwd }),
|
||||
[toolName, detail, metadata, cwd]
|
||||
() =>
|
||||
buildToolCallDisplayModel({
|
||||
name: toolName,
|
||||
status: status === "executing" ? "running" : status,
|
||||
input: args ?? null,
|
||||
output: result ?? null,
|
||||
error: error ?? null,
|
||||
detail,
|
||||
metadata,
|
||||
cwd,
|
||||
}),
|
||||
[toolName, status, args, result, error, detail, metadata, cwd]
|
||||
);
|
||||
const displayName = displayModel.displayName;
|
||||
const kind: ToolCallKind = displayModel.kind;
|
||||
const summary = displayModel.summary;
|
||||
const errorText = useMemo(() => formatToolCallError(error), [error]);
|
||||
const IconComponent = toolKindIcons[kind] || Wrench;
|
||||
const errorText = displayModel.errorText;
|
||||
const iconCategory = detail?.type ?? toolName.trim().toLowerCase();
|
||||
const IconComponent = resolveToolCallIcon(toolName, detail);
|
||||
|
||||
// Check if there's any content to display
|
||||
const hasDetails =
|
||||
@@ -1260,7 +1221,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
}
|
||||
if (isMobile) {
|
||||
openToolCall({
|
||||
kind,
|
||||
toolName,
|
||||
displayName,
|
||||
summary,
|
||||
detail,
|
||||
@@ -1271,7 +1232,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
} else {
|
||||
setIsExpanded((prev) => !prev);
|
||||
}
|
||||
}, [isMobile, openToolCall, kind, displayName, summary, detail, args, result, errorText]);
|
||||
}, [isMobile, openToolCall, toolName, displayName, summary, detail, args, result, errorText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile || !isPerfLoggingEnabled()) {
|
||||
@@ -1288,7 +1249,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
perfLog(TOOL_CALL_LOG_TAG, {
|
||||
event: isExpanded ? "expand_commit" : "collapse_commit",
|
||||
toolName,
|
||||
kind,
|
||||
iconCategory,
|
||||
durationMs: Math.round(durationMs),
|
||||
});
|
||||
}
|
||||
@@ -1298,7 +1259,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
} else {
|
||||
logCommit();
|
||||
}
|
||||
}, [isExpanded, isMobile, toolName, kind]);
|
||||
}, [isExpanded, isMobile, toolName, iconCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onInlineDetailsHoverChange || isMobile || isExpanded) {
|
||||
|
||||
@@ -15,15 +15,15 @@ import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { Pencil, Eye, SquareTerminal, Search, Bot, Wrench, X } from "lucide-react-native";
|
||||
import { X } from "lucide-react-native";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import type { ToolCallKind } from "@/utils/tool-call-display";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
|
||||
// ----- Types -----
|
||||
|
||||
export type ToolCallSheetData = {
|
||||
kind: ToolCallKind;
|
||||
toolName: string;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
detail?: ToolCallDetail;
|
||||
@@ -49,16 +49,6 @@ export function useToolCallSheet(): ToolCallSheetContextValue {
|
||||
return context;
|
||||
}
|
||||
|
||||
// ----- Icon Mapping -----
|
||||
|
||||
const toolKindIcons: Record<string, React.ComponentType<{ size?: number; color?: string }>> = {
|
||||
edit: Pencil,
|
||||
read: Eye,
|
||||
execute: SquareTerminal,
|
||||
search: Search,
|
||||
agent: Bot,
|
||||
};
|
||||
|
||||
// ----- Custom Background Component -----
|
||||
|
||||
function CustomSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
@@ -142,9 +132,9 @@ interface ToolCallSheetContentProps {
|
||||
}
|
||||
|
||||
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
const { kind, displayName, detail, input, output, errorText } = data;
|
||||
const { toolName, displayName, detail, input, output, errorText } = data;
|
||||
|
||||
const IconComponent = toolKindIcons[kind] || Wrench;
|
||||
const IconComponent = resolveToolCallIcon(toolName, detail);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
|
||||
@@ -175,6 +175,10 @@ describe("stream reducer canonical tool calls", () => {
|
||||
|
||||
const summary = buildToolCallDisplayModel({
|
||||
name: tool.payload.data.name,
|
||||
status: tool.payload.data.status,
|
||||
input: tool.payload.data.input,
|
||||
output: tool.payload.data.result,
|
||||
error: tool.payload.data.error,
|
||||
detail: tool.payload.data.detail,
|
||||
}).summary;
|
||||
assert.strictEqual(summary, "npm test");
|
||||
@@ -204,12 +208,47 @@ describe("stream reducer canonical tool calls", () => {
|
||||
|
||||
const summary = buildToolCallDisplayModel({
|
||||
name: tool.payload.data.name,
|
||||
status: tool.payload.data.status,
|
||||
input: tool.payload.data.input,
|
||||
output: tool.payload.data.result,
|
||||
error: tool.payload.data.error,
|
||||
detail: tool.payload.data.detail,
|
||||
cwd: "/tmp/repo",
|
||||
}).summary;
|
||||
assert.strictEqual(summary, "README.md");
|
||||
});
|
||||
|
||||
it("does not infer command summary when detail is absent", () => {
|
||||
const callId = "running-summary-shell-input-only";
|
||||
const state = hydrateStreamState([
|
||||
{
|
||||
event: canonicalToolTimeline({
|
||||
provider: "codex",
|
||||
callId,
|
||||
name: "exec_command",
|
||||
status: "running",
|
||||
input: { command: "npm run lint" },
|
||||
output: null,
|
||||
}),
|
||||
timestamp: new Date("2025-01-01T10:17:00Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const tool = findToolByCallId(state, callId);
|
||||
assert.ok(tool);
|
||||
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: tool.payload.data.name,
|
||||
status: tool.payload.data.status,
|
||||
input: tool.payload.data.input,
|
||||
output: tool.payload.data.result,
|
||||
error: tool.payload.data.error,
|
||||
detail: tool.payload.data.detail,
|
||||
});
|
||||
assert.strictEqual(display.summary, undefined);
|
||||
assert.strictEqual(display.displayName, "Exec Command");
|
||||
});
|
||||
|
||||
it("preserves early input when later updates contain null input", () => {
|
||||
const callId = "null-input-preserve";
|
||||
const updates = [
|
||||
|
||||
@@ -158,6 +158,10 @@ export interface TodoListItem {
|
||||
items: TodoEntry[];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeChunk(text: string): { chunk: string; hasContent: boolean } {
|
||||
if (!text) {
|
||||
return { chunk: "", hasContent: false };
|
||||
@@ -169,6 +173,16 @@ function normalizeChunk(text: string): { chunk: string; hasContent: boolean } {
|
||||
return { chunk, hasContent: /\S/.test(chunk) };
|
||||
}
|
||||
|
||||
function markThoughtReady(item: ThoughtItem): ThoughtItem {
|
||||
if (item.status === "ready") {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
status: "ready",
|
||||
};
|
||||
}
|
||||
|
||||
function appendUserMessage(
|
||||
state: StreamItem[],
|
||||
text: string,
|
||||
@@ -228,7 +242,7 @@ function appendAssistantMessage(
|
||||
const secondLast = state[state.length - 2];
|
||||
if (last?.kind === "user_message" && secondLast?.kind === "assistant_message") {
|
||||
const updated: AssistantMessageItem = {
|
||||
...(secondLast as AssistantMessageItem),
|
||||
...secondLast,
|
||||
text: `${secondLast.text}${chunk}`,
|
||||
timestamp,
|
||||
};
|
||||
@@ -290,7 +304,7 @@ function finalizeActiveThoughts(state: StreamItem[]): StreamItem[] {
|
||||
const nextState = state.map((entry) => {
|
||||
if (entry.kind === "thought" && entry.status !== "ready") {
|
||||
mutated = true;
|
||||
return { ...entry, status: "ready" as ThoughtStatus };
|
||||
return markThoughtReady(entry);
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
@@ -311,12 +325,7 @@ function findExistingAgentToolCallIndex(
|
||||
}
|
||||
|
||||
function hasNonEmptyObject(value: unknown): boolean {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value as Record<string, unknown>).length > 0
|
||||
);
|
||||
return isRecord(value) && Object.keys(value).length > 0;
|
||||
}
|
||||
|
||||
function mergeCanonicalValue(
|
||||
@@ -362,7 +371,10 @@ function appendAgentToolCall(
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const next = [...state];
|
||||
const existing = next[existingIndex] as AgentToolCallItem;
|
||||
const existing = next[existingIndex];
|
||||
if (!existing || !isAgentToolCallItem(existing)) {
|
||||
return state;
|
||||
}
|
||||
const mergedInput = mergeCanonicalValue(existing.payload.data.input, data.input);
|
||||
const mergedResult = mergeCanonicalValue(existing.payload.data.result, data.result);
|
||||
const mergedStatus = mergeAgentToolCallStatus(
|
||||
@@ -570,7 +582,10 @@ export function reduceStreamUpdate(
|
||||
// Avoid rendering legacy plan-mode todo timeline items as Tasks.
|
||||
break;
|
||||
}
|
||||
const items = (item.items ?? []) as TodoEntry[];
|
||||
const items: TodoEntry[] = (item.items ?? []).map((todo) => ({
|
||||
text: todo.text,
|
||||
completed: Boolean(todo.completed),
|
||||
}));
|
||||
nextState = appendTodoList(state, event.provider, items, timestamp);
|
||||
break;
|
||||
}
|
||||
@@ -591,7 +606,10 @@ export function reduceStreamUpdate(
|
||||
(s) => s.kind === "compaction" && s.status === "loading"
|
||||
);
|
||||
if (loadingIdx >= 0) {
|
||||
const existing = state[loadingIdx] as CompactionItem;
|
||||
const existing = state[loadingIdx];
|
||||
if (!existing || existing.kind !== "compaction") {
|
||||
break;
|
||||
}
|
||||
const updated: CompactionItem = {
|
||||
...existing,
|
||||
status: "completed",
|
||||
@@ -704,7 +722,7 @@ function getEventItemKind(
|
||||
function finalizeHeadItems(head: StreamItem[]): StreamItem[] {
|
||||
return head.map((item) => {
|
||||
if (item.kind === "thought" && item.status !== "ready") {
|
||||
return { ...item, status: "ready" as ThoughtStatus };
|
||||
return markThoughtReady(item);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
@@ -881,7 +899,7 @@ export function applyStreamEventWithBuffer(params: {
|
||||
const head: StreamItem[] = params.buffer
|
||||
? [
|
||||
{
|
||||
kind: "assistant_message" as const,
|
||||
kind: "assistant_message",
|
||||
id: params.buffer.id,
|
||||
text: params.buffer.text,
|
||||
timestamp: params.buffer.timestamp,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildToolCallDisplayModel, formatToolCallError } from "./tool-call-display";
|
||||
import { buildToolCallDisplayModel } from "./tool-call-display";
|
||||
|
||||
describe("tool-call-display", () => {
|
||||
it("builds display model from canonical shell detail", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "shell",
|
||||
status: "running",
|
||||
input: { command: "npm test" },
|
||||
output: null,
|
||||
error: null,
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npm test",
|
||||
@@ -13,7 +17,6 @@ describe("tool-call-display", () => {
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
kind: "execute",
|
||||
displayName: "Shell",
|
||||
summary: "npm test",
|
||||
});
|
||||
@@ -22,6 +25,10 @@ describe("tool-call-display", () => {
|
||||
it("builds display model from canonical read detail", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "read_file",
|
||||
status: "completed",
|
||||
input: { path: "/tmp/repo/src/index.ts" },
|
||||
output: { content: "hello" },
|
||||
error: null,
|
||||
detail: {
|
||||
type: "read",
|
||||
filePath: "/tmp/repo/src/index.ts",
|
||||
@@ -30,7 +37,6 @@ describe("tool-call-display", () => {
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
kind: "read",
|
||||
displayName: "Read",
|
||||
summary: "src/index.ts",
|
||||
});
|
||||
@@ -39,13 +45,16 @@ describe("tool-call-display", () => {
|
||||
it("uses metadata summary for task tool calls", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "task",
|
||||
status: "running",
|
||||
input: null,
|
||||
output: null,
|
||||
error: null,
|
||||
metadata: {
|
||||
subAgentActivity: "Running tests",
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
kind: "agent",
|
||||
displayName: "Task",
|
||||
summary: "Running tests",
|
||||
});
|
||||
@@ -54,15 +63,40 @@ describe("tool-call-display", () => {
|
||||
it("falls back to humanized tool name for unknown tools", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "custom_tool_name",
|
||||
status: "completed",
|
||||
input: null,
|
||||
output: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
kind: "tool",
|
||||
displayName: "Custom Tool Name",
|
||||
});
|
||||
});
|
||||
|
||||
it("formats non-string errors", () => {
|
||||
expect(formatToolCallError({ message: "boom" })).toBe('{\n "message": "boom"\n}');
|
||||
it("does not derive command summary from raw input when detail is missing", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "exec_command",
|
||||
status: "running",
|
||||
input: { command: "npm run test" },
|
||||
output: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Exec Command",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns formatted errorText from the same display pipeline", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "shell",
|
||||
status: "failed",
|
||||
input: { command: "false" },
|
||||
output: null,
|
||||
error: { message: "boom" },
|
||||
});
|
||||
|
||||
expect(display.errorText).toBe('{\n "message": "boom"\n}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,211 +1,5 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export type ToolCallKind =
|
||||
| "read"
|
||||
| "edit"
|
||||
| "write"
|
||||
| "execute"
|
||||
| "search"
|
||||
| "agent"
|
||||
| "tool"
|
||||
| "thinking";
|
||||
|
||||
type SummaryParams = {
|
||||
name: string;
|
||||
detail?: ToolCallDetail;
|
||||
metadata?: Record<string, unknown>;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export type ToolCallDisplayModel = {
|
||||
kind: ToolCallKind;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
const TOOL_CALL_DETAIL_SCHEMA: z.ZodType<ToolCallDetail> = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("shell"),
|
||||
command: z.string(),
|
||||
cwd: z.string().optional(),
|
||||
output: z.string().optional(),
|
||||
exitCode: z.number().nullable().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("read"),
|
||||
filePath: z.string(),
|
||||
content: z.string().optional(),
|
||||
offset: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("edit"),
|
||||
filePath: z.string(),
|
||||
oldString: z.string().optional(),
|
||||
newString: z.string().optional(),
|
||||
unifiedDiff: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("write"),
|
||||
filePath: z.string(),
|
||||
content: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("search"),
|
||||
query: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const TOOL_CALL_DISPLAY_INPUT_SCHEMA = z.object({
|
||||
name: z.string().min(1),
|
||||
detail: TOOL_CALL_DETAIL_SCHEMA.optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
cwd: z.string().optional(),
|
||||
});
|
||||
|
||||
function toTitleCase(words: string): string {
|
||||
return words
|
||||
.split(" ")
|
||||
.filter((segment) => segment.length > 0)
|
||||
.map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function humanizeToolName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return name;
|
||||
}
|
||||
return toTitleCase(trimmed.replace(/[._-]+/g, " "));
|
||||
}
|
||||
|
||||
export function stripCwdPrefix(filePath: string, cwd?: string): string {
|
||||
if (!cwd || !filePath) return filePath;
|
||||
|
||||
const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedPath = filePath.replace(/\\/g, "/");
|
||||
|
||||
const prefix = `${normalizedCwd}/`;
|
||||
if (normalizedPath.startsWith(prefix)) {
|
||||
return normalizedPath.slice(prefix.length);
|
||||
}
|
||||
if (normalizedPath === normalizedCwd) {
|
||||
return ".";
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function buildDisplayFromDetail(detail: ToolCallDetail, cwd?: string): ToolCallDisplayModel {
|
||||
switch (detail.type) {
|
||||
case "shell":
|
||||
return {
|
||||
kind: "execute",
|
||||
displayName: "Shell",
|
||||
summary: detail.command,
|
||||
};
|
||||
case "read":
|
||||
return {
|
||||
kind: "read",
|
||||
displayName: "Read",
|
||||
summary: stripCwdPrefix(detail.filePath, cwd),
|
||||
};
|
||||
case "edit":
|
||||
return {
|
||||
kind: "edit",
|
||||
displayName: "Edit",
|
||||
summary: stripCwdPrefix(detail.filePath, cwd),
|
||||
};
|
||||
case "write":
|
||||
return {
|
||||
kind: "write",
|
||||
displayName: "Write",
|
||||
summary: stripCwdPrefix(detail.filePath, cwd),
|
||||
};
|
||||
case "search":
|
||||
return {
|
||||
kind: "search",
|
||||
displayName: "Search",
|
||||
summary: detail.query,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
kind: "tool",
|
||||
displayName: "Tool",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildDisplayWithoutDetail(params: {
|
||||
toolNameLower: string;
|
||||
rawName: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): ToolCallDisplayModel {
|
||||
if (params.toolNameLower === "task") {
|
||||
const summary = params.metadata?.subAgentActivity;
|
||||
return {
|
||||
kind: "agent",
|
||||
displayName: "Task",
|
||||
summary: typeof summary === "string" && summary.trim().length > 0 ? summary : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.toolNameLower === "thinking") {
|
||||
return {
|
||||
kind: "thinking",
|
||||
displayName: "Thinking",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "tool",
|
||||
displayName: humanizeToolName(params.rawName),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildToolCallDisplayModel(params: SummaryParams): ToolCallDisplayModel {
|
||||
const parsed = TOOL_CALL_DISPLAY_INPUT_SCHEMA.parse(params);
|
||||
if (parsed.detail) {
|
||||
return buildDisplayFromDetail(parsed.detail, parsed.cwd);
|
||||
}
|
||||
|
||||
return buildDisplayWithoutDetail({
|
||||
toolNameLower: parsed.name.trim().toLowerCase(),
|
||||
rawName: parsed.name,
|
||||
metadata: parsed.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveToolCallDisplayName(name: string, detail?: ToolCallDetail): string {
|
||||
return buildToolCallDisplayModel({ name, detail }).displayName;
|
||||
}
|
||||
|
||||
export function resolveToolCallKind(name: string, detail?: ToolCallDetail): ToolCallKind {
|
||||
return buildToolCallDisplayModel({ name, detail }).kind;
|
||||
}
|
||||
|
||||
export function resolveToolCallSummary(params: SummaryParams): string | undefined {
|
||||
return buildToolCallDisplayModel(params).summary;
|
||||
}
|
||||
|
||||
export function formatToolCallError(error: unknown): string | undefined {
|
||||
if (error === null || error === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
"content" in (error as Record<string, unknown>) &&
|
||||
typeof (error as Record<string, unknown>).content === "string"
|
||||
) {
|
||||
return (error as Record<string, unknown>).content as string;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error, null, 2);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
export {
|
||||
buildToolCallDisplayModel,
|
||||
type ToolCallDisplayInput,
|
||||
type ToolCallDisplayModel,
|
||||
} from "@server/shared/tool-call-display";
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { Bot, Brain, Eye, Pencil, Search, SquareTerminal, Wrench } from "lucide-react-native";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export type ToolCallIconComponent = ComponentType<{ size?: number; color?: string }>;
|
||||
|
||||
const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> = {
|
||||
shell: SquareTerminal,
|
||||
read: Eye,
|
||||
edit: Pencil,
|
||||
write: Pencil,
|
||||
search: Search,
|
||||
};
|
||||
|
||||
export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail): ToolCallIconComponent {
|
||||
if (detail) {
|
||||
return TOOL_DETAIL_ICONS[detail.type];
|
||||
}
|
||||
|
||||
const lowerName = toolName.trim().toLowerCase();
|
||||
if (lowerName === "task") {
|
||||
return Bot;
|
||||
}
|
||||
if (lowerName === "thinking") {
|
||||
return Brain;
|
||||
}
|
||||
return Wrench;
|
||||
}
|
||||
Reference in New Issue
Block a user