From 4b66442df7d5ec237407fb585f96248c1ee83321 Mon Sep 17 00:00:00 2001 From: Kazuki Chigita Date: Sun, 26 Apr 2026 02:05:45 +0900 Subject: [PATCH 1/8] Render the current Claude Code tool surface in playback (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds dedicated renderers in ToolCallBlock for the modern Claude Code tool set so playback no longer collapses into raw JSON dumps. Per-tool helpers were extracted into ToolCallRenderers / toolCallHelpers to keep the main component readable. New / updated coverage (priority follows the corpus histogram emitted by scripts/aggregate-tool-names.ts): - High: TaskCreate, TaskUpdate, AskUserQuestion, Skill, WebFetch, WebSearch, NotebookEdit, MultiEdit (alias of Edit), Task (alias of Agent) - Mid: ExitPlanMode (with plan + allowed prompts), EnterPlanMode, EnterWorktree / ExitWorktree, Monitor, ScheduleWakeup, ToolSearch - Low: a single generic mcp__server__method formatter showing server, method, and 1–2 key args - Fallback: unknown tools now show 1–2 high-signal scalar args via pickKeyInputArgs instead of dumping the whole input as JSON Also adds: - scripts/aggregate-tool-names.ts: one-shot histogram of tool_use blocks across ~/.claude/projects/, used to derive the priority list - Pure helpers (getToolCategory, parseMcpToolName, summarizeValue, pickKeyInputArgs, truncate) with unit tests Closes #21 Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/aggregate-tool-names.ts | 122 +++++ src/components/playback/ToolCallBlock.css | 294 +++++++++++ src/components/playback/ToolCallBlock.tsx | 222 ++++----- src/components/playback/ToolCallRenderers.tsx | 468 ++++++++++++++++++ .../__tests__/toolCallHelpers.test.ts | 154 ++++++ src/components/playback/toolCallHelpers.ts | 182 +++++++ 6 files changed, 1310 insertions(+), 132 deletions(-) create mode 100644 scripts/aggregate-tool-names.ts create mode 100644 src/components/playback/ToolCallRenderers.tsx create mode 100644 src/components/playback/__tests__/toolCallHelpers.test.ts create mode 100644 src/components/playback/toolCallHelpers.ts 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..1fbab81 --- /dev/null +++ b/src/components/playback/ToolCallRenderers.tsx @@ -0,0 +1,468 @@ +/** + * 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 + +function asString(value: unknown): string | null { + if (value == null) return null + if (typeof value === 'string') return value + return String(value) +} + +// ─── 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} +
+ )} +
+ ) +} + +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) => ( +
+ {q.header &&
{q.header}
} + {q.question && ( +
{truncate(q.question, 280)}
+ )} + {Array.isArray(q.options) && q.options.length > 0 && ( +
    + {q.options.slice(0, 6).map((opt, j) => ( +
  • + + {opt.label ?? `選択肢 ${j + 1}`} + + {opt.description && ( + + {' — '}{truncate(opt.description, 160)} + + )} +
  • + ))} + {q.options.length > 6 && ( +
  • + …他 {q.options.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)}
} +
+ ) +} + +export function WebSearchRenderer({ input }: { input: Input }) { + const query = asString(input.query) + const allowed = Array.isArray(input.allowed_domains) ? input.allowed_domains : [] + const blocked = Array.isArray(input.blocked_domains) ? input.blocked_domains : [] + return ( +
+ {query &&
{truncate(query, 240)}
} + {allowed.length > 0 && ( +
+ allow:{' '} + {allowed.slice(0, 5).map(String).join(', ')} + {allowed.length > 5 && ` …+${allowed.length - 5}`} +
+ )} + {blocked.length > 0 && ( +
+ block:{' '} + {blocked.slice(0, 5).map(String).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) => ( +
  • + {p.tool && {p.tool}}{' '} + {p.prompt && {truncate(p.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. + 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 +} From fa678a3a9382febe4edf616497a2618b7b4f93a9 Mon Sep 17 00:00:00 2001 From: Kazuki Chigita Date: Sun, 26 Apr 2026 02:20:51 +0900 Subject: [PATCH 2/8] fix(nirami): validate AskUserQuestion option items before access The previous code cast q.options items to {label?: string; description?: string} without runtime validation. Since the input arrives from JSONL data of unknown shape, accessing opt.label / opt.description on a non-object item would throw. Filter the array to objects up front and validate field types per option. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/playback/ToolCallRenderers.tsx | 55 ++++++++++++------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/src/components/playback/ToolCallRenderers.tsx b/src/components/playback/ToolCallRenderers.tsx index 1fbab81..3d15a2c 100644 --- a/src/components/playback/ToolCallRenderers.tsx +++ b/src/components/playback/ToolCallRenderers.tsx @@ -180,27 +180,40 @@ export function AskUserQuestionRenderer({ input }: { input: Input }) { {q.question && (
{truncate(q.question, 280)}
)} - {Array.isArray(q.options) && q.options.length > 0 && ( -
    - {q.options.slice(0, 6).map((opt, j) => ( -
  • - - {opt.label ?? `選択肢 ${j + 1}`} - - {opt.description && ( - - {' — '}{truncate(opt.description, 160)} - - )} -
  • - ))} - {q.options.length > 6 && ( -
  • - …他 {q.options.length - 6} 件 -
  • - )} -
- )} + {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} 件 +
  • + )} +
+ ) + })()} ))} From e6cea714aa6767294f33f16f95e47d61fa58da62 Mon Sep 17 00:00:00 2001 From: Kazuki Chigita Date: Sun, 26 Apr 2026 02:21:18 +0900 Subject: [PATCH 3/8] fix(nirami): avoid stringifying object specs in MonitorRenderer input.command / input.spec may contain a structured object spec. Calling asString on it produced the unhelpful "[object Object]" rendering. Use a scalar-only guard so structured payloads fall through to the generic key/value renderer instead. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/playback/ToolCallRenderers.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/playback/ToolCallRenderers.tsx b/src/components/playback/ToolCallRenderers.tsx index 3d15a2c..680665a 100644 --- a/src/components/playback/ToolCallRenderers.tsx +++ b/src/components/playback/ToolCallRenderers.tsx @@ -362,10 +362,19 @@ export function EnterPlanModeRenderer({ input }: { input: Input }) { // ─── Monitor / ScheduleWakeup ─────────────────────────────────────────────── +function asScalarString(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 +} + export function MonitorRenderer({ input }: { input: Input }) { // Monitor typically takes a command-like spec; fall back to scalar args. - const command = asString(input.command ?? input.spec) - const description = asString(input.description ?? input.until) + // Avoid stringifying nested objects to "[object Object]"; let the generic + // renderer handle structured payloads instead. + const command = asScalarString(input.command ?? input.spec) + const description = asScalarString(input.description ?? input.until) if (!command && !description) { return } From d52a5bfd7eafb3033bb4f1e60c28dcc91e2b74fb Mon Sep 17 00:00:00 2001 From: Kazuki Chigita Date: Sun, 26 Apr 2026 02:22:09 +0900 Subject: [PATCH 4/8] fix(nirami): validate AskUserQuestion header and question types q.header and q.question were typed as optional strings but never validated at runtime. A non-string header (e.g. null cleared via JSON, or an unexpected object) would either render incorrectly or, in the case of question, throw inside truncate() which calls String.prototype.slice. Coerce to null when the field is not a string before rendering. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/playback/ToolCallRenderers.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/playback/ToolCallRenderers.tsx b/src/components/playback/ToolCallRenderers.tsx index 680665a..67172cc 100644 --- a/src/components/playback/ToolCallRenderers.tsx +++ b/src/components/playback/ToolCallRenderers.tsx @@ -174,11 +174,14 @@ export function AskUserQuestionRenderer({ input }: { input: Input }) { return (
- {questions.map((q, i) => ( + {questions.map((q, i) => { + const header = typeof q.header === 'string' ? q.header : null + const question = typeof q.question === 'string' ? q.question : null + return (
- {q.header &&
{q.header}
} - {q.question && ( -
{truncate(q.question, 280)}
+ {header &&
{header}
} + {question && ( +
{truncate(question, 280)}
)} {Array.isArray(q.options) && q.options.length > 0 && (() => { const safeOptions = q.options.filter( @@ -215,7 +218,8 @@ export function AskUserQuestionRenderer({ input }: { input: Input }) { ) })()}
- ))} + ) + })}
) } From 1810273365a615404d7d8bc433fba14d7f192725 Mon Sep 17 00:00:00 2001 From: Kazuki Chigita Date: Sun, 26 Apr 2026 02:23:06 +0900 Subject: [PATCH 5/8] fix(nirami): validate AllowedPrompt fields in ExitPlanModeRenderer p.tool and p.prompt were rendered without runtime validation. A non-string prompt would cause truncate() to call slice() on a non-string and throw, and a non-string tool would render via React's default coercion. Coerce both to null when they aren't strings before rendering. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/playback/ToolCallRenderers.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/components/playback/ToolCallRenderers.tsx b/src/components/playback/ToolCallRenderers.tsx index 67172cc..734736d 100644 --- a/src/components/playback/ToolCallRenderers.tsx +++ b/src/components/playback/ToolCallRenderers.tsx @@ -335,12 +335,16 @@ export function ExitPlanModeRenderer({ input }: { input: Input }) {
許可 ({allowed.length})
    - {allowed.slice(0, 5).map((p, i) => ( -
  • - {p.tool && {p.tool}}{' '} - {p.prompt && {truncate(p.prompt, 120)}} -
  • - ))} + {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} 件
  • }
From 6ff61eb055b1e63c7138ee12c6bf04231ced4c78 Mon Sep 17 00:00:00 2001 From: Kazuki Chigita Date: Sun, 26 Apr 2026 02:23:34 +0900 Subject: [PATCH 6/8] fix(nirami): make asString scalar-only to avoid object stringification asString previously fell back to String(value) for non-string values, which produced "[object Object]" for objects and "1,2,3" for arrays whenever the JSONL payload had an unexpected shape. Restrict it to strings, numbers, and booleans so structured payloads return null and downstream renderers can opt into a structured fallback (e.g. GenericInputRenderer) instead. Removes the local asScalarString helper that was added earlier for the same reason. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/playback/ToolCallRenderers.tsx | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/components/playback/ToolCallRenderers.tsx b/src/components/playback/ToolCallRenderers.tsx index 734736d..307cb32 100644 --- a/src/components/playback/ToolCallRenderers.tsx +++ b/src/components/playback/ToolCallRenderers.tsx @@ -15,10 +15,16 @@ import { 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 - return String(value) + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + return null } // ─── Existing tools ───────────────────────────────────────────────────────── @@ -370,19 +376,12 @@ export function EnterPlanModeRenderer({ input }: { input: Input }) { // ─── Monitor / ScheduleWakeup ─────────────────────────────────────────────── -function asScalarString(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 -} - 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 = asScalarString(input.command ?? input.spec) - const description = asScalarString(input.description ?? input.until) + const command = asString(input.command ?? input.spec) + const description = asString(input.description ?? input.until) if (!command && !description) { return } From 0aa0e1790875bcc75f7897ca3ebb9b9225c82fce Mon Sep 17 00:00:00 2001 From: Kazuki Chigita Date: Sun, 26 Apr 2026 02:24:13 +0900 Subject: [PATCH 7/8] fix(nirami): filter WebSearch domain lists to non-empty strings allowed_domains / blocked_domains were rendered via map(String).join(', '), which produces "[object Object]" for any non-string entry that slips through the JSONL payload. Filter to non-empty strings so display stays clean and sliced counts match what is actually shown. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/playback/ToolCallRenderers.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/playback/ToolCallRenderers.tsx b/src/components/playback/ToolCallRenderers.tsx index 307cb32..57c46f4 100644 --- a/src/components/playback/ToolCallRenderers.tsx +++ b/src/components/playback/ToolCallRenderers.tsx @@ -260,24 +260,29 @@ export function WebFetchRenderer({ input }: { input: Input }) { ) } +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 = Array.isArray(input.allowed_domains) ? input.allowed_domains : [] - const blocked = Array.isArray(input.blocked_domains) ? input.blocked_domains : [] + 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).map(String).join(', ')} + {allowed.slice(0, 5).join(', ')} {allowed.length > 5 && ` …+${allowed.length - 5}`}
)} {blocked.length > 0 && (
block:{' '} - {blocked.slice(0, 5).map(String).join(', ')} + {blocked.slice(0, 5).join(', ')} {blocked.length > 5 && ` …+${blocked.length - 5}`}
)} From 2766014f608f42fd4b629b9fd08976c148de31b7 Mon Sep 17 00:00:00 2001 From: Kazuki Chigita Date: Sun, 26 Apr 2026 02:25:00 +0900 Subject: [PATCH 8/8] fix(nirami): sanitize status before composing TaskUpdate CSS class name The status string from JSONL was concatenated into a CSS class modifier without any validation. A status containing whitespace (e.g. "in progress") would split into two class tokens, dropping the intended modifier and adding a stray class. Restrict the modifier to safe characters and fall back to "unknown" otherwise. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/playback/ToolCallRenderers.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/playback/ToolCallRenderers.tsx b/src/components/playback/ToolCallRenderers.tsx index 57c46f4..bf020c1 100644 --- a/src/components/playback/ToolCallRenderers.tsx +++ b/src/components/playback/ToolCallRenderers.tsx @@ -133,6 +133,13 @@ export function TaskCreateRenderer({ input }: { input: Input }) { ) } +// 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) @@ -142,7 +149,9 @@ export function TaskUpdateRenderer({ input }: { input: Input }) {
{taskId && #{taskId}} {status && ( - + {status} )}