/** * @file ToolCallBlock.tsx * @description Collapsible block rendered inside an assistant message for each * tool_use / tool_result pair. Shows tool icon + name in the header, with the * paired result inline when present. Per-tool styling comes from toolStyle.ts; * the tool's input/output payload is delegated to for syntax * highlighting. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments. * * ## 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. * * ## Observability * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and * Docker Compose profiles are documented in `monitoring/README.md`. * * ## Internal dependencies * - `../../lib/types` * - `./CodeBlock` * - `./toolStyle` * * ## Public surface * - `ToolCallBlock` — 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). * ----------------------------------------------------------------------------- * **ToolCallBlock** * 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 { useState } from "react"; import { ChevronRight, AlertCircle, FileText, CheckCircle2 } from "lucide-react"; import type { TranscriptContent } from "../../lib/types"; import { CodeBlock } from "./CodeBlock"; import { styleForTool } from "./toolStyle"; interface ToolCallBlockProps { toolUse: TranscriptContent; toolResult?: TranscriptContent | null; } /** Detect a likely language from a file path's extension. */ function langFromPath(path: string): string { const ext = path.split(".").pop()?.toLowerCase() ?? ""; const map: Record = { ts: "ts", tsx: "ts", js: "js", jsx: "js", mjs: "js", cjs: "js", py: "python", json: "json", yml: "yaml", yaml: "yaml", sh: "bash", bash: "bash", zsh: "bash", html: "html", htm: "html", css: "css", scss: "css", sql: "sql", md: "plain", txt: "plain", diff: "diff", patch: "diff", }; return map[ext] ?? "plain"; } /** Build a one-line summary of the tool call to show in the collapsed header. */ function buildSummary(toolUse: TranscriptContent): string | null { const input = toolUse.input; if (!input || typeof input !== "object" || "_truncated" in input) return null; const obj = input as Record; if (typeof obj.file_path === "string") return obj.file_path; if (typeof obj.path === "string") return obj.path; if (typeof obj.command === "string") return obj.command.slice(0, 200); if (typeof obj.pattern === "string") return obj.pattern; if (typeof obj.query === "string") return obj.query; if (typeof obj.url === "string") return obj.url; if (typeof obj.description === "string") return obj.description; return null; } /** Render the input pane with tool-aware formatting. */ function renderInput(toolUse: TranscriptContent) { const input = toolUse.input; if (!input) return null; // Truncated payload from the backend if (typeof input === "object" && "_truncated" in input) { return ( ); } const obj = input as Record; const tool = (toolUse.name ?? "").toLowerCase(); // Bash: show the command with shell highlighting if (tool === "bash" && typeof obj.command === "string") { return (
{typeof obj.description === "string" && (

{obj.description}

)}
); } // Write: render new content as code with the file path as the chrome label if (tool === "write" && typeof obj.file_path === "string" && typeof obj.content === "string") { return ( ); } // Edit: side-by-side old/new if (tool === "edit" && typeof obj.file_path === "string") { const lang = langFromPath(obj.file_path); return (
{obj.file_path} {obj.replace_all === true && ( replace all )}
{typeof obj.old_string === "string" && ( )} {typeof obj.new_string === "string" && ( )}
); } // Read: just show the path with offset/limit if (tool === "read" && typeof obj.file_path === "string") { return (
{obj.file_path} {(typeof obj.offset === "number" || typeof obj.limit === "number") && ( {typeof obj.offset === "number" ? `:${obj.offset}` : ""} {typeof obj.limit === "number" ? `+${obj.limit}` : ""} )}
); } // Grep: pattern + path if (tool === "grep" && typeof obj.pattern === "string") { return (
Pattern {obj.pattern}
{typeof obj.path === "string" && (
Path {obj.path}
)} {typeof obj.glob === "string" && (
Glob {obj.glob}
)}
); } // Default: pretty JSON return ; } /** Render the result pane: detect diff/json/text. */ function renderResult(toolResult: TranscriptContent, toolName: string) { const text = toolResult.output ?? ""; if (text.length === 0) return
(empty)
; const isError = !!toolResult.is_error; const tool = toolName.toLowerCase(); const label = isError ? "Error" : "Output"; // Heuristics for language let lang = "plain"; const trimmed = text.trim(); if (trimmed.startsWith("{") || trimmed.startsWith("[")) { try { JSON.parse(trimmed); lang = "json"; } catch { // fall through } } else if (/^(\+\+\+|---|@@) /m.test(text) || /^diff --git /m.test(text)) { lang = "diff"; } else if (tool === "bash") { lang = "bash"; } return ; } export function ToolCallBlock({ toolUse, toolResult }: ToolCallBlockProps) { const [expanded, setExpanded] = useState(false); const isError = toolResult?.is_error; const hasResult = toolResult != null; const summary = buildSummary(toolUse); const style = styleForTool(toolUse.name); const Icon = style.Icon; const wrapperBorder = isError ? "border-status-danger/30" : style.border; const wrapperBg = isError ? "bg-status-danger/5" : "bg-surface-2/60"; return (
{/* Collapsed/expanded toggle */} {/* Expanded body */} {expanded && (
{renderInput(toolUse)} {hasResult && renderResult(toolResult, toolUse.name ?? "")}
)}
); }