1import type { ToolLifecycleItemType } from "@t3tools/contracts";
2
3function asRecord(value: unknown): Record<string, unknown> | undefined {
4 return value !== null && typeof value === "object" && !Array.isArray(value)
5 ? (value as Record<string, unknown>)
6 : undefined;
7}
8
9function asTrimmedString(value: unknown): string | undefined {
10 if (typeof value !== "string") {
11 return undefined;
12 }
13 const trimmed = value.trim();
14 return trimmed.length > 0 ? trimmed : undefined;
15}
16
17/** A Claude `Skill` call: the skill it loads and the arguments it passes, if any. */
18export function claudeSkillInvocation(
19 toolName: string | null | undefined,
20 input: unknown,
21): { readonly name: string; readonly args: string | undefined } | undefined {
22 if (toolName !== "Skill") return undefined;
23 const record = asRecord(input);
24 const name = asTrimmedString(record?.skill);
25 return name === undefined ? undefined : { name, args: asTrimmedString(record?.args) };
26}
27
28export function isJavaScriptReplTool(toolName: string | null | undefined): boolean {
29 return toolName === "js" || toolName === "cua_repl.js" || toolName === "node_repl.js";
30}
31
32export const COMPUTER_USE_TOOL_SOURCE = {
33 key: "computer-use",
34 name: "Computer Use",
35 kind: "computer",
36} as const;
37
38/** Computer-use calls may fail or still be running before result metadata is available. */
39export function dynamicToolSurface(toolName: string | null | undefined, input: unknown) {
40 if (toolName === "cua_repl.js") return "computer" as const;
41 const code = asRecord(input)?.code;
42 return isJavaScriptReplTool(toolName) &&
43 typeof code === "string" &&
44 (/\bsky\s*\./u.test(code) || /["']@oai\/sky["']/u.test(code))
45 ? ("computer" as const)
46 : undefined;
47}
48
49/**
50 * Activity log heading a dynamic tool derives from its input: a REPL's `title`,
51 * or the skill a Claude `Skill` call loads.
52 */
53export function dynamicToolTitle(
54 toolName: string | null | undefined,
55 input: unknown,
56 retainedTitle?: string | null,
57): string | undefined {
58 if (isJavaScriptReplTool(toolName)) {
59 const title = asTrimmedString(retainedTitle);
60 const meaningfulTitle =
61 title && title.toLowerCase() !== "js" && title.toLowerCase() !== toolName?.toLowerCase()
62 ? title
63 : undefined;
64 return (
65 asTrimmedString(asRecord(input)?.title) ??
66 meaningfulTitle ??
67 (dynamicToolSurface(toolName, input) === "computer" ? "Computer use" : "Run JavaScript")
68 );
69 }
70 const skill = claudeSkillInvocation(toolName, input);
71 return skill === undefined ? undefined : `Skill: ${skill.name}`;
72}
73
74function recordHasKeys(
75 value: Record<string, unknown> | undefined,
76): value is Record<string, unknown> {
77 return value !== undefined && Object.keys(value).length > 0;
78}
79
80function normalizeCommandValue(value: unknown): string | undefined {
81 const direct = asTrimmedString(value);
82 if (direct) {
83 return direct;
84 }
85 if (!Array.isArray(value)) {
86 return undefined;
87 }
88 const parts: string[] = [];
89 for (const entry of value) {
90 const part = asTrimmedString(entry);
91 if (part !== undefined) {
92 parts.push(part);
93 }
94 }
95 return parts.length > 0 ? parts.join(" ") : undefined;
96}
97
98function stripTrailingExitCode(value: string | undefined): string | undefined {
99 const trimmed = value?.trim();
100 if (!trimmed) {
101 return undefined;
102 }
103 const match = /^(?<output>[\s\S]*?)(?:\s*<exited with exit code \d+>)\s*$/iu.exec(trimmed);
104 const output = match?.groups?.output?.trim() ?? trimmed;
105 return output.length > 0 ? output : undefined;
106}
107
108function extractCommandFromTitle(title: string | undefined): string | undefined {
109 if (!title) {
110 return undefined;
111 }
112 const backtickMatch = /`([^`]+)`/u.exec(title);
113 return backtickMatch?.[1]?.trim() || undefined;
114}
115
116function extractToolCommand(data: Record<string, unknown> | undefined, title: string | undefined) {
117 const item = asRecord(data?.item);
118 const itemInput = asRecord(item?.input);
119 const itemResult = asRecord(item?.result);
120 const rawInput = asRecord(data?.rawInput);
121 const candidates = [
122 normalizeCommandValue(item?.command),
123 normalizeCommandValue(itemInput?.command),
124 normalizeCommandValue(itemResult?.command),
125 normalizeCommandValue(data?.command),
126 normalizeCommandValue(rawInput?.command),
127 ];
128 const direct = candidates.find((candidate) => candidate !== undefined);
129 if (direct) {
130 return direct;
131 }
132 const executable = asTrimmedString(rawInput?.executable);
133 const args = normalizeCommandValue(rawInput?.args);
134 if (executable && args) {
135 return `${executable} ${args}`;
136 }
137 if (executable) {
138 return executable;
139 }
140 return extractCommandFromTitle(title);
141}
142
143const PATH_KEYS = [
144 "path",
145 "filePath",
146 "file_path",
147 "relativePath",
148 "filename",
149 "fileName",
150 "newPath",
151 "oldPath",
152] as const;
153
154function collectPaths(value: unknown, paths: string[], seen: Set<string>, depth: number): void {
155 if (depth > 4 || paths.length >= 8) {
156 return;
157 }
158 if (Array.isArray(value)) {
159 for (const entry of value) {
160 collectPaths(entry, paths, seen, depth + 1);
161 if (paths.length >= 8) {
162 return;
163 }
164 }
165 return;
166 }
167 const record = asRecord(value);
168 if (!record) {
169 return;
170 }
171 for (const key of PATH_KEYS) {
172 const candidate = asTrimmedString(record[key]);
173 if (!candidate || seen.has(candidate)) {
174 continue;
175 }
176 seen.add(candidate);
177 paths.push(candidate);
178 if (paths.length >= 8) {
179 return;
180 }
181 }
182 for (const nestedKey of ["locations", "item", "input", "result", "rawInput", "data", "changes"]) {
183 if (!(nestedKey in record)) {
184 continue;
185 }
186 collectPaths(record[nestedKey], paths, seen, depth + 1);
187 if (paths.length >= 8) {
188 return;
189 }
190 }
191}
192
193/** Structured paths from tool input, never file-body text. */
194export function collectToolFilePaths(data: unknown): string[] {
195 const paths: string[] = [];
196 collectPaths(data, paths, new Set<string>(), 0);
197 return paths;
198}
199
200function extractPrimaryPath(data: Record<string, unknown> | undefined): string | undefined {
201 return collectToolFilePaths(data)[0];
202}
203
204/**
205 * Later ACP updates often resend `rawInput: {}`. Keep the first object that
206 * actually carried keys so a parsed path is not wiped out.
207 */
208export function mergeToolActivityData(
209 previous: unknown,
210 next: unknown,
211): Record<string, unknown> | undefined {
212 const previousRecord = asRecord(previous);
213 const nextRecord = asRecord(next);
214 if (!nextRecord) {
215 return previousRecord;
216 }
217 if (!previousRecord) {
218 return nextRecord;
219 }
220 const previousInput = asRecord(previousRecord.rawInput);
221 const nextInput = asRecord(nextRecord.rawInput);
222 const rawInput = recordHasKeys(nextInput)
223 ? { ...previousInput, ...nextInput }
224 : (previousInput ?? nextInput);
225 const merged = { ...previousRecord, ...nextRecord };
226 if (recordHasKeys(rawInput)) {
227 merged.rawInput = rawInput;
228 } else {
229 delete merged.rawInput;
230 }
231 return merged;
232}
233
234function normalizeEquivalentValue(value: string | undefined): string | undefined {
235 const trimmed = asTrimmedString(value);
236 if (!trimmed) {
237 return undefined;
238 }
239 return trimmed
240 .replace(/\s+/gu, " ")
241 .replace(/\s+(?:complete|completed|started)\s*$/iu, "")
242 .trim();
243}
244
245function isEquivalent(left: string | undefined, right: string | undefined): boolean {
246 const normalizedLeft = normalizeEquivalentValue(left)?.toLowerCase();
247 const normalizedRight = normalizeEquivalentValue(right)?.toLowerCase();
248 return normalizedLeft !== undefined && normalizedLeft === normalizedRight;
249}
250
251export type ToolActivityAction = "command" | "read" | "file_change" | "search" | "other";
252
253function toolNameToken(value: string | undefined): string | undefined {
254 const trimmed = asTrimmedString(value);
255 // Server-prefixed MCP names (`github.read_file`, `mcp__db__find`) are not local reads or searches.
256 if (!trimmed || /__|[./]/u.test(trimmed)) {
257 return undefined;
258 }
259 return trimmed.replace(/[_\s-]/gu, "").toLowerCase();
260}
261
262export function classifyToolActivity(input: {
263 readonly itemType?: ToolLifecycleItemType | string | null | undefined;
264 readonly requestKind?: string | null | undefined;
265 readonly title?: string | undefined;
266 readonly data?: Record<string, unknown> | undefined;
267}): ToolActivityAction {
268 const itemType = input.itemType ?? undefined;
269 const requestKind = asTrimmedString(input.requestKind)?.toLowerCase();
270 const kind = asTrimmedString(input.data?.kind)?.toLowerCase();
271 const toolName = toolNameToken(
272 asTrimmedString(input.data?.toolName) ?? asTrimmedString(asRecord(input.data?.item)?.tool),
273 );
274
275 if (itemType === "command_execution") {
276 return "command";
277 }
278 if (itemType === "image_view") {
279 return "read";
280 }
281 if (itemType === "file_change") {
282 return "file_change";
283 }
284 if (itemType === "web_search") {
285 return "search";
286 }
287 if (requestKind === "command" || kind === "execute") {
288 return "command";
289 }
290 if (
291 requestKind === "file-change" ||
292 kind === "edit" ||
293 kind === "move" ||
294 kind === "delete" ||
295 kind === "write"
296 ) {
297 return "file_change";
298 }
299 if (
300 kind === "search" ||
301 toolName === "find" ||
302 toolName === "grep" ||
303 toolName === "glob" ||
304 toolName === "rg" ||
305 toolName === "ls"
306 ) {
307 return "search";
308 }
309 if (requestKind === "file-read" || kind === "read") {
310 return "read";
311 }
312 if (toolName === "terminal" || toolName === "bash" || toolName === "shell") {
313 return "command";
314 }
315 if (toolName === "read" || toolName === "readfile") {
316 return "read";
317 }
318 return "other";
319}
320
321const SEARCH_QUERY_KEYS = ["pattern", "query", "searchTerm", "regex", "grep", "needle"] as const;
322const SEARCH_GLOB_KEYS = [
323 "glob",
324 "globPattern",
325 "glob_pattern",
326 "include",
327 "filePattern",
328 "file_pattern",
329] as const;
330const SEARCH_TARGET_KEYS = [
331 "path",
332 "target_directory",
333 "targetDirectory",
334 "directory",
335 "cwd",
336 "root",
337] as const;
338
339function firstInputString(
340 record: Record<string, unknown> | undefined,
341 keys: readonly string[],
342): string | undefined {
343 if (!record) {
344 return undefined;
345 }
346 for (const key of keys) {
347 const value = asTrimmedString(record[key]);
348 if (value) {
349 return value;
350 }
351 }
352 return undefined;
353}
354
355function searchInputRecord(
356 data: Record<string, unknown> | undefined,
357): Record<string, unknown> | undefined {
358 return (
359 [data?.rawInput, data?.input, asRecord(data?.item)?.input]
360 .map(asRecord)
361 .find((record) => recordHasKeys(record ?? undefined)) ?? data
362 );
363}
364
365function searchTargetName(value: string | undefined): string | undefined {
366 if (!value) {
367 return undefined;
368 }
369 return value.split(/[\\/]/u).findLast((part) => part.length > 0 && part !== ".");
370}
371
372/** Cursor-style row: "Searched files *.{ts,tsx} in t3chat-new". */
373export function formatSearchToolLabel(
374 data: Record<string, unknown> | undefined,
375): string | undefined {
376 const input = searchInputRecord(data);
377 const query = firstInputString(input, SEARCH_QUERY_KEYS);
378 const glob = firstInputString(input, SEARCH_GLOB_KEYS);
379 const target = searchTargetName(firstInputString(input, SEARCH_TARGET_KEYS));
380 if (query && target) {
381 return `Searched ${query} in ${target}`;
382 }
383 if (glob && target) {
384 return `Searched files ${glob} in ${target}`;
385 }
386 if (glob) {
387 return `Searched files ${glob}`;
388 }
389 if (query) {
390 return `Searched ${query}`;
391 }
392 if (target) {
393 return `Searched in ${target}`;
394 }
395 return undefined;
396}
397
398/** Work-log heading for a file read: verb plus the structured path, never the path alone. */
399export function formatReadToolLabel(path: string, extraCount = 0): string {
400 const trimmed = path.trim();
401 const suffix = extraCount > 0 ? ` +${extraCount} more` : "";
402 if (!trimmed) {
403 return `Read file${suffix}`;
404 }
405 return `Read ${trimmed}${suffix}`;
406}
407
408export interface ToolActivityPresentationInput {
409 readonly itemType?: ToolLifecycleItemType | null | undefined;
410 readonly title?: string | null | undefined;
411 readonly detail?: string | null | undefined;
412 readonly data?: unknown;
413 readonly fallbackSummary?: string | null | undefined;
414}
415
416export interface ToolActivityPresentation {
417 readonly summary: string;
418 readonly detail?: string | undefined;
419}
420
421export function deriveToolActivityPresentation(
422 input: ToolActivityPresentationInput,
423): ToolActivityPresentation {
424 const title = asTrimmedString(input.title);
425 const detail = stripTrailingExitCode(asTrimmedString(input.detail));
426 const fallbackSummary = asTrimmedString(input.fallbackSummary) ?? "Tool";
427 const data = asRecord(input.data);
428 const command = extractToolCommand(data, title);
429 const primaryPath = extractPrimaryPath(data);
430 const action = classifyToolActivity({
431 itemType: input.itemType,
432 title,
433 data,
434 });
435
436 if (action === "command") {
437 return {
438 summary: "Ran command",
439 ...(command ? { detail: command } : {}),
440 };
441 }
442
443 if (action === "read") {
444 if (primaryPath) {
445 return {
446 summary: formatReadToolLabel(primaryPath),
447 };
448 }
449 return {
450 summary: "Read file",
451 };
452 }
453
454 if (action === "file_change") {
455 return {
456 summary: "Changed files",
457 ...(primaryPath ? { detail: primaryPath } : {}),
458 };
459 }
460
461 if (action === "search") {
462 const searchLabel = formatSearchToolLabel(data);
463 if (searchLabel) {
464 return { summary: searchLabel };
465 }
466 return {
467 summary: "Searched files",
468 };
469 }
470
471 if (detail && !isEquivalent(detail, title) && !isEquivalent(detail, fallbackSummary)) {
472 return {
473 summary: title ?? fallbackSummary,
474 detail,
475 };
476 }
477
478 return {
479 summary: title ?? fallbackSummary,
480 };
481}