/** * @file event-summary.ts * @description Produces a short, human-readable summary of a DashboardEvent * for the top of the expanded EventDetail panel. Purely data-driven - parses * `tool_input` / `tool_response` and extracts the most useful facts. Returns * null for events where a summary would add nothing (e.g. unknown tools with * empty payloads). * * ## Output * The single public entry point, {@link buildEventSummary}, returns an * {@link EventSummary} — an `{ icon, headline, bullets }` triple. The icon is an * emoji chosen per event/tool kind, the headline is a one-line description, and * the bullets are optional supporting facts (diff stats, line counts, error * flags). It returns null only when there is genuinely nothing to show. * * ## How it differs from event-grouping.ts * `event-grouping.ts` produces the *collapsed* one-line title for a timeline * row. This file produces the *expanded* detail summary and therefore also * parses `tool_response` (not just `tool_input`) to report on outcomes — how * many lines a command printed, how many hunks an edit touched, how many matches * a search found, and so on. * * ## Event shape * Each helper reads a {@link DashboardEvent}. Relevant fields: `event_type` * (lifecycle name), `tool_name` (present only on tool events), and `data` — a * JSON *string* whose parsed form usually holds `tool_input` (arguments) and * `tool_response` (result). All parsing is defensive: malformed JSON, missing * fields, and unexpected types degrade to a smaller summary or null rather than * throwing. * * @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 * - `./types` * * ## Public surface * - `EventSummary` — exported API; see TSDoc on the symbol for behavior. * - `buildEventSummary` — 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. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **EventSummary** * 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. * * **buildEventSummary** * 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 type { DashboardEvent } from "./types"; /** Rendered result of {@link buildEventSummary}: an emoji icon, a one-line * headline, and zero or more supporting detail lines shown underneath it. */ export type EventSummary = { /** Single emoji representing the event kind (tool-specific or lifecycle). */ icon: string; /** Primary one-line description, e.g. "Edited SessionDetail.tsx". */ headline: string; /** Secondary detail lines (diff stats, line counts, error flags, …); may be empty. */ bullets: string[]; }; // ════════════════════════════════════════════════════════════════════════════ // Small value / formatting helpers // ════════════════════════════════════════════════════════════════════════════ /** Coerces an unknown value to a string, returning "" for non-strings. * @param v Any value pulled out of a parsed payload. * @returns The value when it's a string, otherwise "". */ function str(v: unknown): string { return typeof v === "string" ? v : ""; } /** Narrows an unknown value to a plain object (not null, not an array). * @param v Any value (typically from `JSON.parse`). * @returns The value typed as a record, or null when it isn't a plain object. */ function obj(v: unknown): Record | null { return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : null; } /** Compact two-segment path label (parent dir + filename), mirroring the helper * of the same name in event-grouping.ts. Falls back to the sole segment. * @param path An absolute or relative path (POSIX or Windows separators). * @returns The last two segments joined with "/", or the single segment. * @example shortPath("/repo/src/lib/x.ts") // "lib/x.ts" */ function shortPath(path: string): string { const parts = path.split(/[/\\]/).filter(Boolean); if (parts.length <= 1) return parts[0] ?? path; return parts.slice(-2).join("/"); } /** Truncates `text` to `max` chars, appending an ellipsis when it was clipped. * @param text The string to clamp. * @param max Maximum length before truncation (exclusive of the "..."). * @returns The original string, or its first `max` chars followed by "...". */ function trunc(text: string, max: number): string { return text.length > max ? text.slice(0, max) + "..." : text; } /** Parses `event.data` (JSON) into a plain object, or null on empty/invalid data. * @param event The event whose JSON `data` string should be decoded. * @returns The parsed payload object, or null when data is absent, invalid JSON, * or not a plain object. */ function parseData(event: DashboardEvent): Record | null { if (!event.data) return null; try { const v = JSON.parse(event.data); // Reuse `obj` so arrays / primitives (never valid payloads here) yield null. return obj(v); } catch { // Malformed JSON — no summary data available. return null; } } // ════════════════════════════════════════════════════════════════════════════ // Tool-response analyzers // ════════════════════════════════════════════════════════════════════════════ /** Counts hunks and +/- lines in an Edit tool response's `structuredPatch`. * * Claude Code's Edit/NotebookEdit responses include a `structuredPatch`: an * array of hunks, each with a `lines: string[]` where every entry is prefixed * by " ", "+", or "-" (unified-diff style). This tallies additions / removals * so the summary can show "3 hunks · +12 −4". * @param structuredPatch The `tool_response.structuredPatch` value (untyped). * @returns `{ hunks, added, removed }`; all zero when the input isn't an array. * @example * countHunks([{ lines: ["+new", "-old", " ctx"] }]) // { hunks:1, added:1, removed:1 } */ function countHunks(structuredPatch: unknown): { hunks: number; added: number; removed: number } { // Non-array (missing / failed patch) → nothing to count. if (!Array.isArray(structuredPatch)) return { hunks: 0, added: 0, removed: 0 }; let added = 0; let removed = 0; for (const raw of structuredPatch) { const r = obj(raw); // Skip malformed hunks that lack a `lines` array. if (!r || !Array.isArray(r.lines)) continue; for (const line of r.lines) { if (typeof line !== "string") continue; // Leading "+" = added line, leading "-" = removed line; " " = context. if (line.startsWith("+")) added++; else if (line.startsWith("-")) removed++; } } // Hunk count is simply the number of patch entries. return { hunks: structuredPatch.length, added, removed }; } /** Finds the nearest function/class/const definition line surrounding an * Edit hunk, so the summary can show "Inside: function foo(...)". * * Scans the patch's lines for the first one matching a definition-like shape. * The pattern intentionally requires a leading space (`^\s+`) so it matches * *context* lines (unchanged surroundings) rather than the "+"/"-" changed * lines — the enclosing definition is usually context, not the edit itself. It * recognizes JS/TS `function` / `const|let|var` / `name = (`, Python `def`, and * `class` across languages. * @param structuredPatch The `tool_response.structuredPatch` value (untyped). * @returns The trimmed definition line, or null when none is found. */ function firstEnclosingContext(structuredPatch: unknown): string | null { // Look for a context line that looks like a function/class/const definition. if (!Array.isArray(structuredPatch)) return null; const defPattern = /^\s+(?:function\s+\w+|def\s+\w+|class\s+\w+|(?:const|let|var)\s+\w+|\w+\s*=\s*\()/; for (const raw of structuredPatch) { const r = obj(raw); if (!r || !Array.isArray(r.lines)) continue; for (const line of r.lines) { // First matching line wins; trim the diff indentation for display. if (typeof line === "string" && defPattern.test(line)) { return line.trim(); } } } return null; } /** Counts lines in `text` (empty string counts as 0, not 1). * * Splits on LF or CRLF. The empty-string guard matters: `"".split(/\n/)` * returns `[""]` (length 1), which would wrongly report an empty output as * "1 line". * @param text The text whose lines to count. * @returns The number of newline-separated lines, or 0 for empty input. */ function lineCount(text: string): number { if (!text) return 0; return text.split(/\r?\n/).length; } /** Formats a millisecond duration as "Nms" / "N.Ns" / "Nm Ns", for TurnDuration events. * * Three tiers keep the label readable at any scale: raw milliseconds under a * second, one-decimal seconds under a minute, and "Xm Ys" beyond that. * @param ms A non-negative duration in milliseconds. * @returns A compact human-readable duration string. * @example formatDuration(450) // "450ms" * @example formatDuration(1500) // "1.5s" * @example formatDuration(95000) // "1m 35s" */ function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`; } /** * Builds the icon/headline/bullets summary shown at the top of the expanded * EventDetail panel. Dispatches first on `event_type` for lifecycle events * (Stop, TurnDuration, Compaction, Notification, SessionStart/End, APIError), * then on `tool_name` for tool events - parsing `data`'s `tool_input`/ * `tool_response` to surface tool-specific facts (diff stats for Edit, line * counts for Read/Write, match counts for Grep/Glob, etc.). * @param event The raw event to summarize. * @returns An {@link EventSummary}, or null when there's no tool name and no * recognized event type to build anything useful from. */ export function buildEventSummary(event: DashboardEvent): EventSummary | null { const data = parseData(event); // ── Non-tool events first ────────────────────────────────────────── // Lifecycle events have no tool payload; each maps to a fixed icon / headline // plus any incidental detail carried in `data`. // Stop / SubagentStop: a turn (or subagent turn) ended. Optionally note the // stop-hook flag and the first line of the last assistant message. if (event.event_type === "Stop" || event.event_type === "SubagentStop") { const stopHookActive = data?.stop_hook_active === true; const msg = str(data?.last_assistant_message); const bullets: string[] = []; if (stopHookActive) bullets.push("stop hook active"); // Only the first line keeps the bullet compact for multi-line messages. if (msg) bullets.push(`Last message: ${trunc(msg.split(/\r?\n/)[0] ?? "", 80)}`); return { icon: "🛑", headline: event.event_type === "SubagentStop" ? "Subagent turn ended" : "Turn ended", bullets, }; } // TurnDuration: synthetic event carrying how long the turn took, in ms. if (event.event_type === "TurnDuration") { const durationMs = typeof data?.durationMs === "number" ? data.durationMs : null; return { icon: "⏱️", headline: durationMs != null ? `Turn took ${formatDuration(durationMs)}` : "Turn finished", bullets: [], }; } // Compaction: the transcript was summarized to reclaim context window. if (event.event_type === "Compaction") { return { icon: "🗜️", headline: "Transcript compacted", bullets: ["Token usage reset for the following turn"], }; } // Notification: a user-facing message from the agent; show text + optional type. if (event.event_type === "Notification") { const msg = str(data?.message); const type = str(data?.notification_type); return { icon: "🔔", headline: msg ? `Notification: ${trunc(msg, 80)}` : "Notification", bullets: type ? [`Type: ${type}`] : [], }; } // SessionStart / SessionEnd: bracket a session; surface its source and model // when the payload includes them. The `.filter(Boolean)` drops empty bullets. if (event.event_type === "SessionStart" || event.event_type === "SessionEnd") { const source = str(data?.source); const model = str(data?.model); return { icon: event.event_type === "SessionStart" ? "🎬" : "🏁", headline: event.event_type === "SessionStart" ? "Session started" : "Session ended", bullets: [source && `Source: ${source}`, model && `Model: ${model}`].filter( Boolean ) as string[], }; } // APIError: a provider / API failure was recorded for this event. if (event.event_type === "APIError") { return { icon: "⚠️", headline: "API error recorded", bullets: [], }; } // ── Tool events ──────────────────────────────────────────────────── // Past the lifecycle branches, only tool events remain. Without a tool_name // there's nothing to summarize. const tool = event.tool_name; if (!tool) return null; // Both may be null: `tool_input` is the arguments, `tool_response` the result. const input = obj(data?.tool_input); const response = obj(data?.tool_response); // MCP tools - generic summary. Any "mcp__" tool is summarized structurally, // pulling the most relevant field out of the call args and the response. if (tool.startsWith("mcp__")) { const headline = humanizeMcp(tool); const bullets: string[] = []; if (input) { // Surface the most identifying call argument, if any. const top = firstStringField(input, ["title", "query", "q", "url", "name", "id"]); if (top) bullets.push(`Called with: ${trunc(top, 80)}`); } if (response) { // Prefer a recognizable response field; otherwise report its field count. const resTop = firstStringField(response, ["title", "name", "state", "status", "url"]); if (resTop) bullets.push(`Response: ${trunc(resTop, 80)}`); else bullets.push(`Returned ${Object.keys(response).length} fields`); } return { icon: "🧩", headline, bullets }; } // ── Native tools ─────────────────────────────────────────────────── // Each case builds an icon + headline + outcome bullets from that tool's // specific input / response shape. switch (tool) { case "Bash": case "PowerShell": { // Report the command plus stdout/stderr line counts and interruption. const cmd = str(input?.command); const desc = str(input?.description); const stdout = str(response?.stdout); const stderr = str(response?.stderr); const interrupted = response?.interrupted === true; const bullets: string[] = []; if (desc) bullets.push(`"${desc}"`); if (stdout) bullets.push(`${lineCount(stdout)} lines stdout`); if (stderr) bullets.push(`${lineCount(stderr)} lines stderr`); // Distinguish "empty stderr" (we have a response) from "unknown" (no // response yet) — only claim "no stderr" once some output / response exists. else if (stdout || response) bullets.push("no stderr"); if (interrupted) bullets.push("⚠ interrupted"); return { icon: "💻", // Headline leads with the binary, then the full (clamped) command. headline: cmd ? `Ran ${trunc(firstWord(cmd), 40)}: ${trunc(cmd, 80)}` : `${tool} call`, bullets, }; } case "Edit": case "NotebookEdit": { // Summarize the diff: enclosing definition, hunk / line counts, and whether // it was a global replace_all. const path = str(input?.file_path); const { hunks, added, removed } = countHunks(response?.structuredPatch); const ctx = firstEnclosingContext(response?.structuredPatch); const replaceAll = input?.replace_all === true; const bullets: string[] = []; if (ctx) bullets.push(`Inside: ${trunc(ctx, 80)}`); // Pluralize "hunk" and show the "+added −removed" tally. if (hunks > 0) bullets.push(`${hunks} hunk${hunks === 1 ? "" : "s"} · +${added} −${removed}`); if (replaceAll) bullets.push("replace_all mode"); return { icon: "✏️", headline: path ? `Edited ${shortPath(path)}` : `${tool} call`, bullets, }; } case "Write": { // Report the size of the written content in lines and bytes. const path = str(input?.file_path); const content = str(input?.content); const bullets: string[] = []; if (content) bullets.push(`${lineCount(content)} lines · ${content.length} bytes`); return { icon: "📝", headline: path ? `Wrote ${shortPath(path)}` : `Write call`, bullets, }; } case "Read": { // Note whether a partial range (offset/limit) or the full file was read, // plus how many lines came back. const path = str(input?.file_path); const offset = input?.offset; const limit = input?.limit; const bullets: string[] = []; if (offset != null || limit != null) { // A range read — describe whichever bounds were provided. const parts: string[] = []; if (offset != null) parts.push(`offset ${offset}`); if (limit != null) parts.push(`limit ${limit}`); bullets.push(`Range: ${parts.join(", ")}`); } else { bullets.push("Full file"); } // Read responses come back as a raw string of file contents. if (typeof response === "string") { bullets.push(`${lineCount(response)} lines returned`); } return { icon: "📖", headline: path ? `Read ${shortPath(path)}` : "Read call", bullets, }; } case "Grep": { // Headline the search pattern (+ scope); bullet the match count. const pattern = str(input?.pattern); const path = str(input?.path); const bullets: string[] = []; const matchCount = countGrepMatches(response); // Pluralize "match" / "matches" based on the count. if (matchCount != null) bullets.push(`${matchCount} match${matchCount === 1 ? "" : "es"}`); return { icon: "🔍", headline: pattern ? `Searched "${trunc(pattern, 50)}"${path ? ` in ${shortPath(path)}` : ""}` : "Grep call", bullets, }; } case "Glob": { // Headline the glob pattern; bullet how many files matched. const pattern = str(input?.pattern); const bullets: string[] = []; const fileCount = countFiles(response); if (fileCount != null) bullets.push(`${fileCount} file${fileCount === 1 ? "" : "s"}`); return { icon: "🗂️", headline: pattern ? `Listed files matching "${pattern}"` : "Glob call", bullets, }; } case "WebFetch": { // Headline the fetched host; bullet the extraction prompt and response size. const url = str(input?.url); const prompt = str(input?.prompt); const bullets: string[] = []; if (prompt) bullets.push(`Prompt: ${trunc(prompt, 80)}`); if (typeof response === "string") bullets.push(`${lineCount(response)} lines returned`); let host = ""; try { host = new URL(url).host; } catch { // Malformed / relative URL — show the raw string instead of the host. host = url; } return { icon: "🌐", headline: url ? `Fetched ${host}` : "WebFetch call", bullets, }; } case "Task": case "Agent": { // Detail the spawned subagent's kind, task, and output size. const subtype = str(input?.subagent_type); const desc = str(input?.description); const bullets: string[] = []; if (subtype) bullets.push(`Subagent: ${subtype}`); if (desc) bullets.push(`Description: ${trunc(desc, 80)}`); if (typeof response === "string") bullets.push(`${lineCount(response)} lines output`); return { icon: "🤖", headline: `Spawned subagent`, bullets }; } case "TaskCreate": { // A task was created — headline its description. const d = str(input?.description); return { icon: "✅", headline: d ? `Created task: ${trunc(d, 80)}` : "TaskCreate", bullets: [], }; } case "TaskUpdate": { // A task was updated — prefer its description, falling back to its id. const d = str(input?.description) || str(input?.id); return { icon: "🔄", headline: d ? `Updated task: ${trunc(d, 80)}` : "TaskUpdate", bullets: [], }; } case "AskUserQuestion": { // Headline the first question's text from the `questions` array. const qs = Array.isArray(input?.questions) ? input?.questions : null; const first = qs && qs.length > 0 ? obj(qs[0]) : null; const q = first ? str(first.question) : ""; return { icon: "❓", headline: q ? `Asked: "${trunc(q, 80)}"` : "Asked user", bullets: [], }; } case "ScheduleWakeup": { // Headline the delay; bullet the reason when supplied. const delay = input?.delaySeconds; const reason = str(input?.reason); return { icon: "😴", headline: typeof delay === "number" ? `Scheduled wakeup in ${delay}s` : "Scheduled wakeup", bullets: reason ? [`Reason: ${trunc(reason, 80)}`] : [], }; } default: { // Unknown native tool - minimal summary. With neither input nor response // there is nothing worth showing, so return null (no summary card). if (!input && !response) return null; return { icon: "🔧", headline: `${tool} call`, // At least report how many input fields were passed. bullets: input ? [`${Object.keys(input).length} input fields`] : [], }; } } } // ════════════════════════════════════════════════════════════════════════════ // MCP name + field extraction helpers // ════════════════════════════════════════════════════════════════════════════ /** Turns an `mcp__server__tool_name` tool name into "Server · tool name" for * the MCP-tool summary headline (duplicates the dedupe/casing logic in * event-grouping.ts's `humanizeMcpServer`, kept local to avoid a cross-import). * @param toolName A namespaced MCP tool name, e.g. "mcp__github__list_issues". * @returns "Server · tool name" (e.g. "Github · list issues"), or the raw name * when it doesn't have the expected 3+ `__`-separated segments. * @example humanizeMcp("mcp__claude_ai_Slack__send_message") // "Slack · send message" */ function humanizeMcp(toolName: string): string { const parts = toolName.split("__").filter(Boolean); // Not a well-formed MCP name — return it untouched. if (parts.length < 3) return toolName; const rawServer = parts[1] ?? ""; // Everything past the server is the (possibly multi-word) action. const rest = parts.slice(2).join(" "); // Reuse the same server-humanization logic as elsewhere: split, dedupe, last token. const tokens = rawServer.split(/[_-]+/).filter(Boolean); const dedup: string[] = []; for (const t of tokens) if (dedup[dedup.length - 1] !== t) dedup.push(t); const last = dedup[dedup.length - 1] ?? rawServer; // Capitalize only when the token is all-lowercase (preserves "GitLab" etc.). const server = last.toLowerCase() === last ? last.charAt(0).toUpperCase() + last.slice(1) : last; // Normalize the action to lowercase spaced words. const toolPart = rest.replace(/_+/g, " ").trim().toLowerCase(); return `${server} · ${toolPart}`; } /** Extracts the first whitespace-delimited token of a shell command (the binary). * @param command The full command string. * @returns The first non-whitespace run, or the original string if none. * @example firstWord(" npm run build") // "npm" */ function firstWord(command: string): string { const m = command.trim().match(/^(\S+)/); return m ? (m[1] ?? command) : command; } /** Returns the first non-empty string value found in `obj` among `priority` * keys, in order - used to surface the most relevant MCP call/response field. * @param obj The object to inspect (a parsed input or response). * @param priority Keys to try, most-preferred first. * @returns The first matching non-empty string, or null when none match. */ function firstStringField(obj: Record, priority: string[]): string | null { for (const k of priority) { const v = obj[k]; if (typeof v === "string" && v.length > 0) return v; } return null; } /** Best-effort match count from a Grep tool response, checking a few * known response shapes (array, `.matches`, `.files`, `.count`, `.numFiles`). * * Grep results have varied over time and by output mode, so each known shape is * probed in turn and the first that fits wins. * @param response The `tool_response` value (untyped). * @returns The match / file count, or null when no known shape applies. */ function countGrepMatches(response: unknown): number | null { if (!response) return null; // Plain array of matches. if (Array.isArray(response)) return response.length; const r = obj(response); if (!r) return null; // Object wrappers seen across Grep output modes. if (Array.isArray(r.matches)) return r.matches.length; if (Array.isArray(r.files)) return r.files.length; if (typeof r.count === "number") return r.count; if (typeof r.numFiles === "number") return r.numFiles; return null; } /** Best-effort file count from a Glob tool response (array, `.files`, or `.paths`). * @param response The `tool_response` value (untyped). * @returns The number of matched files, or null when no known shape applies. */ function countFiles(response: unknown): number | null { if (Array.isArray(response)) return response.length; const r = obj(response); if (!r) return null; if (Array.isArray(r.files)) return r.files.length; if (Array.isArray(r.paths)) return r.paths.length; return null; }