When tool_result entries arrive without a matching tool_call, the transcript was showing generic 'tool' as the name. Now pl-local parses toolName from tool_execution_end events and passes it through, so the UI can display the actual tool name (e.g., 'bash', 'Read', 'Ls') instead of 'tool'.
1016 lines
33 KiB
TypeScript
1016 lines
33 KiB
TypeScript
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<string, unknown> | null {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
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<string, unknown>, 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<string, string>();
|
|
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<Extract<TranscriptBlock, { type: "command_group" }>["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<string, Extract<TranscriptBlock, { type: "tool" }>>();
|
|
const pendingActivityBlocks = new Map<string, Extract<TranscriptBlock, { type: "activity" }>>();
|
|
|
|
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<TranscriptBlock, { type: "tool" }> = {
|
|
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<TranscriptBlock, { type: "tool" }> => 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<TranscriptBlock, { type: "activity" }> = {
|
|
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<TranscriptBlock, { type: "tool" }> =>
|
|
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<TranscriptBlock, { type: "message" }>;
|
|
density: TranscriptDensity;
|
|
}) {
|
|
const isAssistant = block.role === "assistant";
|
|
const compact = density === "compact";
|
|
|
|
return (
|
|
<div>
|
|
{!isAssistant && (
|
|
<div className="mb-1.5 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
|
<User className={compact ? "h-3.5 w-3.5" : "h-4 w-4"} />
|
|
<span>User</span>
|
|
</div>
|
|
)}
|
|
<MarkdownBody
|
|
className={cn(
|
|
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
|
compact ? "text-xs leading-5 text-foreground/85" : "text-sm",
|
|
)}
|
|
>
|
|
{block.text}
|
|
</MarkdownBody>
|
|
{block.streaming && (
|
|
<div className="mt-2 inline-flex items-center gap-1 text-[10px] font-medium italic text-muted-foreground">
|
|
<span className="relative flex h-1.5 w-1.5">
|
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-70" />
|
|
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
|
</span>
|
|
Streaming
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TranscriptThinkingBlock({
|
|
block,
|
|
density,
|
|
className,
|
|
}: {
|
|
block: Extract<TranscriptBlock, { type: "thinking" }>;
|
|
density: TranscriptDensity;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<MarkdownBody
|
|
className={cn(
|
|
"italic text-foreground/70 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
|
density === "compact" ? "text-[11px] leading-5" : "text-sm leading-6",
|
|
className,
|
|
)}
|
|
>
|
|
{block.text}
|
|
</MarkdownBody>
|
|
);
|
|
}
|
|
|
|
function TranscriptToolCard({
|
|
block,
|
|
density,
|
|
}: {
|
|
block: Extract<TranscriptBlock, { type: "tool" }>;
|
|
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 (
|
|
<div className={cn(block.status === "error" && "rounded-xl border border-red-500/20 bg-red-500/[0.04] p-3")}>
|
|
<div className="flex items-start gap-2">
|
|
{block.status === "error" ? (
|
|
<CircleAlert className={iconClass} />
|
|
) : block.status === "completed" ? (
|
|
<Check className={iconClass} />
|
|
) : (
|
|
<Wrench className={iconClass} />
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
|
{block.name}
|
|
</span>
|
|
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em]", statusTone)}>
|
|
{statusLabel}
|
|
</span>
|
|
</div>
|
|
<div className={cn("mt-1 break-words text-foreground/80", compact ? "text-xs" : "text-sm")}>
|
|
{summary}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="mt-0.5 inline-flex h-5 w-5 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
|
onClick={() => setOpen((value) => !value)}
|
|
aria-label={open ? "Collapse tool details" : "Expand tool details"}
|
|
>
|
|
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
{open && (
|
|
<div className="mt-3">
|
|
<div className={detailsClass}>
|
|
<div className={cn("grid gap-3", compact ? "grid-cols-1" : "lg:grid-cols-2")}>
|
|
<div>
|
|
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
|
Input
|
|
</div>
|
|
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-foreground/80">
|
|
{formatToolPayload(block.input) || "<empty>"}
|
|
</pre>
|
|
</div>
|
|
<div>
|
|
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
|
Result
|
|
</div>
|
|
<pre className={cn(
|
|
"overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px]",
|
|
block.status === "error" ? "text-red-700 dark:text-red-300" : "text-foreground/80",
|
|
)}>
|
|
{block.result ? formatToolPayload(block.result) : "Waiting for result..."}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function hasSelectedText() {
|
|
if (typeof window === "undefined") return false;
|
|
return (window.getSelection()?.toString().length ?? 0) > 0;
|
|
}
|
|
|
|
function TranscriptCommandGroup({
|
|
block,
|
|
density,
|
|
}: {
|
|
block: Extract<TranscriptBlock, { type: "command_group" }>;
|
|
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 (
|
|
<div className={cn(showExpandedErrorState && "rounded-xl border border-red-500/20 bg-red-500/[0.04] p-3")}>
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
className={cn("flex cursor-pointer gap-2", subtitle ? "items-start" : "items-center")}
|
|
onClick={() => {
|
|
if (hasSelectedText()) return;
|
|
setOpen((value) => !value);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
setOpen((value) => !value);
|
|
}
|
|
}}
|
|
>
|
|
<div className={cn("flex shrink-0 items-center", subtitle && "mt-0.5")}>
|
|
{block.items.slice(0, Math.min(block.items.length, 3)).map((_, index) => (
|
|
<span
|
|
key={index}
|
|
className={cn(
|
|
"inline-flex h-6 w-6 items-center justify-center rounded-full border shadow-sm",
|
|
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",
|
|
)}
|
|
>
|
|
<TerminalSquare className="h-3.5 w-3.5" />
|
|
</span>
|
|
))}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-[11px] font-semibold uppercase leading-none tracking-[0.1em] text-muted-foreground/70">
|
|
{title}
|
|
</div>
|
|
{subtitle && (
|
|
<div className={cn("mt-1 break-words font-mono text-foreground/85", compact ? "text-xs" : "text-sm")}>
|
|
{subtitle}
|
|
</div>
|
|
)}
|
|
{!subtitle && latestItem?.status === "error" && open && (
|
|
<div className={cn("mt-1", compact ? "text-xs" : "text-sm", statusTone)}>
|
|
Command failed
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"inline-flex h-5 w-5 items-center justify-center text-muted-foreground transition-colors hover:text-foreground",
|
|
subtitle && "mt-0.5",
|
|
)}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setOpen((value) => !value);
|
|
}}
|
|
aria-label={open ? "Collapse command details" : "Expand command details"}
|
|
>
|
|
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
{open && (
|
|
<div className={cn("mt-3 space-y-3", hasError && "rounded-xl border border-red-500/20 bg-red-500/[0.06] p-3")}>
|
|
{block.items.map((item, index) => (
|
|
<div key={`${item.ts}-${index}`} className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className={cn(
|
|
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border",
|
|
item.status === "error"
|
|
? "border-red-500/25 bg-red-500/[0.08] text-red-600 dark:text-red-300"
|
|
: item.status === "running"
|
|
? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300"
|
|
: "border-border/70 bg-background text-foreground/55",
|
|
)}>
|
|
<TerminalSquare className="h-3 w-3" />
|
|
</span>
|
|
<span className={cn("font-mono break-all", compact ? "text-[11px]" : "text-xs")}>
|
|
{summarizeToolInput("command_execution", item.input, density)}
|
|
</span>
|
|
</div>
|
|
{item.result && (
|
|
<pre className={cn(
|
|
"overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px]",
|
|
item.status === "error" ? "text-red-700 dark:text-red-300" : "text-foreground/80",
|
|
)}>
|
|
{formatToolPayload(item.result)}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TranscriptActivityRow({
|
|
block,
|
|
density,
|
|
}: {
|
|
block: Extract<TranscriptBlock, { type: "activity" }>;
|
|
density: TranscriptDensity;
|
|
}) {
|
|
return (
|
|
<div className="flex items-start gap-2">
|
|
{block.status === "completed" ? (
|
|
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-300" />
|
|
) : (
|
|
<span className="relative mt-1 flex h-2.5 w-2.5 shrink-0">
|
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-cyan-400 opacity-70" />
|
|
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-cyan-500" />
|
|
</span>
|
|
)}
|
|
<div className={cn(
|
|
"break-words text-foreground/80",
|
|
density === "compact" ? "text-xs leading-5" : "text-sm leading-6",
|
|
)}>
|
|
{block.name}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TranscriptEventRow({
|
|
block,
|
|
density,
|
|
}: {
|
|
block: Extract<TranscriptBlock, { type: "event" }>;
|
|
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 (
|
|
<div className={toneClasses}>
|
|
<div className="flex items-start gap-2">
|
|
{block.tone === "error" ? (
|
|
<CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
) : block.tone === "warn" ? (
|
|
<TerminalSquare className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
) : (
|
|
<span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-current/50" />
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
{block.label === "result" && block.tone !== "error" ? (
|
|
<div className={cn("whitespace-pre-wrap break-words text-sky-700 dark:text-sky-300", compact ? "text-[11px]" : "text-xs")}>
|
|
{block.text}
|
|
</div>
|
|
) : (
|
|
<div className={cn("whitespace-pre-wrap break-words", compact ? "text-[11px]" : "text-xs")}>
|
|
<span className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/70">
|
|
{block.label}
|
|
</span>
|
|
{block.text ? <span className="ml-2">{block.text}</span> : null}
|
|
</div>
|
|
)}
|
|
{block.detail && (
|
|
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-foreground/75">
|
|
{block.detail}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TranscriptStdoutRow({
|
|
block,
|
|
density,
|
|
collapseByDefault,
|
|
}: {
|
|
block: Extract<TranscriptBlock, { type: "stdout" }>;
|
|
density: TranscriptDensity;
|
|
collapseByDefault: boolean;
|
|
}) {
|
|
const [open, setOpen] = useState(!collapseByDefault);
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
|
stdout
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="inline-flex h-5 w-5 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
|
onClick={() => setOpen((value) => !value)}
|
|
aria-label={open ? "Collapse stdout" : "Expand stdout"}
|
|
>
|
|
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
{open && (
|
|
<pre className={cn(
|
|
"mt-2 overflow-x-auto whitespace-pre-wrap break-words font-mono text-foreground/80",
|
|
density === "compact" ? "text-[11px]" : "text-xs",
|
|
)}>
|
|
{block.text}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RawTranscriptView({
|
|
entries,
|
|
density,
|
|
}: {
|
|
entries: TranscriptEntry[];
|
|
density: TranscriptDensity;
|
|
}) {
|
|
const compact = density === "compact";
|
|
return (
|
|
<div className={cn("font-mono", compact ? "space-y-1 text-[11px]" : "space-y-1.5 text-xs")}>
|
|
{entries.map((entry, idx) => (
|
|
<div
|
|
key={`${entry.kind}-${entry.ts}-${idx}`}
|
|
className={cn(
|
|
"grid gap-x-3",
|
|
"grid-cols-[auto_1fr]",
|
|
)}
|
|
>
|
|
<span className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
|
{entry.kind}
|
|
</span>
|
|
<pre className="min-w-0 whitespace-pre-wrap break-words text-foreground/80">
|
|
{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}
|
|
</pre>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className={cn("rounded-2xl border border-dashed border-border/70 bg-background/40 p-4 text-sm text-muted-foreground", className)}>
|
|
{emptyMessage}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (mode === "raw") {
|
|
return (
|
|
<div className={className}>
|
|
<RawTranscriptView entries={visibleEntries} density={density} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn("space-y-3", className)}>
|
|
{visibleBlocks.map((block, index) => (
|
|
<div
|
|
key={`${block.type}-${block.ts}-${index}`}
|
|
className={cn(index === visibleBlocks.length - 1 && streaming && "animate-in fade-in slide-in-from-bottom-1 duration-300")}
|
|
>
|
|
{block.type === "message" && <TranscriptMessageBlock block={block} density={density} />}
|
|
{block.type === "thinking" && (
|
|
<TranscriptThinkingBlock block={block} density={density} className={thinkingClassName} />
|
|
)}
|
|
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
|
|
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
|
|
{block.type === "stdout" && (
|
|
<TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} />
|
|
)}
|
|
{block.type === "activity" && <TranscriptActivityRow block={block} density={density} />}
|
|
{block.type === "event" && <TranscriptEventRow block={block} density={density} />}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|