import { useMemo, useState } from "react"; import type { TranscriptEntry } from "../../adapters"; import { MarkdownBody } from "../MarkdownBody"; import { cn, formatTokens } from "../../lib/utils"; import { Check, ChevronDown, ChevronRight, CircleAlert, 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; collapseStdout?: boolean; emptyMessage?: string; className?: string; thinkingClassName?: 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: "activity"; ts: string; activityId?: string; name: string; status: "running" | "completed"; } | { type: "command_group"; ts: string; endTs?: string; items: Array<{ ts: string; endTs?: string; input: unknown; result?: string; isError?: boolean; status: "running" | "completed" | "error"; }>; } | { type: "stdout"; ts: string; text: string; } | { 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 humanizeLabel(value: string): string { return value .replace(/[_-]+/g, " ") .trim() .replace(/\b\w/g, (char) => char.toUpperCase()); } function stripWrappedShell(command: string): string { const trimmed = compactWhitespace(command); const shellWrapped = trimmed.match(/^(?:(?:\/bin\/)?(?:zsh|bash|sh)|cmd(?:\.exe)?(?:\s+\/d)?(?:\s+\/s)?(?:\s+\/c)?)\s+(?:-lc|\/c)\s+(.+)$/i); const inner = shellWrapped?.[1] ?? trimmed; const quoted = inner.match(/^(['"])([\s\S]*)\1$/); return compactWhitespace(quoted?.[2] ?? inner); } 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") { const normalized = isCommandTool(name, input) ? stripWrappedShell(input) : compactWhitespace(input); return truncate(normalized, compactMax); } const record = asRecord(input); if (!record) { const serialized = compactWhitespace(formatUnknown(input)); return serialized ? truncate(serialized, compactMax) : `Inspect ${name} input`; } const command = typeof record.command === "string" ? record.command : typeof record.cmd === "string" ? record.cmd : null; if (command && isCommandTool(name, record)) { return truncate(stripWrappedShell(command), compactMax); } 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 parseStructuredToolResult(result: string | undefined) { if (!result) return null; const lines = result.split(/\r?\n/); const metadata = new Map(); let bodyStartIndex = lines.findIndex((line) => line.trim() === ""); if (bodyStartIndex === -1) bodyStartIndex = lines.length; for (let index = 0; index < bodyStartIndex; index += 1) { const match = lines[index]?.match(/^([a-z_]+):\s*(.+)$/i); if (match) { metadata.set(match[1].toLowerCase(), compactWhitespace(match[2])); } } const body = lines.slice(Math.min(bodyStartIndex + 1, lines.length)) .map((line) => compactWhitespace(line)) .filter(Boolean) .join("\n"); return { command: metadata.get("command") ?? null, status: metadata.get("status") ?? null, exitCode: metadata.get("exit_code") ?? null, body, }; } function isCommandTool(name: string, input: unknown): boolean { if (name === "command_execution" || name === "shell" || name === "shellToolCall" || name === "bash") { return true; } if (typeof input === "string") { return /\b(?:bash|zsh|sh|cmd|powershell)\b/i.test(input); } const record = asRecord(input); return Boolean(record && (typeof record.command === "string" || typeof record.cmd === "string")); } function displayToolName(name: string, input: unknown): string { if (isCommandTool(name, input)) return "Executing command"; return humanizeLabel(name); } function summarizeToolResult(result: string | undefined, isError: boolean | undefined, density: TranscriptDensity): string { if (!result) return isError ? "Tool failed" : "Waiting for result"; const structured = parseStructuredToolResult(result); if (structured) { if (structured.body) { return truncate(structured.body.split("\n")[0] ?? structured.body, density === "compact" ? 84 : 140); } if (structured.status === "completed") return "Completed"; if (structured.status === "failed" || structured.status === "error") { return structured.exitCode ? `Failed with exit code ${structured.exitCode}` : "Failed"; } } 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 parseSystemActivity(text: string): { activityId?: string; name: string; status: "running" | "completed" } | null { const match = text.match(/^item (started|completed):\s*([a-z0-9_-]+)(?:\s+\(id=([^)]+)\))?$/i); if (!match) return null; return { status: match[1].toLowerCase() === "started" ? "running" : "completed", name: humanizeLabel(match[2] ?? "Activity"), activityId: match[3] || undefined, }; } function shouldHideNiceModeStderr(text: string): boolean { const normalized = compactWhitespace(text).toLowerCase(); return normalized.startsWith("[paperclip] skipping saved session resume"); } function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] { const grouped: TranscriptBlock[] = []; let pending: Array["items"][number]> = []; let groupTs: string | null = null; let groupEndTs: string | undefined; const flush = () => { if (pending.length === 0 || !groupTs) return; grouped.push({ type: "command_group", ts: groupTs, endTs: groupEndTs, items: pending, }); pending = []; groupTs = null; groupEndTs = undefined; }; for (const block of blocks) { if (block.type === "tool" && isCommandTool(block.name, block.input)) { if (!groupTs) { groupTs = block.ts; } groupEndTs = block.endTs ?? block.ts; pending.push({ ts: block.ts, endTs: block.endTs, input: block.input, result: block.result, isError: block.isError, status: block.status, }); continue; } flush(); grouped.push(block); } flush(); return grouped; } export function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] { const blocks: TranscriptBlock[] = []; const pendingToolBlocks = new Map>(); const pendingActivityBlocks = 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: displayToolName(entry.name, entry.input), 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: entry.toolName ?? "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") { blocks.push({ type: "event", ts: entry.ts, label: "result", tone: entry.isError ? "error" : "info", text: entry.text.trim() || entry.errors[0] || (entry.isError ? "Run failed" : "Completed"), }); continue; } if (entry.kind === "stderr") { if (shouldHideNiceModeStderr(entry.text)) { continue; } blocks.push({ type: "event", ts: entry.ts, label: "stderr", tone: "error", text: entry.text, }); continue; } if (entry.kind === "system") { if (compactWhitespace(entry.text).toLowerCase() === "turn started") { continue; } const activity = parseSystemActivity(entry.text); if (activity) { const existing = activity.activityId ? pendingActivityBlocks.get(activity.activityId) : undefined; if (existing) { existing.status = activity.status; existing.ts = entry.ts; if (activity.status === "completed" && activity.activityId) { pendingActivityBlocks.delete(activity.activityId); } } else { const block: Extract = { type: "activity", ts: entry.ts, activityId: activity.activityId, name: activity.name, status: activity.status, }; blocks.push(block); if (activity.status === "running" && activity.activityId) { pendingActivityBlocks.set(activity.activityId, block); } } continue; } blocks.push({ type: "event", ts: entry.ts, label: "system", tone: "warn", text: entry.text, }); continue; } const activeCommandBlock = [...blocks].reverse().find( (block): block is Extract => block.type === "tool" && block.status === "running" && isCommandTool(block.name, block.input), ); if (activeCommandBlock) { activeCommandBlock.result = activeCommandBlock.result ? `${activeCommandBlock.result}${activeCommandBlock.result.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`}` : entry.text; continue; } if (previous?.type === "stdout") { previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`; previous.ts = entry.ts; } else { blocks.push({ type: "stdout", ts: entry.ts, text: entry.text, }); } } return groupCommandBlocks(blocks); } function TranscriptMessageBlock({ block, density, }: { block: Extract; density: TranscriptDensity; }) { const isAssistant = block.role === "assistant"; const compact = density === "compact"; return (
{!isAssistant && (
User
)} *:first-child]:mt-0 [&>*:last-child]:mb-0", compact ? "text-xs leading-5 text-foreground/85" : "text-sm", )} > {block.text} {block.streaming && (
Streaming
)}
); } function TranscriptThinkingBlock({ block, density, className, }: { block: Extract; density: TranscriptDensity; className?: string; }) { return ( *:first-child]:mt-0 [&>*:last-child]:mb-0", density === "compact" ? "text-[11px] leading-5" : "text-sm leading-6", className, )} > {block.text} ); } function TranscriptToolCard({ block, density, }: { block: Extract; density: TranscriptDensity; }) { const [open, setOpen] = useState(block.status === "error"); const compact = density === "compact"; const parsedResult = parseStructuredToolResult(block.result); const statusLabel = block.status === "running" ? "Running" : block.status === "error" ? "Errored" : "Completed"; const statusTone = block.status === "running" ? "text-cyan-700 dark:text-cyan-300" : block.status === "error" ? "text-red-700 dark:text-red-300" : "text-emerald-700 dark:text-emerald-300"; const detailsClass = cn( "space-y-3", block.status === "error" && "rounded-xl border border-red-500/20 bg-red-500/[0.06] p-3", ); const iconClass = cn( "mt-0.5 h-3.5 w-3.5 shrink-0", block.status === "error" ? "text-red-600 dark:text-red-300" : block.status === "completed" ? "text-emerald-600 dark:text-emerald-300" : "text-cyan-600 dark:text-cyan-300", ); const summary = block.status === "running" ? summarizeToolInput(block.name, block.input, density) : block.status === "completed" && parsedResult?.body ? truncate(parsedResult.body.split("\n")[0] ?? parsedResult.body, compact ? 84 : 140) : summarizeToolResult(block.result, block.isError, density); return (
{block.status === "error" ? ( ) : block.status === "completed" ? ( ) : ( )}
{block.name} {statusLabel}
{summary}
{open && (
Input
                  {formatToolPayload(block.input) || ""}
                
Result
                  {block.result ? formatToolPayload(block.result) : "Waiting for result..."}
                
)}
); } function hasSelectedText() { if (typeof window === "undefined") return false; return (window.getSelection()?.toString().length ?? 0) > 0; } function TranscriptCommandGroup({ block, density, }: { block: Extract; density: TranscriptDensity; }) { const [open, setOpen] = useState(false); const compact = density === "compact"; const runningItem = [...block.items].reverse().find((item) => item.status === "running"); const latestItem = block.items[block.items.length - 1] ?? null; const hasError = block.items.some((item) => item.status === "error"); const isRunning = Boolean(runningItem); const showExpandedErrorState = open && hasError; const title = isRunning ? "Executing command" : block.items.length === 1 ? "Executed command" : `Executed ${block.items.length} commands`; const subtitle = runningItem ? summarizeToolInput("command_execution", runningItem.input, density) : null; const statusTone = isRunning ? "text-cyan-700 dark:text-cyan-300" : "text-foreground/70"; return (
{ if (hasSelectedText()) return; setOpen((value) => !value); }} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setOpen((value) => !value); } }} >
{block.items.slice(0, Math.min(block.items.length, 3)).map((_, index) => ( 0 && "-ml-1.5", isRunning ? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300" : "border-border/70 bg-background text-foreground/55", isRunning && "animate-pulse", )} > ))}
{title}
{subtitle && (
{subtitle}
)} {!subtitle && latestItem?.status === "error" && open && (
Command failed
)}
{open && (
{block.items.map((item, index) => (
{summarizeToolInput("command_execution", item.input, density)}
{item.result && (
                  {formatToolPayload(item.result)}
                
)}
))}
)}
); } function TranscriptActivityRow({ block, density, }: { block: Extract; density: TranscriptDensity; }) { return (
{block.status === "completed" ? ( ) : ( )}
{block.name}
); } function TranscriptEventRow({ block, density, }: { block: Extract; density: TranscriptDensity; }) { const compact = density === "compact"; const toneClasses = block.tone === "error" ? "rounded-xl border border-red-500/20 bg-red-500/[0.06] p-3 text-red-700 dark:text-red-300" : block.tone === "warn" ? "text-amber-700 dark:text-amber-300" : block.tone === "info" ? "text-sky-700 dark:text-sky-300" : "text-foreground/75"; return (
{block.tone === "error" ? ( ) : block.tone === "warn" ? ( ) : ( )}
{block.label === "result" && block.tone !== "error" ? (
{block.text}
) : (
{block.label} {block.text ? {block.text} : null}
)} {block.detail && (
              {block.detail}
            
)}
); } function TranscriptStdoutRow({ block, density, collapseByDefault, }: { block: Extract; density: TranscriptDensity; collapseByDefault: boolean; }) { const [open, setOpen] = useState(!collapseByDefault); return (
stdout
{open && (
          {block.text}
        
)}
); } function RawTranscriptView({ entries, density, }: { entries: TranscriptEntry[]; density: TranscriptDensity; }) { const compact = density === "compact"; return (
{entries.map((entry, idx) => (
{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, collapseStdout = false, emptyMessage = "No transcript yet.", className, thinkingClassName, }: 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 === "command_group" && } {block.type === "stdout" && ( )} {block.type === "activity" && } {block.type === "event" && }
))}
); }