@aphrody/m3-ai 3.3.1 → 3.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/dist/A2APeerInspector.d.ts +41 -0
- package/dist/A2APeerInspector.d.ts.map +1 -0
- package/dist/A2ATaskTimeline.d.ts +28 -0
- package/dist/A2ATaskTimeline.d.ts.map +1 -0
- package/dist/AgUiEventStream.d.ts +87 -0
- package/dist/AgUiEventStream.d.ts.map +1 -0
- package/dist/GeminiAccountMenu.d.ts +55 -0
- package/dist/GeminiAccountMenu.d.ts.map +1 -0
- package/dist/GeminiPersonalContextEdit.d.ts +29 -0
- package/dist/GeminiPersonalContextEdit.d.ts.map +1 -0
- package/dist/GoogleSpotlightBar.d.ts +32 -0
- package/dist/GoogleSpotlightBar.d.ts.map +1 -0
- package/dist/Markdown.d.ts +2 -1
- package/dist/Markdown.d.ts.map +1 -1
- package/dist/McpAppCard.d.ts +24 -0
- package/dist/McpAppCard.d.ts.map +1 -0
- package/dist/ModelSelector.d.ts.map +1 -1
- package/dist/TaskRow.d.ts.map +1 -1
- package/dist/ToolCall.d.ts.map +1 -1
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3629 -1950
- package/dist/index.js.map +32 -1
- package/package.json +17 -20
- package/src/A2APeerInspector.tsx +378 -0
- package/src/A2ATaskTimeline.tsx +218 -0
- package/src/AgUiEventStream.ts +488 -0
- package/src/ChatComposer.tsx +239 -0
- package/src/GeminiAccountMenu.tsx +212 -0
- package/src/GeminiPersonalContextEdit.tsx +147 -0
- package/src/GoogleSpotlightBar.tsx +165 -0
- package/src/InlineBranchCard.tsx +97 -0
- package/src/InlineWorkerCard.tsx +182 -0
- package/src/Markdown.tsx +701 -0
- package/src/McpAppCard.tsx +161 -0
- package/src/MessageBubble.tsx +95 -0
- package/src/ModelSelector.tsx +300 -0
- package/src/TaskCreateForm.tsx +123 -0
- package/src/TaskDetail.tsx +224 -0
- package/src/TaskList.tsx +136 -0
- package/src/TaskPriorityIcon.tsx +39 -0
- package/src/TaskRow.tsx +177 -0
- package/src/TaskStatusIcon.tsx +39 -0
- package/src/ToolCall.tsx +639 -0
- package/src/index.ts +107 -0
- package/src/internal.tsx +107 -0
- package/src/types.ts +212 -0
package/src/ToolCall.tsx
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0 AND MIT
|
|
2
|
+
// Derived from SpaceUI (packages/aphrody-spaceui/packages/ai/src/ToolCall.tsx), MIT, rebuilt on Material Design 3.
|
|
3
|
+
|
|
4
|
+
import { useId, useState } from "react";
|
|
5
|
+
import { clsx } from "clsx";
|
|
6
|
+
import { AnimatePresence, motion } from "motion/react";
|
|
7
|
+
import { Icon } from "@aphrody/m3-primitives";
|
|
8
|
+
import type { ToolCallPair, ToolCallStatus } from "./types";
|
|
9
|
+
import { CircularIndicator, StatusChip, expandMotion, focusRing } from "./internal";
|
|
10
|
+
import { McpAppCard } from "./McpAppCard";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Tool-specific rendering
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
interface ToolRenderer {
|
|
17
|
+
summary(pair: ToolCallPair): string | null;
|
|
18
|
+
argsView?(pair: ToolCallPair): React.ReactNode | null;
|
|
19
|
+
resultView?(pair: ToolCallPair): React.ReactNode | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The first present argument among `keys` (the tools name the same argument differently). */
|
|
23
|
+
function firstArg(pair: ToolCallPair, keys: readonly string[]): unknown {
|
|
24
|
+
for (const key of keys) {
|
|
25
|
+
const value = pair.args?.[key];
|
|
26
|
+
if (value !== undefined && value !== null) return value;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const pathSummary =
|
|
32
|
+
(pathKeys: readonly string[]): ToolRenderer["summary"] =>
|
|
33
|
+
(pair) => {
|
|
34
|
+
if (pair.title) return pair.title;
|
|
35
|
+
const path = firstArg(pair, pathKeys);
|
|
36
|
+
return path ? truncate(String(path), 60) : null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const fileResultView = (pair: ToolCallPair) =>
|
|
40
|
+
pair.resultRaw ? <ResultLine text={truncate(pair.resultRaw, 100)} /> : null;
|
|
41
|
+
|
|
42
|
+
/** `file_read` and `read`: the path with its optional line window, output in a collapsible block. */
|
|
43
|
+
function fileRead(pathKeys: readonly string[]): ToolRenderer {
|
|
44
|
+
return {
|
|
45
|
+
summary: pathSummary(pathKeys),
|
|
46
|
+
argsView(pair) {
|
|
47
|
+
const path = firstArg(pair, pathKeys);
|
|
48
|
+
if (!path) return null;
|
|
49
|
+
const offset = pair.args?.offset;
|
|
50
|
+
const limit = pair.args?.limit;
|
|
51
|
+
return (
|
|
52
|
+
<div className="rounded-small bg-surface-container-highest px-3 py-2">
|
|
53
|
+
<p className="font-code text-body-small text-on-surface-variant">
|
|
54
|
+
{String(path)}
|
|
55
|
+
{offset ? ` (from line ${offset})` : ""}
|
|
56
|
+
{limit ? ` (${limit} lines)` : ""}
|
|
57
|
+
</p>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
},
|
|
61
|
+
resultView(pair) {
|
|
62
|
+
if (!pair.resultRaw) return null;
|
|
63
|
+
return <CollapsiblePre text={pair.resultRaw} maxLines={30} />;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `file_write` and `write`: the path and a preview of the content. */
|
|
69
|
+
function fileWrite(pathKeys: readonly string[]): ToolRenderer {
|
|
70
|
+
return {
|
|
71
|
+
summary: pathSummary(pathKeys),
|
|
72
|
+
argsView(pair) {
|
|
73
|
+
const path = firstArg(pair, pathKeys);
|
|
74
|
+
const content = pair.args?.content;
|
|
75
|
+
if (!path && !content) return null;
|
|
76
|
+
return (
|
|
77
|
+
<div className="rounded-small bg-surface-container-highest px-3 py-2">
|
|
78
|
+
{!!path && (
|
|
79
|
+
<p className="mb-1 font-code text-body-small text-on-surface-variant">{String(path)}</p>
|
|
80
|
+
)}
|
|
81
|
+
{!!content && (
|
|
82
|
+
<pre className="max-h-40 overflow-auto font-code text-body-small text-outline">
|
|
83
|
+
{truncate(String(content), 2000)}
|
|
84
|
+
</pre>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
},
|
|
89
|
+
resultView: fileResultView,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `file_edit` and `edit`: the path and the old / new text. */
|
|
94
|
+
function fileEdit(
|
|
95
|
+
pathKeys: readonly string[],
|
|
96
|
+
oldKeys: readonly string[],
|
|
97
|
+
newKeys: readonly string[],
|
|
98
|
+
): ToolRenderer {
|
|
99
|
+
// Whole class names, so Tailwind's scanner finds them.
|
|
100
|
+
const TONE = { error: "text-error", success: "text-success" } as const;
|
|
101
|
+
const side = (label: string, tone: keyof typeof TONE, text: unknown) =>
|
|
102
|
+
text ? (
|
|
103
|
+
<div className="mt-1">
|
|
104
|
+
<p className={`text-body-small font-medium ${TONE[tone]}`}>{label}</p>
|
|
105
|
+
<pre className={`max-h-20 overflow-auto font-code text-body-small ${TONE[tone]}`}>
|
|
106
|
+
{truncate(String(text), 500)}
|
|
107
|
+
</pre>
|
|
108
|
+
</div>
|
|
109
|
+
) : null;
|
|
110
|
+
return {
|
|
111
|
+
summary: pathSummary(pathKeys),
|
|
112
|
+
argsView(pair) {
|
|
113
|
+
const path = firstArg(pair, pathKeys);
|
|
114
|
+
if (!path) return null;
|
|
115
|
+
return (
|
|
116
|
+
<div className="rounded-small bg-surface-container-highest px-3 py-2">
|
|
117
|
+
<p className="mb-1 font-code text-body-small text-on-surface-variant">{String(path)}</p>
|
|
118
|
+
{side("Old", "error", firstArg(pair, oldKeys))}
|
|
119
|
+
{side("New", "success", firstArg(pair, newKeys))}
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
},
|
|
123
|
+
resultView: fileResultView,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const toolRenderers: Record<string, ToolRenderer> = {
|
|
128
|
+
file_read: fileRead(["path"]),
|
|
129
|
+
file_write: fileWrite(["path"]),
|
|
130
|
+
file_edit: fileEdit(["path"], ["old_string"], ["new_string"]),
|
|
131
|
+
|
|
132
|
+
file_list: {
|
|
133
|
+
summary: pathSummary(["path"]),
|
|
134
|
+
resultView(pair) {
|
|
135
|
+
if (!pair.resultRaw) return null;
|
|
136
|
+
return <CollapsiblePre text={pair.resultRaw} maxLines={30} />;
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
set_status: {
|
|
141
|
+
summary(pair) {
|
|
142
|
+
const kind = pair.args?.kind;
|
|
143
|
+
const message = pair.args?.message;
|
|
144
|
+
if (kind === "outcome") {
|
|
145
|
+
return message ? `Outcome: ${truncate(String(message), 50)}` : "Outcome set";
|
|
146
|
+
}
|
|
147
|
+
return message ? truncate(String(message), 60) : null;
|
|
148
|
+
},
|
|
149
|
+
resultView() {
|
|
150
|
+
return null;
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
// OpenCode tools
|
|
155
|
+
|
|
156
|
+
read: fileRead(["filePath", "file_path"]),
|
|
157
|
+
write: fileWrite(["filePath", "file_path"]),
|
|
158
|
+
edit: fileEdit(["filePath", "file_path"], ["oldString", "old_string"], ["newString", "new_string"]),
|
|
159
|
+
|
|
160
|
+
glob: {
|
|
161
|
+
summary(pair) {
|
|
162
|
+
if (pair.title) return pair.title;
|
|
163
|
+
const pattern = pair.args?.pattern;
|
|
164
|
+
return pattern ? truncate(String(pattern), 60) : null;
|
|
165
|
+
},
|
|
166
|
+
resultView(pair) {
|
|
167
|
+
if (!pair.resultRaw) return null;
|
|
168
|
+
return <CollapsiblePre text={pair.resultRaw} maxLines={20} />;
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
grep: {
|
|
173
|
+
summary(pair) {
|
|
174
|
+
if (pair.title) return pair.title;
|
|
175
|
+
const pattern = pair.args?.pattern;
|
|
176
|
+
const include = pair.args?.include;
|
|
177
|
+
if (pattern && include) {
|
|
178
|
+
return `/${pattern}/ in ${include}`;
|
|
179
|
+
}
|
|
180
|
+
return pattern ? `/${truncate(String(pattern), 40)}/` : null;
|
|
181
|
+
},
|
|
182
|
+
resultView(pair) {
|
|
183
|
+
if (!pair.resultRaw) return null;
|
|
184
|
+
return <CollapsiblePre text={pair.resultRaw} maxLines={20} />;
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
webfetch: {
|
|
189
|
+
summary(pair) {
|
|
190
|
+
if (pair.title) return pair.title;
|
|
191
|
+
const url = pair.args?.url;
|
|
192
|
+
return url ? truncate(String(url), 60) : null;
|
|
193
|
+
},
|
|
194
|
+
resultView(pair) {
|
|
195
|
+
if (!pair.resultRaw) return null;
|
|
196
|
+
return <CollapsiblePre text={pair.resultRaw} maxLines={20} />;
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
read_skill: {
|
|
201
|
+
summary(pair) {
|
|
202
|
+
if (pair.title) return pair.title;
|
|
203
|
+
const name = pair.args?.name;
|
|
204
|
+
return name ? String(name) : null;
|
|
205
|
+
},
|
|
206
|
+
resultView(pair) {
|
|
207
|
+
if (!pair.resultRaw) return null;
|
|
208
|
+
return <CollapsiblePre text={pair.resultRaw} maxLines={30} />;
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
web_search: {
|
|
213
|
+
summary(pair) {
|
|
214
|
+
if (pair.title) return pair.title;
|
|
215
|
+
const query = pair.args?.query;
|
|
216
|
+
const resultCount =
|
|
217
|
+
pair.result?.result_count ??
|
|
218
|
+
(Array.isArray(pair.result?.results) ? (pair.result!.results as unknown[]).length : null);
|
|
219
|
+
const queryStr = query ? truncate(String(query), 50) : null;
|
|
220
|
+
if (queryStr && resultCount != null) {
|
|
221
|
+
return `${queryStr} (${resultCount} result${resultCount !== 1 ? "s" : ""})`;
|
|
222
|
+
}
|
|
223
|
+
return queryStr;
|
|
224
|
+
},
|
|
225
|
+
argsView(pair) {
|
|
226
|
+
const query = pair.args?.query;
|
|
227
|
+
if (!query) return null;
|
|
228
|
+
const count = pair.args?.count;
|
|
229
|
+
const freshness = pair.args?.freshness;
|
|
230
|
+
return (
|
|
231
|
+
<div className="rounded-small bg-surface-container-highest px-3 py-2">
|
|
232
|
+
<p className="text-body-small text-on-surface-variant">
|
|
233
|
+
<span className="select-none text-outline">Search: </span>
|
|
234
|
+
{String(query)}
|
|
235
|
+
</p>
|
|
236
|
+
{!!(count || freshness) && (
|
|
237
|
+
<p className="mt-0.5 text-body-small text-outline">
|
|
238
|
+
{count ? `${count} results` : ""}
|
|
239
|
+
{count && freshness ? " · " : ""}
|
|
240
|
+
{freshness ? `${freshness}` : ""}
|
|
241
|
+
</p>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
},
|
|
246
|
+
resultView(pair) {
|
|
247
|
+
if (!pair.resultRaw) return null;
|
|
248
|
+
return <CollapsiblePre text={pair.resultRaw} maxLines={20} />;
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
spacebot_docs: {
|
|
253
|
+
summary(pair) {
|
|
254
|
+
if (pair.title) return pair.title;
|
|
255
|
+
const action = pair.args?.action;
|
|
256
|
+
const docId = pair.args?.doc_id;
|
|
257
|
+
if (action === "read" && docId) {
|
|
258
|
+
return truncate(String(docId), 50);
|
|
259
|
+
}
|
|
260
|
+
return action ? String(action) : "list";
|
|
261
|
+
},
|
|
262
|
+
argsView(pair) {
|
|
263
|
+
const action = pair.args?.action;
|
|
264
|
+
const docId = pair.args?.doc_id;
|
|
265
|
+
const query = pair.args?.query;
|
|
266
|
+
if (!action && !docId) return null;
|
|
267
|
+
return (
|
|
268
|
+
<div className="rounded-small bg-surface-container-highest px-3 py-2">
|
|
269
|
+
{!!docId && (
|
|
270
|
+
<p className="font-code text-body-small text-on-surface-variant">{String(docId)}</p>
|
|
271
|
+
)}
|
|
272
|
+
{!!query && (
|
|
273
|
+
<p className="mt-0.5 text-body-small text-outline">filter: {String(query)}</p>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
},
|
|
278
|
+
resultView(pair) {
|
|
279
|
+
if (!pair.resultRaw) return null;
|
|
280
|
+
return <CollapsiblePre text={pair.resultRaw} maxLines={30} />;
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
todowrite: {
|
|
285
|
+
summary(pair) {
|
|
286
|
+
if (pair.title) return pair.title;
|
|
287
|
+
return "Update tasks";
|
|
288
|
+
},
|
|
289
|
+
resultView() {
|
|
290
|
+
return null;
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
task: {
|
|
295
|
+
summary(pair) {
|
|
296
|
+
if (pair.title) return pair.title;
|
|
297
|
+
const description = pair.args?.description;
|
|
298
|
+
return description ? truncate(String(description), 60) : null;
|
|
299
|
+
},
|
|
300
|
+
resultView(pair) {
|
|
301
|
+
if (!pair.resultRaw) return null;
|
|
302
|
+
return <CollapsiblePre text={pair.resultRaw} maxLines={20} />;
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const defaultRenderer: ToolRenderer = {
|
|
308
|
+
summary(pair) {
|
|
309
|
+
if (!pair.argsRaw || pair.argsRaw === "{}") return null;
|
|
310
|
+
return truncate(pair.argsRaw, 60);
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
function getRenderer(name: string): ToolRenderer {
|
|
315
|
+
return toolRenderers[name] ?? defaultRenderer;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Shared sub-components
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
function ResultLine({ text }: { text: string }) {
|
|
323
|
+
return <p className="px-3 py-2 text-body-small text-on-surface-variant">{text}</p>;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function CollapsiblePre({ text, maxLines = 20 }: { text: string; maxLines?: number }) {
|
|
327
|
+
const [expanded, setExpanded] = useState(false);
|
|
328
|
+
const lines = text.split("\n");
|
|
329
|
+
const needsCollapse = lines.length > maxLines;
|
|
330
|
+
const displayText =
|
|
331
|
+
needsCollapse && !expanded ? lines.slice(0, maxLines).join("\n") + "\n..." : text;
|
|
332
|
+
|
|
333
|
+
return (
|
|
334
|
+
<div>
|
|
335
|
+
<pre className="max-h-80 overflow-auto whitespace-pre-wrap px-3 py-2 font-code text-body-small text-on-surface-variant">
|
|
336
|
+
{displayText}
|
|
337
|
+
</pre>
|
|
338
|
+
{needsCollapse && (
|
|
339
|
+
<button
|
|
340
|
+
type="button"
|
|
341
|
+
onClick={() => setExpanded(!expanded)}
|
|
342
|
+
className={clsx(
|
|
343
|
+
"flex h-8 w-full items-center justify-center gap-1 border-t border-outline-variant px-3 text-label-medium text-primary",
|
|
344
|
+
"transition-colors duration-200 ease-standard hover:bg-primary/8 active:bg-primary/10",
|
|
345
|
+
focusRing,
|
|
346
|
+
)}
|
|
347
|
+
>
|
|
348
|
+
<Icon
|
|
349
|
+
name="expand_more"
|
|
350
|
+
size={18}
|
|
351
|
+
className={clsx(
|
|
352
|
+
"transition-transform duration-200 ease-standard",
|
|
353
|
+
expanded && "rotate-180",
|
|
354
|
+
)}
|
|
355
|
+
/>
|
|
356
|
+
{expanded ? "Show less" : `Show all ${lines.length} lines`}
|
|
357
|
+
</button>
|
|
358
|
+
)}
|
|
359
|
+
</div>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// Status display helpers
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
/** Material Symbols glyph per status (the running state shows a circular indicator). */
|
|
368
|
+
const STATUS_ICONS: Record<ToolCallStatus, string> = {
|
|
369
|
+
running: "pending",
|
|
370
|
+
completed: "check_circle",
|
|
371
|
+
error: "error",
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const STATUS_COLORS: Record<ToolCallStatus, string> = {
|
|
375
|
+
running: "text-primary",
|
|
376
|
+
completed: "text-success",
|
|
377
|
+
error: "text-error",
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const STATUS_LABELS: Record<ToolCallStatus, string> = {
|
|
381
|
+
running: "Running",
|
|
382
|
+
completed: "Completed",
|
|
383
|
+
error: "Error",
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
/** Leading Material Symbol per tool family. */
|
|
387
|
+
function toolIcon(name: string): string {
|
|
388
|
+
if (name.startsWith("file_") || name === "read" || name === "write" || name === "edit") {
|
|
389
|
+
return "description";
|
|
390
|
+
}
|
|
391
|
+
if (name === "glob" || name === "grep" || name === "web_search") return "search";
|
|
392
|
+
if (name === "webfetch") return "language";
|
|
393
|
+
if (name === "set_status") return "schedule";
|
|
394
|
+
return "build";
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function formatToolName(name: string): string {
|
|
398
|
+
const overrides: Record<string, string> = {
|
|
399
|
+
webfetch: "Web Fetch",
|
|
400
|
+
todowrite: "Todo",
|
|
401
|
+
read_skill: "Read Skill",
|
|
402
|
+
web_search: "Web Search",
|
|
403
|
+
spacebot_docs: "Docs",
|
|
404
|
+
};
|
|
405
|
+
if (overrides[name]) return overrides[name];
|
|
406
|
+
|
|
407
|
+
const stripped = name.replace(/^file_/, "");
|
|
408
|
+
|
|
409
|
+
return stripped
|
|
410
|
+
.split("_")
|
|
411
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
412
|
+
.join(" ");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function toolCategory(name: string): string | null {
|
|
416
|
+
if (name.startsWith("file_")) return "File";
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// Main component
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
export interface ToolCallProps {
|
|
425
|
+
pair: ToolCallPair;
|
|
426
|
+
/** Start expanded (uncontrolled). */
|
|
427
|
+
defaultExpanded?: boolean;
|
|
428
|
+
className?: string;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* A tool invocation as an M3 outlined card: status + tool name header (`text-label-large`),
|
|
433
|
+
* summary, and an expandable body with arguments and result blocks in `font-code`.
|
|
434
|
+
*/
|
|
435
|
+
export function ToolCall({ pair, defaultExpanded = false, className }: ToolCallProps) {
|
|
436
|
+
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
437
|
+
const bodyId = useId();
|
|
438
|
+
const renderer = getRenderer(pair.name);
|
|
439
|
+
const summary = renderer.summary(pair);
|
|
440
|
+
const category = toolCategory(pair.name);
|
|
441
|
+
const displayName = formatToolName(pair.name);
|
|
442
|
+
const isError = pair.status === "error";
|
|
443
|
+
|
|
444
|
+
const args = expanded ? renderArgs(pair, renderer) : null;
|
|
445
|
+
const result = expanded ? renderResult(pair, renderer) : null;
|
|
446
|
+
|
|
447
|
+
return (
|
|
448
|
+
<div
|
|
449
|
+
data-status={pair.status}
|
|
450
|
+
className={clsx(
|
|
451
|
+
"m3-tool-call min-w-0 overflow-hidden rounded-medium border bg-surface-container-low text-on-surface",
|
|
452
|
+
isError ? "border-error" : "border-outline-variant",
|
|
453
|
+
className,
|
|
454
|
+
)}
|
|
455
|
+
>
|
|
456
|
+
<button
|
|
457
|
+
type="button"
|
|
458
|
+
onClick={() => setExpanded(!expanded)}
|
|
459
|
+
aria-expanded={expanded}
|
|
460
|
+
aria-controls={bodyId}
|
|
461
|
+
className={clsx(
|
|
462
|
+
"flex min-h-10 w-full items-center gap-2 rounded-medium px-3 py-2 text-left",
|
|
463
|
+
"transition-colors duration-200 ease-standard hover:bg-on-surface/8 active:bg-on-surface/10",
|
|
464
|
+
focusRing,
|
|
465
|
+
)}
|
|
466
|
+
>
|
|
467
|
+
<span
|
|
468
|
+
className={clsx(
|
|
469
|
+
"flex size-5 shrink-0 items-center justify-center",
|
|
470
|
+
STATUS_COLORS[pair.status],
|
|
471
|
+
)}
|
|
472
|
+
>
|
|
473
|
+
{pair.status === "running" ? (
|
|
474
|
+
<CircularIndicator size={16} label="Running" />
|
|
475
|
+
) : (
|
|
476
|
+
<Icon
|
|
477
|
+
name={STATUS_ICONS[pair.status]}
|
|
478
|
+
size={18}
|
|
479
|
+
filled
|
|
480
|
+
label={STATUS_LABELS[pair.status]}
|
|
481
|
+
/>
|
|
482
|
+
)}
|
|
483
|
+
</span>
|
|
484
|
+
<Icon name={toolIcon(pair.name)} size={18} className="shrink-0 text-on-surface-variant" />
|
|
485
|
+
{category && <span className="text-label-medium text-outline">{category}</span>}
|
|
486
|
+
<span className="shrink-0 text-label-large text-on-surface">{displayName}</span>
|
|
487
|
+
{summary && !expanded ? (
|
|
488
|
+
<span className="min-w-0 flex-1 truncate text-body-small text-on-surface-variant">
|
|
489
|
+
{summary}
|
|
490
|
+
</span>
|
|
491
|
+
) : (
|
|
492
|
+
<span className="flex-1" />
|
|
493
|
+
)}
|
|
494
|
+
{isError && (
|
|
495
|
+
<StatusChip tone="error" className="h-5 px-1.5 text-label-small">
|
|
496
|
+
error
|
|
497
|
+
</StatusChip>
|
|
498
|
+
)}
|
|
499
|
+
<Icon
|
|
500
|
+
name="expand_more"
|
|
501
|
+
size={20}
|
|
502
|
+
className={clsx(
|
|
503
|
+
"shrink-0 text-on-surface-variant transition-transform duration-300 ease-emphasized",
|
|
504
|
+
expanded && "rotate-180",
|
|
505
|
+
)}
|
|
506
|
+
/>
|
|
507
|
+
</button>
|
|
508
|
+
|
|
509
|
+
<AnimatePresence initial={false}>
|
|
510
|
+
{expanded ? (
|
|
511
|
+
<motion.div id={bodyId} key="body" {...expandMotion} className="overflow-hidden">
|
|
512
|
+
<div className="flex flex-col gap-2 border-t border-outline-variant px-3 py-3">
|
|
513
|
+
{args}
|
|
514
|
+
{result != null && result !== false ? (
|
|
515
|
+
<div
|
|
516
|
+
className={clsx(
|
|
517
|
+
"m3-tool-result overflow-hidden rounded-small",
|
|
518
|
+
isError
|
|
519
|
+
? "bg-error-container text-on-error-container [&_.text-on-surface-variant]:text-on-error-container [&_.text-outline]:text-on-error-container/80"
|
|
520
|
+
: "bg-surface-container-highest text-on-surface-variant",
|
|
521
|
+
)}
|
|
522
|
+
>
|
|
523
|
+
{result}
|
|
524
|
+
</div>
|
|
525
|
+
) : null}
|
|
526
|
+
</div>
|
|
527
|
+
</motion.div>
|
|
528
|
+
) : null}
|
|
529
|
+
</AnimatePresence>
|
|
530
|
+
</div>
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function renderArgs(pair: ToolCallPair, renderer: ToolRenderer): React.ReactNode {
|
|
535
|
+
if (renderer.argsView) {
|
|
536
|
+
const custom = renderer.argsView(pair);
|
|
537
|
+
if (custom) return custom;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (pair.args && Object.keys(pair.args).length > 0) {
|
|
541
|
+
return (
|
|
542
|
+
<div className="rounded-small bg-surface-container-highest px-3 py-2">
|
|
543
|
+
<div className="flex flex-col gap-0.5">
|
|
544
|
+
{Object.entries(pair.args).map(([key, value]) => (
|
|
545
|
+
<p key={key} className="font-code text-body-small">
|
|
546
|
+
<span className="text-outline">{key}: </span>
|
|
547
|
+
<span className="text-on-surface-variant">{formatArgValue(key, value)}</span>
|
|
548
|
+
</p>
|
|
549
|
+
))}
|
|
550
|
+
</div>
|
|
551
|
+
</div>
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (pair.argsRaw && pair.argsRaw !== "{}" && pair.argsRaw.trim().length > 0) {
|
|
556
|
+
return (
|
|
557
|
+
<div className="rounded-small bg-surface-container-highest px-3 py-2">
|
|
558
|
+
<pre className="max-h-40 overflow-auto font-code text-body-small text-on-surface-variant">
|
|
559
|
+
{pair.argsRaw}
|
|
560
|
+
</pre>
|
|
561
|
+
</div>
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function renderResult(pair: ToolCallPair, renderer: ToolRenderer): React.ReactNode {
|
|
569
|
+
if (pair.status === "running") {
|
|
570
|
+
return (
|
|
571
|
+
<div className="flex items-center gap-2 px-3 py-2 text-body-small text-on-surface-variant">
|
|
572
|
+
<CircularIndicator size={14} label="Running" />
|
|
573
|
+
Running...
|
|
574
|
+
</div>
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (renderer.resultView) {
|
|
579
|
+
const custom = renderer.resultView(pair);
|
|
580
|
+
if (custom !== undefined) return custom;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (!pair.resultRaw) return null;
|
|
584
|
+
|
|
585
|
+
// Check if result contains an interactive MCP-UI resource or app
|
|
586
|
+
const mcpResourceUri = (pair.result?._meta as any)?.ui?.resourceUri || (pair.result?.ui as any)?.resourceUri;
|
|
587
|
+
const mcpHtml = (pair.result as any)?.html || (pair.result as any)?.uiHtml;
|
|
588
|
+
if (mcpResourceUri || mcpHtml) {
|
|
589
|
+
return (
|
|
590
|
+
<div className="p-1">
|
|
591
|
+
<McpAppCard
|
|
592
|
+
toolName={pair.name}
|
|
593
|
+
title={pair.title || pair.name}
|
|
594
|
+
html={typeof mcpHtml === "string" ? mcpHtml : undefined}
|
|
595
|
+
toolResourceUri={typeof mcpResourceUri === "string" ? mcpResourceUri : undefined}
|
|
596
|
+
toolInput={pair.args || undefined}
|
|
597
|
+
toolResult={pair.result || undefined}
|
|
598
|
+
/>
|
|
599
|
+
</div>
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (pair.result && Object.keys(pair.result).length > 0) {
|
|
604
|
+
return (
|
|
605
|
+
<div className="px-3 py-2">
|
|
606
|
+
<div className="flex flex-col gap-0.5">
|
|
607
|
+
{Object.entries(pair.result).map(([key, value]) => (
|
|
608
|
+
<p key={key} className="font-code text-body-small">
|
|
609
|
+
<span className="text-outline">{key}: </span>
|
|
610
|
+
<span className="text-on-surface-variant">
|
|
611
|
+
{typeof value === "string" ? truncate(value, 200) : JSON.stringify(value)}
|
|
612
|
+
</span>
|
|
613
|
+
</p>
|
|
614
|
+
))}
|
|
615
|
+
</div>
|
|
616
|
+
</div>
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return <CollapsiblePre text={pair.resultRaw} maxLines={20} />;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function formatArgValue(key: string, value: unknown): string {
|
|
624
|
+
if (key === "secret" && typeof value === "string") {
|
|
625
|
+
return "***";
|
|
626
|
+
}
|
|
627
|
+
if (typeof value === "string") {
|
|
628
|
+
return truncate(value, 100);
|
|
629
|
+
}
|
|
630
|
+
if (typeof value === "boolean" || typeof value === "number") {
|
|
631
|
+
return String(value);
|
|
632
|
+
}
|
|
633
|
+
return JSON.stringify(value);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function truncate(text: string, maxLen: number): string {
|
|
637
|
+
if (text.length <= maxLen) return text;
|
|
638
|
+
return text.slice(0, maxLen) + "...";
|
|
639
|
+
}
|