/** * @file primitives.tsx * @description Presentational building blocks used by the per-tool input and * response renderers. Each primitive is a pure component with a narrow, * typed contract so they can be composed freely (Terminal + TerminalOutput for * Bash; Terminal + UnifiedDiff for Edit; LineNumberedCode for Read/Write; * FileList/MatchList for Grep/Glob; KeyValueCard for MCP tools). * @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. * * ## 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`. * * ## Public surface * - `CopyButton` — exported API; see TSDoc on the symbol for behavior. * - `Terminal` — exported API; see TSDoc on the symbol for behavior. * - `TerminalOutput` — exported API; see TSDoc on the symbol for behavior. * - `LineNumberedCode` — exported API; see TSDoc on the symbol for behavior. * - `DiffHunk` — exported API; see TSDoc on the symbol for behavior. * - `UnifiedDiff` — exported API; see TSDoc on the symbol for behavior. * - `KeyValueCard` — exported API; see TSDoc on the symbol for behavior. * - `FileList` — exported API; see TSDoc on the symbol for behavior. * - `GrepMatch` — exported API; see TSDoc on the symbol for behavior. * - `MatchList` — 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). * ----------------------------------------------------------------------------- * **CopyButton** * 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. * * **Terminal** * 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. * * **TerminalOutput** * 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. * * **LineNumberedCode** * 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. * * **DiffHunk** * 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. * * **UnifiedDiff** * 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. * * **KeyValueCard** * 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. * * **FileList** * 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. * * **GrepMatch** * 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. * * **MatchList** * 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 { useTranslation } from "react-i18next"; import { Copy, Check } from "lucide-react"; // ───────────────────────── Copy button ───────────────────────── export function CopyButton({ text }: { text: string }) { const { t } = useTranslation("common"); const [copied, setCopied] = useState(false); async function copy() { try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { // Clipboard API can fail in insecure contexts - silently ignore. } } return ( ); } // ───────────────────────── Terminal (command) ───────────────────────── export function Terminal({ command, description }: { command: string; description?: string }) { return (
terminal
        {description && 
# {description}
}
$ {command}
); } // ───────────────────────── Terminal output (stdout/stderr) ───────────────────────── export function TerminalOutput({ stdout, stderr, interrupted, exitCode, }: { stdout?: string; stderr?: string; interrupted?: boolean; exitCode?: number; }) { const hasStdout = typeof stdout === "string" && stdout.length > 0; const hasStderr = typeof stderr === "string" && stderr.length > 0; const flag = interrupted === true ? { label: "interrupted", color: "text-red-400 border-red-500/40 bg-red-500/10" } : typeof exitCode === "number" && exitCode !== 0 ? { label: `exit ${exitCode}`, color: "text-red-400 border-red-500/40 bg-red-500/10", } : null; return (
{hasStdout && } {hasStderr && } {flag && ( {flag.label} )}
); } function OutputBlock({ label, text, variant, }: { label: string; text: string; variant: "out" | "err"; }) { const color = variant === "err" ? "text-red-300" : "text-gray-200"; return (
{label}
        {text}
      
); } // ───────────────────────── Line-numbered code ───────────────────────── export function LineNumberedCode({ text, maxHeight = "24rem", startLine = 1, label, }: { text: string; maxHeight?: string; startLine?: number; label?: string; }) { const lines = text.split(/\r?\n/); return (
{label && (
{label}
)}
{lines.map((line, i) => ( ))}
{i + startLine} {line}
); } // ───────────────────────── Unified diff ───────────────────────── export type DiffHunk = { oldStart: number; newStart: number; oldLines: number; newLines: number; lines: string[]; }; export function UnifiedDiff({ hunks }: { hunks: DiffHunk[] }) { if (hunks.length === 0) { return

no diff

; } return (
{hunks.map((hunk, i) => ( ))}
); } function HunkView({ hunk }: { hunk: DiffHunk }) { let oldLine = hunk.oldStart; let newLine = hunk.newStart; return (
@@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
{hunk.lines.map((line, i) => { const kind = line.startsWith("+") ? "add" : line.startsWith("-") ? "remove" : "ctx"; const body = line.slice(kind === "ctx" ? 0 : 1); const showOld = kind !== "add"; const showNew = kind !== "remove"; const rowBg = kind === "add" ? "bg-green-500/10 text-green-200" : kind === "remove" ? "bg-red-500/10 text-red-200" : "text-gray-300"; const oldCell = showOld ? oldLine++ : ""; const newCell = showNew ? newLine++ : ""; const sign = kind === "add" ? "+" : kind === "remove" ? "-" : " "; return ( ); })}
{oldCell} {newCell} {sign} {body}
); } // ───────────────────────── Key-value card ───────────────────────── export function KeyValueCard({ data, priority = [], }: { data: Record; priority?: string[]; }) { const entries = Object.entries(data); const priorityEntries = priority .map((k) => [k, data[k]] as [string, unknown]) .filter(([, v]) => v !== undefined); const restEntries = entries.filter(([k]) => !priority.includes(k)); const ordered = [...priorityEntries, ...restEntries]; if (ordered.length === 0) { return

empty

; } return ( {ordered.map(([k, v], i) => ( 0 ? "border-t border-border" : ""}> ))}
{k}
); } function ValueCell({ value }: { value: unknown }) { if (value == null) return null; if (typeof value === "boolean") return ( {String(value)} ); if (typeof value === "number") return {value}; if (typeof value === "string") { if (value.length > 120 || value.includes("\n")) { return (
          {value}
        
); } return {value}; } if (Array.isArray(value)) { if (value.length === 0) return []; return (
    {value.map((item, i) => (
  1. ))}
); } return (
      {safeStringify(value)}
    
); } function safeStringify(value: unknown): string { try { return JSON.stringify(value, null, 2); } catch { return String(value); } } // ───────────────────────── File list / match list ───────────────────────── export function FileList({ paths }: { paths: string[] }) { if (paths.length === 0) return

no files

; return ( ); } export type GrepMatch = { file?: string; line?: number; text?: string; }; export function MatchList({ matches }: { matches: GrepMatch[] }) { if (matches.length === 0) return

no matches

; return ( ); }