import { useEffect, useMemo, useState, type ReactNode } from "react"; import type { TranscriptEntry } from "../../adapters"; import { MarkdownBody } from "../MarkdownBody"; import { cn, formatTokens, relativeTime } from "../../lib/utils"; import { Bot, BrainCircuit, ChevronDown, ChevronRight, CircleAlert, Info, TerminalSquare, User, Wrench, } from "lucide-react"; export type TranscriptMode = "nice" | "raw"; export type TranscriptDensity = "comfortable" | "compact"; interface RunTranscriptViewProps { entries: TranscriptEntry[]; mode?: TranscriptMode; density?: TranscriptDensity; limit?: number; streaming?: boolean; emptyMessage?: string; className?: string; } type TranscriptBlock = | { type: "message"; role: "assistant" | "user"; ts: string; text: string; streaming: boolean; } | { type: "thinking"; ts: string; text: string; streaming: boolean; } | { type: "tool"; ts: string; endTs?: string; name: string; toolUseId?: string; input: unknown; result?: string; isError?: boolean; status: "running" | "completed" | "error"; } | { type: "event"; ts: string; label: string; tone: "info" | "warn" | "error" | "neutral"; text: string; detail?: string; }; function asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; } function compactWhitespace(value: string): string { return value.replace(/\s+/g, " ").trim(); } function truncate(value: string, max: number): string { return value.length > max ? `${value.slice(0, Math.max(0, max - 1))}…` : value; } function stripMarkdown(value: string): string { return compactWhitespace( value .replace(/```[\s\S]*?```/g, " code ") .replace(/`([^`]+)`/g, "$1") .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") .replace(/[*_#>-]/g, " "), ); } function formatTimestamp(ts: string): string { const date = new Date(ts); if (Number.isNaN(date.getTime())) return ts; return date.toLocaleTimeString("en-US", { hour12: false }); } function formatUnknown(value: unknown): string { if (typeof value === "string") return value; if (value === null || value === undefined) return ""; try { return JSON.stringify(value, null, 2); } catch { return String(value); } } function formatToolPayload(value: unknown): string { if (typeof value === "string") { try { return JSON.stringify(JSON.parse(value), null, 2); } catch { return value; } } return formatUnknown(value); } function extractToolUseId(input: unknown): string | undefined { const record = asRecord(input); if (!record) return undefined; const candidates = [ record.toolUseId, record.tool_use_id, record.callId, record.call_id, record.id, ]; for (const candidate of candidates) { if (typeof candidate === "string" && candidate.trim()) { return candidate; } } return undefined; } function summarizeRecord(record: Record, keys: string[]): string | null { for (const key of keys) { const value = record[key]; if (typeof value === "string" && value.trim()) { return truncate(compactWhitespace(value), 120); } } return null; } function summarizeToolInput(name: string, input: unknown, density: TranscriptDensity): string { const compactMax = density === "compact" ? 72 : 120; if (typeof input === "string") return truncate(compactWhitespace(input), compactMax); const record = asRecord(input); if (!record) { const serialized = compactWhitespace(formatUnknown(input)); return serialized ? truncate(serialized, compactMax) : `Inspect ${name} input`; } const direct = summarizeRecord(record, ["command", "cmd", "path", "filePath", "file_path", "query", "url", "prompt", "message"]) ?? summarizeRecord(record, ["pattern", "name", "title", "target", "tool"]) ?? null; if (direct) return truncate(direct, compactMax); if (Array.isArray(record.paths) && record.paths.length > 0) { const first = record.paths.find((value): value is string => typeof value === "string" && value.trim().length > 0); if (first) { return truncate(`${record.paths.length} paths, starting with ${first}`, compactMax); } } const keys = Object.keys(record); if (keys.length === 0) return `No ${name} input`; if (keys.length === 1) return truncate(`${keys[0]} payload`, compactMax); return truncate(`${keys.length} fields: ${keys.slice(0, 3).join(", ")}`, compactMax); } function summarizeToolResult(result: string | undefined, isError: boolean | undefined, density: TranscriptDensity): string { if (!result) return isError ? "Tool failed" : "Waiting for result"; const lines = result .split(/\r?\n/) .map((line) => compactWhitespace(line)) .filter(Boolean); const firstLine = lines[0] ?? result; return truncate(firstLine, density === "compact" ? 84 : 140); } function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] { const blocks: TranscriptBlock[] = []; const pendingToolBlocks = new Map>(); for (const entry of entries) { const previous = blocks[blocks.length - 1]; if (entry.kind === "assistant" || entry.kind === "user") { const isStreaming = streaming && entry.kind === "assistant" && entry.delta === true; if (previous?.type === "message" && previous.role === entry.kind) { previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`; previous.ts = entry.ts; previous.streaming = previous.streaming || isStreaming; } else { blocks.push({ type: "message", role: entry.kind, ts: entry.ts, text: entry.text, streaming: isStreaming, }); } continue; } if (entry.kind === "thinking") { const isStreaming = streaming && entry.delta === true; if (previous?.type === "thinking") { previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`; previous.ts = entry.ts; previous.streaming = previous.streaming || isStreaming; } else { blocks.push({ type: "thinking", ts: entry.ts, text: entry.text, streaming: isStreaming, }); } continue; } if (entry.kind === "tool_call") { const toolBlock: Extract = { type: "tool", ts: entry.ts, name: entry.name, toolUseId: entry.toolUseId ?? extractToolUseId(entry.input), input: entry.input, status: "running", }; blocks.push(toolBlock); if (toolBlock.toolUseId) { pendingToolBlocks.set(toolBlock.toolUseId, toolBlock); } continue; } if (entry.kind === "tool_result") { const matched = pendingToolBlocks.get(entry.toolUseId) ?? [...blocks].reverse().find((block): block is Extract => block.type === "tool" && block.status === "running"); if (matched) { matched.result = entry.content; matched.isError = entry.isError; matched.status = entry.isError ? "error" : "completed"; matched.endTs = entry.ts; pendingToolBlocks.delete(entry.toolUseId); } else { blocks.push({ type: "tool", ts: entry.ts, endTs: entry.ts, name: "tool", toolUseId: entry.toolUseId, input: null, result: entry.content, isError: entry.isError, status: entry.isError ? "error" : "completed", }); } continue; } if (entry.kind === "init") { blocks.push({ type: "event", ts: entry.ts, label: "init", tone: "info", text: `Model ${entry.model}${entry.sessionId ? ` • session ${entry.sessionId}` : ""}`, }); continue; } if (entry.kind === "result") { const summary = `tokens in ${formatTokens(entry.inputTokens)} • out ${formatTokens(entry.outputTokens)} • cached ${formatTokens(entry.cachedTokens)} • $${entry.costUsd.toFixed(6)}`; const detailParts = [ entry.text.trim(), entry.subtype ? `subtype=${entry.subtype}` : "", entry.errors.length > 0 ? `errors=${entry.errors.join(" | ")}` : "", ].filter(Boolean); blocks.push({ type: "event", ts: entry.ts, label: "result", tone: entry.isError ? "error" : "info", text: summary, detail: detailParts.join("\n\n") || undefined, }); continue; } if (entry.kind === "stderr") { blocks.push({ type: "event", ts: entry.ts, label: "stderr", tone: "error", text: entry.text, }); continue; } if (entry.kind === "system") { blocks.push({ type: "event", ts: entry.ts, label: "system", tone: "warn", text: entry.text, }); continue; } blocks.push({ type: "event", ts: entry.ts, label: "stdout", tone: "neutral", text: entry.text, }); } return blocks; } function TranscriptDisclosure({ icon, label, tone, summary, timestamp, defaultOpen, compact, children, }: { icon: typeof BrainCircuit; label: string; tone: "thinking" | "tool"; summary: string; timestamp: string; defaultOpen: boolean; compact: boolean; children: ReactNode; }) { const [open, setOpen] = useState(defaultOpen); const [touched, setTouched] = useState(false); useEffect(() => { if (!touched) { setOpen(defaultOpen); } }, [defaultOpen, touched]); const Icon = icon; const borderTone = tone === "thinking" ? "border-amber-500/25 bg-amber-500/[0.07]" : "border-cyan-500/25 bg-cyan-500/[0.07]"; const iconTone = tone === "thinking" ? "text-amber-700 dark:text-amber-300" : "text-cyan-700 dark:text-cyan-300"; return (
{open &&
{children}
}
); } function TranscriptMessageBlock({ block, density, }: { block: Extract; density: TranscriptDensity; }) { const isAssistant = block.role === "assistant"; const Icon = isAssistant ? Bot : User; const panelTone = isAssistant ? "border-emerald-500/25 bg-emerald-500/[0.08]" : "border-violet-500/20 bg-violet-500/[0.07]"; const iconTone = isAssistant ? "text-emerald-700 dark:text-emerald-300" : "text-violet-700 dark:text-violet-300"; const compact = density === "compact"; return (
{isAssistant ? "Assistant" : "User"} {formatTimestamp(block.ts)} {block.streaming && ( Streaming )}
{compact ? (
{truncate(stripMarkdown(block.text), 360)}
) : ( {block.text} )}
); } function TranscriptThinkingBlock({ block, density, }: { block: Extract; density: TranscriptDensity; }) { const compact = density === "compact"; return (
{block.text}
); } function TranscriptToolCard({ block, density, }: { block: Extract; density: TranscriptDensity; }) { const compact = density === "compact"; const statusLabel = block.status === "running" ? "Running" : block.status === "error" ? "Errored" : "Completed"; const statusTone = block.status === "running" ? "border-cyan-500/25 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300" : block.status === "error" ? "border-red-500/25 bg-red-500/10 text-red-700 dark:text-red-300" : "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; return (
{statusLabel} {block.toolUseId && ( {truncate(block.toolUseId, compact ? 24 : 40)} )}
Input
              {formatToolPayload(block.input) || ""}
            
Result
              {block.result ? formatToolPayload(block.result) : "Waiting for result..."}
            
); } function TranscriptEventRow({ block, density, }: { block: Extract; density: TranscriptDensity; }) { const compact = density === "compact"; const toneClasses = block.tone === "error" ? "border-red-500/20 bg-red-500/[0.06] text-red-700 dark:text-red-300" : block.tone === "warn" ? "border-amber-500/20 bg-amber-500/[0.06] text-amber-700 dark:text-amber-300" : block.tone === "info" ? "border-sky-500/20 bg-sky-500/[0.06] text-sky-700 dark:text-sky-300" : "border-border/70 bg-background/70 text-foreground/75"; return (
{block.tone === "error" ? ( ) : block.tone === "warn" ? ( ) : ( )}
{block.label} {compact ? relativeTime(block.ts) : formatTimestamp(block.ts)}
{block.text}
{block.detail && (
              {block.detail}
            
)}
); } function RawTranscriptView({ entries, density, }: { entries: TranscriptEntry[]; density: TranscriptDensity; }) { const compact = density === "compact"; return (
{entries.map((entry, idx) => (
{formatTimestamp(entry.ts)} {entry.kind}
            {entry.kind === "tool_call"
              ? `${entry.name}\n${formatToolPayload(entry.input)}`
              : entry.kind === "tool_result"
                ? formatToolPayload(entry.content)
                : entry.kind === "result"
                  ? `${entry.text}\n${formatTokens(entry.inputTokens)} / ${formatTokens(entry.outputTokens)} / $${entry.costUsd.toFixed(6)}`
                  : entry.kind === "init"
                    ? `model=${entry.model}${entry.sessionId ? ` session=${entry.sessionId}` : ""}`
                    : entry.text}
          
))}
); } export function RunTranscriptView({ entries, mode = "nice", density = "comfortable", limit, streaming = false, emptyMessage = "No transcript yet.", className, }: RunTranscriptViewProps) { const blocks = useMemo(() => normalizeTranscript(entries, streaming), [entries, streaming]); const visibleBlocks = limit ? blocks.slice(-limit) : blocks; const visibleEntries = limit ? entries.slice(-limit) : entries; if (entries.length === 0) { return (
{emptyMessage}
); } if (mode === "raw") { return (
); } return (
{visibleBlocks.map((block, index) => (
{block.type === "message" && } {block.type === "thinking" && } {block.type === "tool" && } {block.type === "event" && }
))}
); }