/** * @file tool-views.tsx * @description Per-tool renderers for `tool_input` and `tool_response` fields. * Dispatched from EventDetail when the event's `tool_name` is recognised. * Unknown tools fall back to the caller's generic JSON code view. * * Handled tools: * - Bash / PowerShell → Terminal + TerminalOutput * - Edit / NotebookEdit → Terminal + UnifiedDiff (from old/new + structuredPatch) * - Read → Terminal + LineNumberedCode * - Write → Terminal + LineNumberedCode * - Grep → Terminal + MatchList * - Glob → Terminal + FileList * - WebFetch → Terminal + LineNumberedCode * - Task / Agent → metadata + prompt * - mcp__* → KeyValueCard with known semantic fields promoted * - AskUserQuestion → formatted Q with options * - Any other → returns null (caller falls back to generic JSON) * * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Internal dependencies * - `./primitives` * * ## Public surface * - `ToolInputView` — exported API; see TSDoc on the symbol for behavior. * - `ToolResponseView` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `.claude/skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **ToolInputView** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * **ToolResponseView** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { FileList, KeyValueCard, LineNumberedCode, MatchList, Terminal, TerminalOutput, UnifiedDiff, } from "./primitives"; import type { DiffHunk, GrepMatch } from "./primitives"; // ───────────────────────── Helpers ───────────────────────── function str(v: unknown): string { return typeof v === "string" ? v : ""; } function obj(v: unknown): Record | null { return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : null; } function isMcp(toolName: string): boolean { return toolName.startsWith("mcp__"); } /** Builds a unified-diff hunk from a bare old_string / new_string pair. One * hunk, minimal context - good enough for the input preview before the * actual structuredPatch comes back in the response. */ function diffFromStrings(oldStr: string, newStr: string): DiffHunk[] { if (!oldStr && !newStr) return []; const oldLines = oldStr ? oldStr.split(/\r?\n/) : []; const newLines = newStr ? newStr.split(/\r?\n/) : []; const lines: string[] = []; for (const l of oldLines) lines.push(`-${l}`); for (const l of newLines) lines.push(`+${l}`); return [ { oldStart: 1, newStart: 1, oldLines: oldLines.length, newLines: newLines.length, lines, }, ]; } /** Normalises the `structuredPatch` array that shows up in Edit/NotebookEdit * tool_response into DiffHunk shape. Tolerates missing fields. */ function parseStructuredPatch(value: unknown): DiffHunk[] { if (!Array.isArray(value)) return []; const hunks: DiffHunk[] = []; for (const raw of value) { const r = obj(raw); if (!r) continue; const lines = Array.isArray(r.lines) ? (r.lines.filter((l) => typeof l === "string") as string[]) : []; hunks.push({ oldStart: typeof r.oldStart === "number" ? r.oldStart : 1, newStart: typeof r.newStart === "number" ? r.newStart : 1, oldLines: typeof r.oldLines === "number" ? r.oldLines : lines.length, newLines: typeof r.newLines === "number" ? r.newLines : lines.length, lines, }); } return hunks; } /** Best-effort match list from a Grep tool_response. Supports the common * shapes: array of strings, array of {file,line,text}, or an object with * `matches` / `files` keys. */ function parseGrepMatches(value: unknown): GrepMatch[] { if (Array.isArray(value)) return value.map(toMatch).filter(Boolean) as GrepMatch[]; const o = obj(value); if (!o) return []; if (Array.isArray(o.matches)) return o.matches.map(toMatch).filter(Boolean) as GrepMatch[]; if (Array.isArray(o.files)) return (o.files as unknown[]) .map((f) => (typeof f === "string" ? ({ file: f } as GrepMatch) : null)) .filter(Boolean) as GrepMatch[]; return []; } function toMatch(raw: unknown): GrepMatch | null { if (typeof raw === "string") { const m = raw.match(/^(.+?):(\d+):(.*)$/); if (m) return { file: m[1], line: Number(m[2]), text: m[3] }; return { text: raw }; } const o = obj(raw); if (!o) return null; const match: GrepMatch = {}; if (typeof o.file === "string") match.file = o.file; if (typeof o.path === "string" && !match.file) match.file = o.path; if (typeof o.line === "number") match.line = o.line; if (typeof o.line_number === "number" && match.line == null) match.line = o.line_number; if (typeof o.text === "string") match.text = o.text; if (typeof o.match === "string" && !match.text) match.text = o.match; if (typeof o.content === "string" && !match.text) match.text = o.content; return match; } function parseFileList(value: unknown): string[] { if (Array.isArray(value)) return value.filter((v): v is string => typeof v === "string"); const o = obj(value); if (!o) return []; if (Array.isArray(o.files)) return (o.files as unknown[]).filter((v): v is string => typeof v === "string"); if (Array.isArray(o.paths)) return (o.paths as unknown[]).filter((v): v is string => typeof v === "string"); return []; } // ───────────────────────── Top-level dispatchers ───────────────────────── /** Returns a rendered view for the tool's input, or null when the tool isn't * specifically handled (caller renders JSON fallback). */ export function ToolInputView({ toolName, input, }: { toolName: string | null; input: unknown; }): React.ReactNode | null { if (!toolName) return null; const i = obj(input); if (!i) return null; // MCP tools - show the input as a key-value card with URL/query/id promoted. if (isMcp(toolName)) { return ( ); } switch (toolName) { case "Bash": case "PowerShell": { const cmd = str(i.command); const desc = str(i.description); if (!cmd) return null; return ; } case "Read": { const path = str(i.file_path); if (!path) return null; const flags: string[] = []; if (i.offset != null) flags.push(`--offset=${i.offset}`); if (i.limit != null) flags.push(`--limit=${i.limit}`); return ; } case "Write": { const path = str(i.file_path); const content = str(i.content); return (
{path && } {content && }
); } case "Edit": case "NotebookEdit": { const path = str(i.file_path); const oldStr = str(i.old_string); const newStr = str(i.new_string); const hunks = diffFromStrings(oldStr, newStr); const replaceAll = i.replace_all === true ? " --replace-all" : ""; return (
{path && } {hunks.length > 0 && }
); } case "Grep": { const pattern = str(i.pattern); const path = str(i.path); const flags = [ i.glob ? `--glob=${str(i.glob)}` : null, i.type ? `--type=${str(i.type)}` : null, i.output_mode ? `--mode=${str(i.output_mode)}` : null, i["-i"] ? "-i" : null, i["-n"] ? "-n" : null, ].filter(Boolean) as string[]; const cmd = `grep "${pattern}"${path ? " " + path : ""}${flags.length ? " " + flags.join(" ") : ""}`; return ; } case "Glob": { const pattern = str(i.pattern); const path = str(i.path); return ; } case "WebFetch": { const url = str(i.url); const prompt = str(i.prompt); return (
{url && }
); } case "Task": case "Agent": { const desc = str(i.description); const subtype = str(i.subagent_type); const prompt = str(i.prompt); return (
{prompt && }
); } case "TaskCreate": case "TaskUpdate": case "TaskGet": case "TaskStop": case "TaskOutput": case "TaskList": return ; case "AskUserQuestion": { const questions = Array.isArray(i.questions) ? i.questions : null; if (!questions) return null; return (
{questions.map((q, idx) => { const qo = obj(q); if (!qo) return null; return ( ); })}
); } default: return null; } } /** Returns a rendered view for the tool's response, or null when the tool * isn't specifically handled (caller renders JSON fallback). */ export function ToolResponseView({ toolName, response, }: { toolName: string | null; response: unknown; }): React.ReactNode | null { if (!toolName) return null; if (isMcp(toolName)) { const r = obj(response); if (r) return ( ); // Non-object responses (string, array) fall through to generic. return null; } switch (toolName) { case "Bash": case "PowerShell": { const r = obj(response); if (!r) return null; return ( ); } case "Edit": case "NotebookEdit": { const r = obj(response); if (!r) return null; const hunks = parseStructuredPatch(r.structuredPatch); const originalFile = typeof r.originalFile === "string" ? r.originalFile : ""; return (
{hunks.length > 0 && } {originalFile && (
original file ({originalFile.split(/\r?\n/).length} lines)
)}
); } case "Read": { if (typeof response === "string") return ; const r = obj(response); if (r && typeof r.content === "string") return ; return null; } case "Write": { const r = obj(response); if (!r) return null; return ; } case "Grep": { const matches = parseGrepMatches(response); if (matches.length === 0) return null; return ; } case "Glob": { const files = parseFileList(response); if (files.length === 0) return null; return ; } case "WebFetch": { if (typeof response === "string") return ; const r = obj(response); if (r && typeof r.content === "string") return ; if (r) return ; return null; } case "Task": case "Agent": { if (typeof response === "string") return ; const r = obj(response); if (r) return ; return null; } case "TaskCreate": case "TaskUpdate": case "TaskGet": case "TaskStop": case "TaskOutput": case "TaskList": { const r = obj(response); if (r) return ; return null; } case "AskUserQuestion": { const r = obj(response); if (r) return ; return null; } default: return null; } }