diff --git a/scripts/aggregate-tool-names.ts b/scripts/aggregate-tool-names.ts new file mode 100644 index 0000000..c53e0ad --- /dev/null +++ b/scripts/aggregate-tool-names.ts @@ -0,0 +1,122 @@ +/** + * One-shot aggregation script: emits a frequency histogram of `toolName` + * values across the local Claude Code session corpus. + * + * Used to inform the priority list of tools that need dedicated rendering in + * `src/components/playback/ToolCallBlock.tsx` (issue #21). + * + * Usage: + * npx tsx scripts/aggregate-tool-names.ts [--sessions-dir ] [--limit ] + * + * Default sessions dir is `~/.claude/projects/`. + */ + +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +interface Args { + sessionsDir: string; + limit: number; +} + +function parseArgs(argv: string[]): Args { + const args: Args = { + sessionsDir: join(homedir(), ".claude", "projects"), + limit: 50, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--sessions-dir") { + args.sessionsDir = argv[++i]; + } else if (a === "--limit") { + args.limit = parseInt(argv[++i], 10) || args.limit; + } + } + return args; +} + +function listJsonlFilesRecursive(root: string): string[] { + const out: string[] = []; + let entries: string[]; + try { + entries = readdirSync(root); + } catch { + return out; + } + for (const name of entries) { + const p = join(root, name); + let s; + try { + s = statSync(p); + } catch { + continue; + } + if (s.isDirectory()) { + out.push(...listJsonlFilesRecursive(p)); + } else if (s.isFile() && p.endsWith(".jsonl")) { + out.push(p); + } + } + return out; +} + +function aggregate(files: string[]): Map { + const counts = new Map(); + for (const f of files) { + let raw: string; + try { + raw = readFileSync(f, "utf-8"); + } catch { + continue; + } + for (const line of raw.split("\n")) { + if (!line) continue; + let json: unknown; + try { + json = JSON.parse(line); + } catch { + continue; + } + const message = (json as { message?: unknown })?.message; + const content = (message as { content?: unknown })?.content; + if (!Array.isArray(content)) continue; + for (const block of content) { + if ( + block && + typeof block === "object" && + (block as { type?: unknown }).type === "tool_use" + ) { + const name = (block as { name?: unknown }).name; + if (typeof name === "string") { + counts.set(name, (counts.get(name) ?? 0) + 1); + } + } + } + } + } + return counts; +} + +function main() { + const { sessionsDir, limit } = parseArgs(process.argv.slice(2)); + const files = listJsonlFilesRecursive(sessionsDir); + if (files.length === 0) { + console.error(`No .jsonl files found under ${sessionsDir}`); + process.exit(1); + } + const counts = aggregate(files); + const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]); + const total = sorted.reduce((sum, [, c]) => sum + c, 0); + + console.log(`# Tool name histogram`); + console.log(`# scanned ${files.length} jsonl files in ${sessionsDir}`); + console.log(`# ${total} tool_use blocks total\n`); + console.log(`rank\tcount\tshare\ttool`); + sorted.slice(0, limit).forEach(([name, count], i) => { + const share = ((count / total) * 100).toFixed(2); + console.log(`${i + 1}\t${count}\t${share}%\t${name}`); + }); +} + +main(); diff --git a/src/components/playback/ToolCallBlock.css b/src/components/playback/ToolCallBlock.css index 9f7ad6a..cd48f84 100644 --- a/src/components/playback/ToolCallBlock.css +++ b/src/components/playback/ToolCallBlock.css @@ -55,6 +55,41 @@ color: #78716c; } +.tool-name-badge--task { + background: #fef3c7; + color: #b45309; +} + +.tool-name-badge--question { + background: #ede9fe; + color: #6d28d9; +} + +.tool-name-badge--skill { + background: #ecfeff; + color: #0e7490; +} + +.tool-name-badge--web { + background: #ecfdf5; + color: #047857; +} + +.tool-name-badge--plan { + background: #fef9c3; + color: #854d0e; +} + +.tool-name-badge--notebook { + background: #fdf2f8; + color: #9d174d; +} + +.tool-name-badge--mcp { + background: #eef2ff; + color: #4338ca; +} + /* Input section */ .tool-input { padding: 8px 10px; @@ -187,3 +222,262 @@ .tool-result--error .tool-result-content { color: var(--danger); } + +/* ─── Task tracking (TaskCreate / TaskUpdate) ──────────────────────────── */ +.tool-task-subject { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + +.tool-task-activeform { + font-size: 12px; + color: var(--text-secondary); + margin-top: 4px; +} + +.tool-task-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-bottom: 4px; +} + +.tool-task-tag { + display: inline-block; + font-size: 11px; + padding: 1px 6px; + background-color: var(--bg-tertiary); + color: var(--text-secondary); + border-radius: 3px; +} + +.tool-task-id { + font-family: monospace; + font-size: 12px; + color: var(--text-secondary); +} + +.tool-task-status { + display: inline-block; + font-size: 11px; + font-weight: 600; + padding: 1px 6px; + border-radius: 3px; + text-transform: uppercase; + background-color: var(--bg-tertiary); + color: var(--text-secondary); +} + +.tool-task-status--pending { + background: #f5f5f4; + color: #78716c; +} + +.tool-task-status--in_progress { + background: #fef3c7; + color: #b45309; +} + +.tool-task-status--completed { + background: #dcfce7; + color: #166534; +} + +.tool-task-status--failed, +.tool-task-status--cancelled { + background: #fee2e2; + color: #b91c1c; +} + +/* ─── AskUserQuestion ──────────────────────────────────────────────────── */ +.tool-question { + margin-bottom: 8px; +} + +.tool-question:last-child { + margin-bottom: 0; +} + +.tool-question-header { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-secondary); + margin-bottom: 4px; +} + +.tool-question-text { + font-size: 13px; + color: var(--text-primary); + margin-bottom: 6px; +} + +.tool-question-options { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.tool-question-option { + font-size: 12px; + color: var(--text-secondary); + padding: 2px 0; +} + +.tool-question-option-label { + font-weight: 600; + color: var(--text-primary); +} + +.tool-question-option-desc { + color: var(--text-secondary); +} + +.tool-question-option--more { + color: var(--text-tertiary); + font-style: italic; +} + +/* ─── Skill ────────────────────────────────────────────────────────────── */ +.tool-skill-name { + font-family: monospace; + font-size: 13px; + color: var(--accent); + margin-bottom: 4px; +} + +/* ─── Web (WebFetch / WebSearch) ───────────────────────────────────────── */ +.tool-url { + font-family: monospace; + font-size: 12px; + color: var(--accent); + margin-bottom: 6px; + overflow-wrap: anywhere; +} + +.tool-query { + font-size: 13px; + color: var(--text-primary); + margin-bottom: 4px; +} + +.tool-web-domains { + font-size: 11px; + color: var(--text-secondary); + font-family: monospace; + margin-top: 2px; +} + +.tool-web-domain-label { + color: var(--text-tertiary); +} + +/* ─── Notebook ─────────────────────────────────────────────────────────── */ +.tool-notebook-tag { + display: inline-block; + font-size: 11px; + padding: 1px 6px; + border-radius: 3px; + background: #fdf2f8; + color: #9d174d; +} + +.tool-notebook-tag--type { + background: var(--bg-tertiary); + color: var(--text-secondary); +} + +/* ─── Plan mode ────────────────────────────────────────────────────────── */ +.tool-plan-block { + max-height: 200px; +} + +.tool-plan-empty { + font-size: 12px; + color: var(--text-secondary); + font-style: italic; +} + +.tool-plan-allowed { + margin-top: 6px; + font-size: 12px; + color: var(--text-secondary); +} + +.tool-plan-allowed-label { + font-weight: 600; + color: var(--text-primary); + margin-right: 4px; +} + +.tool-plan-allowed-list { + list-style: none; + margin: 4px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +/* ─── MCP ──────────────────────────────────────────────────────────────── */ +.tool-mcp-route { + display: inline-flex; + align-items: center; + gap: 4px; + font-family: monospace; + font-size: 12px; + margin-bottom: 6px; +} + +.tool-mcp-server { + color: var(--text-primary); + font-weight: 600; +} + +.tool-mcp-sep { + color: var(--text-tertiary); +} + +.tool-mcp-method { + color: var(--accent); +} + +/* ─── Generic key/value list (fallback) ─────────────────────────────────── */ +.tool-kv-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.tool-kv { + font-size: 12px; + display: flex; + gap: 8px; + align-items: baseline; +} + +.tool-kv-key { + font-family: monospace; + color: var(--text-secondary); + flex-shrink: 0; +} + +.tool-kv-value { + color: var(--text-primary); + font-family: monospace; + word-break: break-word; +} + +.tool-subtitle--muted { + color: var(--text-tertiary); + font-style: italic; +} diff --git a/src/components/playback/ToolCallBlock.tsx b/src/components/playback/ToolCallBlock.tsx index b92619c..df43311 100644 --- a/src/components/playback/ToolCallBlock.tsx +++ b/src/components/playback/ToolCallBlock.tsx @@ -1,6 +1,29 @@ import { useState } from 'react' import type { ToolCall, SubagentSession } from '../../types' import { SubagentBranch } from './SubagentBranch' +import { getToolCategory, truncate } from './toolCallHelpers' +import { + AgentRenderer, + AskUserQuestionRenderer, + BashRenderer, + EditRenderer, + EnterPlanModeRenderer, + ExitPlanModeRenderer, + GenericInputRenderer, + GrepGlobRenderer, + McpRenderer, + MonitorRenderer, + NotebookEditRenderer, + ReadRenderer, + ScheduleWakeupRenderer, + SkillRenderer, + TaskCreateRenderer, + TaskUpdateRenderer, + ToolSearchRenderer, + WebFetchRenderer, + WebSearchRenderer, + WriteRenderer, +} from './ToolCallRenderers' import './ToolCallBlock.css' interface Props { @@ -8,32 +31,6 @@ interface Props { subagents: Record } -type ToolCategory = 'shell' | 'edit' | 'read' | 'search' | 'agent' | 'other' - -function getToolCategory(name: string): ToolCategory { - switch (name) { - case 'Bash': - return 'shell' - case 'Edit': - case 'Write': - return 'edit' - case 'Read': - return 'read' - case 'Grep': - case 'Glob': - return 'search' - case 'Agent': - return 'agent' - default: - return 'other' - } -} - -function truncate(str: string, maxLen: number): string { - if (str.length <= maxLen) return str - return str.slice(0, maxLen) + '...' -} - export function ToolCallBlock({ toolCall, subagents }: Props) { const name = toolCall.toolName ?? '' const input = toolCall.input ?? {} @@ -42,113 +39,10 @@ export function ToolCallBlock({ toolCall, subagents }: Props) { const isLongResult = typeof result === 'string' && result.length > 500 const [resultOpen, setResultOpen] = useState(!isLongResult) - // Find matching subagent const subagentId = toolCall.subagentId ?? null const matchingSubagent = subagentId ? subagents[subagentId] : null - const renderInput = () => { - switch (name) { - case 'Bash': - return ( -
- {input.description != null && ( -
{String(input.description)}
- )} - {input.command != null && ( -
{String(input.command)}
- )} -
- ) - - case 'Edit': - return ( -
- {input.file_path != null && ( -
{String(input.file_path)}
- )} - {input.old_string != null && ( -
{String(input.old_string)}
- )} - {input.new_string != null && ( -
{String(input.new_string)}
- )} -
- ) - - case 'Write': - return ( -
- {input.file_path != null && ( -
{String(input.file_path)}
- )} - {input.content != null && ( -
-                {truncate(String(input.content), 300)}
-              
- )} - {input.contentLength != null && ( -
- 合計 {String(input.contentLength)} 文字 -
- )} -
- ) - - case 'Read': - return ( -
- {input.file_path != null && ( -
{String(input.file_path)}
- )} -
- ) - - case 'Grep': - case 'Glob': - return ( -
- {input.pattern != null && ( - {String(input.pattern)} - )} - {typeof input.path === 'string' && input.path && ( - in {input.path} - )} -
- ) - - case 'Agent': - return ( -
- {(input.prompt != null || input.description != null) && ( -
- {String(input.prompt ?? input.description)} -
- )} - {input.subagent_type != null && ( - {String(input.subagent_type)} - )} -
- ) - - default: { - const displayKeys = Object.keys(input).filter( - k => input[k] != null && typeof input[k] !== 'object' - ) - if (displayKeys.length === 0) return null - const display: Record = {} - for (const k of displayKeys.slice(0, 5)) { - display[k] = input[k] - } - return ( -
-
-              {JSON.stringify(display, null, 2)}
-            
-
- ) - } - } - } + const inputView = renderToolInput(name, input) const renderResult = () => { if (!result) return null @@ -177,9 +71,9 @@ export function ToolCallBlock({ toolCall, subagents }: Props) { {name} - {renderInput()} + {inputView} {renderResult()} - {name === 'Agent' && matchingSubagent && ( + {(name === 'Agent' || name === 'Task') && matchingSubagent && ( ) } + +function renderToolInput(name: string, input: Record) { + switch (name) { + case 'Bash': + return + case 'Edit': + case 'MultiEdit': + return + case 'Write': + return + case 'Read': + return + case 'Grep': + case 'Glob': + return + case 'Agent': + case 'Task': + return + + // Task tracking + case 'TaskCreate': + return + case 'TaskUpdate': + return + + // Questions / skills + case 'AskUserQuestion': + return + case 'Skill': + return + case 'ToolSearch': + return + + // Web + case 'WebFetch': + return + case 'WebSearch': + return + + // Notebook + case 'NotebookEdit': + return + + // Plan mode / worktree toggles + case 'EnterPlanMode': + case 'EnterWorktree': + return + case 'ExitPlanMode': + case 'ExitWorktree': + return + + // Monitor / ScheduleWakeup + case 'Monitor': + return + case 'ScheduleWakeup': + return + + default: + if (name.startsWith('mcp__')) { + return + } + return + } +} diff --git a/src/components/playback/ToolCallRenderers.tsx b/src/components/playback/ToolCallRenderers.tsx new file mode 100644 index 0000000..bf020c1 --- /dev/null +++ b/src/components/playback/ToolCallRenderers.tsx @@ -0,0 +1,511 @@ +/** + * Per-tool input renderers used by ToolCallBlock. + * + * Each renderer receives the parsed `input` object for its tool and returns a + * compact JSX summary. The renderers are intentionally small and focused so + * the main ToolCallBlock component stays readable. + */ + +import { + parseMcpToolName, + pickKeyInputArgs, + summarizeValue, + truncate, +} from './toolCallHelpers' + +type Input = Record + +/** + * Coerce a value to a string for display, but only for scalars. Objects and + * arrays return null so callers can fall back to a structured renderer instead + * of rendering "[object Object]" or "1,2,3" garbage. + */ +function asString(value: unknown): string | null { + if (value == null) return null + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + return null +} + +// ─── Existing tools ───────────────────────────────────────────────────────── + +export function BashRenderer({ input }: { input: Input }) { + return ( +
+ {input.description != null && ( +
{String(input.description)}
+ )} + {input.command != null && ( +
{String(input.command)}
+ )} +
+ ) +} + +export function EditRenderer({ input }: { input: Input }) { + return ( +
+ {input.file_path != null && ( +
{String(input.file_path)}
+ )} + {input.old_string != null && ( +
{String(input.old_string)}
+ )} + {input.new_string != null && ( +
{String(input.new_string)}
+ )} +
+ ) +} + +export function WriteRenderer({ input }: { input: Input }) { + return ( +
+ {input.file_path != null && ( +
{String(input.file_path)}
+ )} + {input.content != null && ( +
{truncate(String(input.content), 300)}
+ )} + {input.contentLength != null && ( +
+ 合計 {String(input.contentLength)} 文字 +
+ )} +
+ ) +} + +export function ReadRenderer({ input }: { input: Input }) { + return ( +
+ {input.file_path != null && ( +
{String(input.file_path)}
+ )} +
+ ) +} + +export function GrepGlobRenderer({ input }: { input: Input }) { + return ( +
+ {input.pattern != null && ( + {String(input.pattern)} + )} + {typeof input.path === 'string' && input.path && ( + in {input.path} + )} +
+ ) +} + +export function AgentRenderer({ input }: { input: Input }) { + return ( +
+ {(input.prompt != null || input.description != null) && ( +
+ {String(input.prompt ?? input.description)} +
+ )} + {input.subagent_type != null && ( + {String(input.subagent_type)} + )} +
+ ) +} + +// ─── Task tracking (TaskCreate / TaskUpdate / TaskList / etc.) ────────────── + +export function TaskCreateRenderer({ input }: { input: Input }) { + const subject = asString(input.subject) + const description = asString(input.description) + const activeForm = asString(input.activeForm) + return ( +
+ {subject &&
{subject}
} + {description &&
{truncate(description, 280)}
} + {activeForm && ( +
+ 進行中 {activeForm} +
+ )} +
+ ) +} + +// Allow only characters that are safe inside a CSS class name modifier. +// Anything else collapses to "unknown" so we never inject extra classes +// from upstream-controlled status strings. +function sanitizeStatusModifier(status: string): string { + return /^[a-zA-Z0-9_-]+$/.test(status) ? status : 'unknown' +} + +export function TaskUpdateRenderer({ input }: { input: Input }) { + const taskId = asString(input.taskId) + const status = asString(input.status) + const note = asString(input.note ?? input.description) + return ( +
+
+ {taskId && #{taskId}} + {status && ( + + {status} + + )} +
+ {note &&
{truncate(note, 280)}
} +
+ ) +} + +// ─── AskUserQuestion ──────────────────────────────────────────────────────── + +interface AskQuestion { + question?: string + header?: string + options?: { label?: string; description?: string }[] + multiSelect?: boolean +} + +export function AskUserQuestionRenderer({ input }: { input: Input }) { + const rawQuestions = Array.isArray(input.questions) ? input.questions : [] + const questions: AskQuestion[] = rawQuestions.filter( + (q): q is AskQuestion => typeof q === 'object' && q !== null, + ) + + if (questions.length === 0) { + // Fallback: maybe a single question payload + const single = asString(input.question) + if (!single) return null + return ( +
+
{truncate(single, 300)}
+
+ ) + } + + return ( +
+ {questions.map((q, i) => { + const header = typeof q.header === 'string' ? q.header : null + const question = typeof q.question === 'string' ? q.question : null + return ( +
+ {header &&
{header}
} + {question && ( +
{truncate(question, 280)}
+ )} + {Array.isArray(q.options) && q.options.length > 0 && (() => { + const safeOptions = q.options.filter( + (opt): opt is { label?: string; description?: string } => + typeof opt === 'object' && opt !== null, + ) + if (safeOptions.length === 0) return null + return ( +
    + {safeOptions.slice(0, 6).map((opt, j) => { + const label = + typeof opt.label === 'string' && opt.label.length > 0 + ? opt.label + : `選択肢 ${j + 1}` + const description = + typeof opt.description === 'string' ? opt.description : null + return ( +
  • + {label} + {description && ( + + {' — '}{truncate(description, 160)} + + )} +
  • + ) + })} + {safeOptions.length > 6 && ( +
  • + …他 {safeOptions.length - 6} 件 +
  • + )} +
+ ) + })()} +
+ ) + })} +
+ ) +} + +// ─── Skill ────────────────────────────────────────────────────────────────── + +export function SkillRenderer({ input }: { input: Input }) { + const skill = asString(input.skill ?? input.name) + const args = asString(input.args ?? input.arguments) + return ( +
+ {skill &&
/{skill}
} + {args &&
{truncate(args, 240)}
} +
+ ) +} + +// ─── Web ──────────────────────────────────────────────────────────────────── + +export function WebFetchRenderer({ input }: { input: Input }) { + const url = asString(input.url) + const prompt = asString(input.prompt) + return ( +
+ {url && ( +
+ {truncate(url, 200)} +
+ )} + {prompt &&
{truncate(prompt, 280)}
} +
+ ) +} + +function toStringDomains(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((d): d is string => typeof d === 'string' && d.length > 0) +} + +export function WebSearchRenderer({ input }: { input: Input }) { + const query = asString(input.query) + const allowed = toStringDomains(input.allowed_domains) + const blocked = toStringDomains(input.blocked_domains) + return ( +
+ {query &&
{truncate(query, 240)}
} + {allowed.length > 0 && ( +
+ allow:{' '} + {allowed.slice(0, 5).join(', ')} + {allowed.length > 5 && ` …+${allowed.length - 5}`} +
+ )} + {blocked.length > 0 && ( +
+ block:{' '} + {blocked.slice(0, 5).join(', ')} + {blocked.length > 5 && ` …+${blocked.length - 5}`} +
+ )} +
+ ) +} + +// ─── NotebookEdit ─────────────────────────────────────────────────────────── + +export function NotebookEditRenderer({ input }: { input: Input }) { + const notebookPath = asString(input.notebook_path ?? input.file_path) + const cellId = asString(input.cell_id) + const cellType = asString(input.cell_type) + const editMode = asString(input.edit_mode) + const newSource = asString(input.new_source) + return ( +
+ {notebookPath && ( +
{notebookPath}
+ )} +
+ {editMode && ( + {editMode} + )} + {cellType && ( + {cellType} + )} + {cellId && ( + cell:{truncate(cellId, 24)} + )} +
+ {newSource && ( +
{truncate(newSource, 300)}
+ )} +
+ ) +} + +// ─── Plan mode / worktree toggles ─────────────────────────────────────────── + +interface AllowedPrompt { + tool?: string + prompt?: string +} + +export function ExitPlanModeRenderer({ input }: { input: Input }) { + const plan = asString(input.plan) + const allowed = Array.isArray(input.allowedPrompts) + ? (input.allowedPrompts.filter( + (p): p is AllowedPrompt => typeof p === 'object' && p !== null, + ) as AllowedPrompt[]) + : [] + return ( +
+ {plan && ( +
+          {truncate(plan, 600)}
+        
+ )} + {allowed.length > 0 && ( +
+ 許可 ({allowed.length}) +
    + {allowed.slice(0, 5).map((p, i) => { + const tool = typeof p.tool === 'string' ? p.tool : null + const prompt = typeof p.prompt === 'string' ? p.prompt : null + return ( +
  • + {tool && {tool}}{' '} + {prompt && {truncate(prompt, 120)}} +
  • + ) + })} + {allowed.length > 5 &&
  • …他 {allowed.length - 5} 件
  • } +
+
+ )} +
+ ) +} + +export function EnterPlanModeRenderer({ input }: { input: Input }) { + // EnterPlanMode usually has empty input. Show any reason if present. + const reason = asString(input.reason ?? input.message) + if (!reason) { + return ( +
プラン作成モードに入りました
+ ) + } + return ( +
+
{truncate(reason, 240)}
+
+ ) +} + +// ─── Monitor / ScheduleWakeup ─────────────────────────────────────────────── + +export function MonitorRenderer({ input }: { input: Input }) { + // Monitor typically takes a command-like spec; fall back to scalar args. + // Avoid stringifying nested objects to "[object Object]"; let the generic + // renderer handle structured payloads instead. + const command = asString(input.command ?? input.spec) + const description = asString(input.description ?? input.until) + if (!command && !description) { + return + } + return ( +
+ {description &&
{description}
} + {command &&
{truncate(command, 300)}
} +
+ ) +} + +export function ScheduleWakeupRenderer({ input }: { input: Input }) { + const at = asString(input.at ?? input.when ?? input.timestamp) + const after = asString(input.after ?? input.delay) + const reason = asString(input.reason ?? input.message ?? input.note) + if (!at && !after && !reason) { + return + } + return ( +
+ {(at || after) && ( +
+ {at && at: {at}} + {after && after: {after}} +
+ )} + {reason &&
{truncate(reason, 240)}
} +
+ ) +} + +// ─── ToolSearch ───────────────────────────────────────────────────────────── + +export function ToolSearchRenderer({ input }: { input: Input }) { + const query = asString(input.query) + const max = input.max_results + return ( +
+ {query &&
{truncate(query, 240)}
} + {max != null && ( + max: {String(max)} + )} +
+ ) +} + +// ─── Generic MCP formatter ────────────────────────────────────────────────── + +export function McpRenderer({ + toolName, + input, +}: { + toolName: string + input: Input +}) { + const parsed = parseMcpToolName(toolName) + const keyArgs = pickKeyInputArgs(input, 2) + return ( +
+ {parsed && ( +
+ {parsed.server} + {parsed.method && ( + <> + · + {parsed.method} + + )} +
+ )} + {keyArgs.length > 0 && ( +
    + {keyArgs.map(arg => ( +
  • + {arg.key} + {arg.display} +
  • + ))} +
+ )} +
+ ) +} + +// ─── Generic / unknown tool fallback ──────────────────────────────────────── + +export function GenericInputRenderer({ input }: { input: Input }) { + const keyArgs = pickKeyInputArgs(input, 2) + if (keyArgs.length === 0) { + // Final fallback: at least show how many keys are present. + const total = Object.keys(input ?? {}).length + if (total === 0) return null + return ( +
+
+ {summarizeValue(input)} +
+
+ ) + } + return ( +
+
    + {keyArgs.map(arg => ( +
  • + {arg.key} + {arg.display} +
  • + ))} +
+
+ ) +} diff --git a/src/components/playback/__tests__/toolCallHelpers.test.ts b/src/components/playback/__tests__/toolCallHelpers.test.ts new file mode 100644 index 0000000..5ed3429 --- /dev/null +++ b/src/components/playback/__tests__/toolCallHelpers.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from 'vitest' +import { + getToolCategory, + parseMcpToolName, + pickKeyInputArgs, + summarizeValue, + truncate, +} from '../toolCallHelpers' + +describe('getToolCategory', () => { + it('classifies the existing 7 tool surface', () => { + expect(getToolCategory('Bash')).toBe('shell') + expect(getToolCategory('Edit')).toBe('edit') + expect(getToolCategory('Write')).toBe('edit') + expect(getToolCategory('Read')).toBe('read') + expect(getToolCategory('Grep')).toBe('search') + expect(getToolCategory('Glob')).toBe('search') + expect(getToolCategory('Agent')).toBe('agent') + }) + + it('classifies the new high-priority tools', () => { + expect(getToolCategory('TaskCreate')).toBe('task') + expect(getToolCategory('TaskUpdate')).toBe('task') + expect(getToolCategory('AskUserQuestion')).toBe('question') + expect(getToolCategory('Skill')).toBe('skill') + expect(getToolCategory('WebFetch')).toBe('web') + expect(getToolCategory('WebSearch')).toBe('web') + expect(getToolCategory('NotebookEdit')).toBe('notebook') + }) + + it('classifies plan-mode toggles', () => { + expect(getToolCategory('EnterPlanMode')).toBe('plan') + expect(getToolCategory('ExitPlanMode')).toBe('plan') + expect(getToolCategory('EnterWorktree')).toBe('plan') + expect(getToolCategory('ExitWorktree')).toBe('plan') + }) + + it('classifies any mcp__* tool as mcp regardless of server', () => { + expect(getToolCategory('mcp__XcodeBuildMCP__list_sims')).toBe('mcp') + expect(getToolCategory('mcp__mobile-mcp__mobile_take_screenshot')).toBe('mcp') + }) + + it('falls back to other for unknown tool names', () => { + expect(getToolCategory('SomethingNew')).toBe('other') + expect(getToolCategory('')).toBe('other') + }) +}) + +describe('parseMcpToolName', () => { + it('returns null for non-mcp tools', () => { + expect(parseMcpToolName('Bash')).toBeNull() + expect(parseMcpToolName('TaskCreate')).toBeNull() + }) + + it('splits server and method on the first __', () => { + expect(parseMcpToolName('mcp__XcodeBuildMCP__list_sims')).toEqual({ + server: 'XcodeBuildMCP', + method: 'list_sims', + }) + }) + + it('handles servers with hyphens and methods with underscores', () => { + expect(parseMcpToolName('mcp__mobile-mcp__mobile_take_screenshot')).toEqual({ + server: 'mobile-mcp', + method: 'mobile_take_screenshot', + }) + }) + + it('handles names without a method segment', () => { + expect(parseMcpToolName('mcp__solo')).toEqual({ + server: 'solo', + method: '', + }) + }) +}) + +describe('truncate', () => { + it('returns the input unchanged when within the limit', () => { + expect(truncate('hello', 10)).toBe('hello') + }) + + it('appends an ellipsis when truncating', () => { + expect(truncate('abcdefghij', 5)).toBe('abcde...') + }) +}) + +describe('summarizeValue', () => { + it('summarizes scalars verbatim', () => { + expect(summarizeValue('hi')).toBe('hi') + expect(summarizeValue(42)).toBe('42') + expect(summarizeValue(true)).toBe('true') + }) + + it('renders arrays and objects compactly', () => { + expect(summarizeValue([1, 2, 3])).toBe('[3 items]') + expect(summarizeValue([])).toBe('[0 items]') + expect(summarizeValue([1])).toBe('[1 item]') + expect(summarizeValue({ a: 1, b: 2 })).toBe('{2 keys}') + expect(summarizeValue({ a: 1 })).toBe('{1 key}') + }) + + it('truncates long strings', () => { + expect(summarizeValue('a'.repeat(200), 10)).toBe('aaaaaaaaaa...') + }) + + it('returns empty string for null/undefined', () => { + expect(summarizeValue(null)).toBe('') + expect(summarizeValue(undefined)).toBe('') + }) +}) + +describe('pickKeyInputArgs', () => { + it('prefers high-signal keys in priority order', () => { + const args = pickKeyInputArgs({ + verbose: true, + command: 'npm run build', + url: 'https://example.com', + misc: 'whatever', + }) + // url > command in priority list + expect(args.map(a => a.key)).toEqual(['url', 'command']) + }) + + it('falls back to scalar fields when no priority keys match', () => { + const args = pickKeyInputArgs({ + foo: 'one', + bar: 'two', + baz: { nested: true }, + }) + expect(args.map(a => a.key)).toEqual(['foo', 'bar']) + }) + + it('skips null and complex values when filling from non-priority keys', () => { + const args = pickKeyInputArgs({ + maybe: null, + nested: { x: 1 }, + list: [1, 2], + hello: 'world', + }) + expect(args.map(a => a.key)).toEqual(['hello']) + }) + + it('respects the limit', () => { + const args = pickKeyInputArgs( + { url: 'u', file_path: '/a', command: 'c', query: 'q' }, + 2, + ) + expect(args).toHaveLength(2) + }) + + it('returns empty list for empty input', () => { + expect(pickKeyInputArgs({})).toEqual([]) + }) +}) diff --git a/src/components/playback/toolCallHelpers.ts b/src/components/playback/toolCallHelpers.ts new file mode 100644 index 0000000..f300d7f --- /dev/null +++ b/src/components/playback/toolCallHelpers.ts @@ -0,0 +1,182 @@ +/** + * Pure helpers for ToolCallBlock rendering. + * + * These functions are split out from the React component so they can be tested + * without a DOM/JSX runtime. + */ + +export type ToolCategory = + | 'shell' + | 'edit' + | 'read' + | 'search' + | 'agent' + | 'task' + | 'question' + | 'skill' + | 'web' + | 'plan' + | 'notebook' + | 'mcp' + | 'other' + +const CATEGORY_BY_TOOL: Record = { + // shell / file edits / reads / search / agent (existing) + Bash: 'shell', + Edit: 'edit', + MultiEdit: 'edit', + Write: 'edit', + Read: 'read', + Grep: 'search', + Glob: 'search', + Agent: 'agent', + Task: 'agent', + // task tracking + TaskCreate: 'task', + TaskUpdate: 'task', + TaskList: 'task', + TaskGet: 'task', + TaskOutput: 'task', + TaskStop: 'task', + TodoWrite: 'task', + // questions / skills / search + AskUserQuestion: 'question', + Skill: 'skill', + ToolSearch: 'search', + // web + WebFetch: 'web', + WebSearch: 'web', + // plan mode + EnterPlanMode: 'plan', + ExitPlanMode: 'plan', + EnterWorktree: 'plan', + ExitWorktree: 'plan', + // notebook + NotebookEdit: 'notebook', + // misc + ScheduleWakeup: 'other', + Monitor: 'other', + PushNotification: 'other', + RemoteTrigger: 'other', + LSP: 'other', +} + +export function getToolCategory(name: string): ToolCategory { + if (CATEGORY_BY_TOOL[name]) return CATEGORY_BY_TOOL[name] + if (name.startsWith('mcp__')) return 'mcp' + return 'other' +} + +/** + * Parse an MCP tool name into server / method parts. + * Format: `mcp____` (server may itself contain underscores). + */ +export interface McpToolParts { + server: string + method: string +} + +export function parseMcpToolName(name: string): McpToolParts | null { + if (!name.startsWith('mcp__')) return null + // Strip the `mcp__` prefix, then split server/method on the FIRST `__`. + const rest = name.slice('mcp__'.length) + const sep = rest.indexOf('__') + if (sep === -1) { + return { server: rest, method: '' } + } + return { + server: rest.slice(0, sep), + method: rest.slice(sep + 2), + } +} + +/** + * Truncate a string to a max length, adding an ellipsis when truncated. + */ +export function truncate(str: string, maxLen: number): string { + if (str.length <= maxLen) return str + return str.slice(0, maxLen) + '...' +} + +/** + * Render an arbitrary value as a short, human-readable summary string. + * Used by the generic / unknown-tool fallback to keep things compact. + */ +export function summarizeValue(value: unknown, maxLen = 120): string { + if (value == null) return '' + if (typeof value === 'string') return truncate(value, maxLen) + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + if (Array.isArray(value)) { + return `[${value.length} item${value.length === 1 ? '' : 's'}]` + } + if (typeof value === 'object') { + const keys = Object.keys(value as Record) + return `{${keys.length} key${keys.length === 1 ? '' : 's'}}` + } + return truncate(String(value), maxLen) +} + +/** + * Pick a small set of "key" arguments from a tool input for the fallback view. + * + * Heuristic: prefer well-known argument names that identify what the call is + * doing (`url`, `file_path`, `command`, `query`, etc.) before falling back to + * the first scalar fields. + */ +const PRIORITY_KEYS = [ + 'url', + 'file_path', + 'path', + 'command', + 'query', + 'pattern', + 'prompt', + 'description', + 'subject', + 'taskId', + 'status', + 'name', + 'title', + 'message', + 'subagent_type', +] + +export interface KeyArg { + key: string + value: unknown + display: string +} + +export function pickKeyInputArgs( + input: Record, + limit = 2, +): KeyArg[] { + if (!input) return [] + const seen = new Set() + const result: KeyArg[] = [] + + // 1) priority keys, in order + for (const key of PRIORITY_KEYS) { + if (result.length >= limit) break + if (key in input && input[key] != null) { + seen.add(key) + result.push({ key, value: input[key], display: summarizeValue(input[key]) }) + } + } + + // 2) any remaining scalar fields + if (result.length < limit) { + for (const [key, value] of Object.entries(input)) { + if (result.length >= limit) break + if (seen.has(key)) continue + if (value == null) continue + const t = typeof value + if (t !== 'string' && t !== 'number' && t !== 'boolean') continue + result.push({ key, value, display: summarizeValue(value) }) + } + } + + return result +}