@ai-sdk/devtools 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2257 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import {
3
+ ChevronRight,
4
+ RefreshCw,
5
+ Trash2,
6
+ Copy,
7
+ Check,
8
+ Zap,
9
+ Wrench,
10
+ MessageSquare,
11
+ AlertCircle,
12
+ ChevronDown,
13
+ Settings,
14
+ Brain,
15
+ Loader2,
16
+ BarChart3,
17
+ } from 'lucide-react';
18
+ import { AISDKLogo } from '@/components/icons';
19
+ import { Button } from '@/components/ui/button';
20
+ import { Badge } from '@/components/ui/badge';
21
+ import { Card } from '@/components/ui/card';
22
+ import { ScrollArea } from '@/components/ui/scroll-area';
23
+ import {
24
+ Collapsible,
25
+ CollapsibleContent,
26
+ CollapsibleTrigger,
27
+ } from '@/components/ui/collapsible';
28
+ import {
29
+ Drawer,
30
+ DrawerContent,
31
+ DrawerHeader,
32
+ DrawerTitle,
33
+ DrawerTrigger,
34
+ } from '@/components/ui/drawer';
35
+ import {
36
+ Tooltip,
37
+ TooltipContent,
38
+ TooltipTrigger,
39
+ } from '@/components/ui/tooltip';
40
+
41
+ interface Run {
42
+ id: string;
43
+ started_at: string;
44
+ stepCount: number;
45
+ firstMessage?: string;
46
+ hasError?: boolean;
47
+ isInProgress?: boolean;
48
+ type?: 'generate' | 'stream';
49
+ }
50
+
51
+ interface Step {
52
+ id: string;
53
+ run_id: string;
54
+ step_number: number;
55
+ type: 'generate' | 'stream';
56
+ model_id: string;
57
+ provider: string | null;
58
+ started_at: string;
59
+ duration_ms: number | null;
60
+ input: string;
61
+ output: string | null;
62
+ usage: string | null;
63
+ error: string | null;
64
+ raw_request: string | null;
65
+ raw_response: string | null;
66
+ raw_chunks: string | null;
67
+ provider_options: string | null;
68
+ }
69
+
70
+ interface RunDetail {
71
+ run: { id: string; started_at: string; isInProgress?: boolean };
72
+ steps: Step[];
73
+ }
74
+
75
+ type StepType = 'tool-calls' | 'response' | 'error';
76
+
77
+ interface StepSummary {
78
+ type: StepType;
79
+ icon: 'wrench' | 'message' | 'alert';
80
+ label: string;
81
+ toolDetails?: string;
82
+ }
83
+
84
+ function summarizeToolCalls(toolCalls: any[]): {
85
+ label: string;
86
+ details: string;
87
+ } {
88
+ // Count occurrences of each tool
89
+ const counts = toolCalls.reduce((acc: Record<string, number>, call: any) => {
90
+ acc[call.toolName] = (acc[call.toolName] || 0) + 1;
91
+ return acc;
92
+ }, {});
93
+
94
+ const uniqueTools = Object.keys(counts);
95
+
96
+ // Format tool name with count if > 1
97
+ const formatTool = (name: string) => {
98
+ const count = counts[name] ?? 0;
99
+ return count > 1 ? `${name} (x${count})` : name;
100
+ };
101
+
102
+ const allToolsFormatted = uniqueTools.map(formatTool);
103
+
104
+ // Single tool type
105
+ if (uniqueTools.length === 1 && uniqueTools[0]) {
106
+ return {
107
+ label: formatTool(uniqueTools[0]),
108
+ details: '', // No tooltip needed
109
+ };
110
+ }
111
+
112
+ // Two tools - show both
113
+ if (uniqueTools.length === 2) {
114
+ return {
115
+ label: `${formatTool(uniqueTools[0])}, ${formatTool(uniqueTools[1])}`,
116
+ details: allToolsFormatted.join(', '),
117
+ };
118
+ }
119
+
120
+ // 3+ tools: show first two + ellipsis
121
+ return {
122
+ label: `${formatTool(uniqueTools[0])}, ${formatTool(uniqueTools[1])}, ...`,
123
+ details: allToolsFormatted.join(', '),
124
+ };
125
+ }
126
+
127
+ interface StepInputSummary {
128
+ type: 'user' | 'tool';
129
+ label: string;
130
+ fullText?: string; // For tooltip on user messages
131
+ toolDetails?: string; // For tooltip on tool results
132
+ }
133
+
134
+ function getStepInputSummary(
135
+ input: any,
136
+ isFirstStep: boolean,
137
+ ): StepInputSummary | null {
138
+ const prompt = input?.prompt;
139
+ if (!Array.isArray(prompt)) return null;
140
+
141
+ // For first step: always show the last user message (what kicked off this run)
142
+ if (isFirstStep) {
143
+ const userMessages = prompt.filter((msg: any) => msg.role === 'user');
144
+ const lastUserMessage = userMessages[userMessages.length - 1];
145
+
146
+ if (lastUserMessage) {
147
+ const content = lastUserMessage.content;
148
+ let text: string | null = null;
149
+
150
+ if (typeof content === 'string') {
151
+ text = content;
152
+ } else if (Array.isArray(content)) {
153
+ const textPart = content.find((part: any) => part.type === 'text');
154
+ if (textPart?.text) {
155
+ text = textPart.text;
156
+ }
157
+ }
158
+
159
+ if (text) {
160
+ return {
161
+ type: 'user',
162
+ label: `"${truncateText(text)}"`,
163
+ fullText: text,
164
+ };
165
+ }
166
+ }
167
+ return null;
168
+ }
169
+
170
+ // For subsequent steps: show the last tool results
171
+ const toolMessages = prompt.filter((msg: any) => msg.role === 'tool');
172
+ if (toolMessages.length > 0) {
173
+ // Only get tool names from the last tool message (the most recent tool results)
174
+ const lastToolMessage = toolMessages[toolMessages.length - 1];
175
+ const toolCounts: Record<string, number> = {};
176
+
177
+ const content = lastToolMessage.content;
178
+ if (Array.isArray(content)) {
179
+ for (const part of content) {
180
+ if (part.type === 'tool-result' && part.toolName) {
181
+ toolCounts[part.toolName] = (toolCounts[part.toolName] || 0) + 1;
182
+ }
183
+ }
184
+ }
185
+
186
+ const uniqueTools = Object.keys(toolCounts);
187
+ if (uniqueTools.length === 0) {
188
+ return { type: 'tool', label: 'tool result' };
189
+ }
190
+
191
+ // Format tool name with count if > 1
192
+ const formatTool = (name: string) => {
193
+ const count = toolCounts[name];
194
+ return count > 1 ? `${name} (x${count})` : name;
195
+ };
196
+
197
+ const allToolsFormatted = uniqueTools.map(formatTool);
198
+
199
+ if (uniqueTools.length === 1) {
200
+ return { type: 'tool', label: formatTool(uniqueTools[0]) };
201
+ }
202
+
203
+ if (uniqueTools.length === 2) {
204
+ return {
205
+ type: 'tool',
206
+ label: `${formatTool(uniqueTools[0])}, ${formatTool(uniqueTools[1])}`,
207
+ toolDetails: allToolsFormatted.join(', '),
208
+ };
209
+ }
210
+
211
+ // 3+ tools: show first two + ellipsis
212
+ return {
213
+ type: 'tool',
214
+ label: `${formatTool(uniqueTools[0])}, ${formatTool(uniqueTools[1])}, ...`,
215
+ toolDetails: allToolsFormatted.join(', '),
216
+ };
217
+ }
218
+
219
+ // Fall back to last user message
220
+ const userMessages = prompt.filter((msg: any) => msg.role === 'user');
221
+ const lastUserMessage = userMessages[userMessages.length - 1];
222
+
223
+ if (!lastUserMessage) return null;
224
+
225
+ // Extract text content from the message
226
+ const content = lastUserMessage.content;
227
+ let text: string | null = null;
228
+
229
+ if (typeof content === 'string') {
230
+ text = content;
231
+ } else if (Array.isArray(content)) {
232
+ const textPart = content.find((part: any) => part.type === 'text');
233
+ if (textPart?.text) {
234
+ text = textPart.text;
235
+ }
236
+ }
237
+
238
+ if (!text) return null;
239
+
240
+ return {
241
+ type: 'user',
242
+ label: `"${truncateText(text)}"`,
243
+ fullText: text,
244
+ };
245
+ }
246
+
247
+ function truncateText(text: string, maxLength: number = 30): string {
248
+ if (text.length <= maxLength) return text;
249
+ return text.slice(0, maxLength).trim() + '…';
250
+ }
251
+
252
+ function getStepSummary(output: any, error: string | null): StepSummary {
253
+ if (error) {
254
+ return { type: 'error', icon: 'alert', label: 'Error' };
255
+ }
256
+
257
+ // finishReason can be a string or an object with {unified, raw} properties
258
+ const finishReason =
259
+ typeof output?.finishReason === 'string'
260
+ ? output.finishReason
261
+ : output?.finishReason?.unified;
262
+
263
+ if (finishReason === 'tool-calls') {
264
+ const toolCalls =
265
+ output?.toolCalls ||
266
+ output?.content?.filter((p: any) => p.type === 'tool-call') ||
267
+ [];
268
+ const { label, details } = summarizeToolCalls(toolCalls);
269
+ return {
270
+ type: 'tool-calls',
271
+ icon: 'wrench',
272
+ label,
273
+ toolDetails: details || undefined,
274
+ };
275
+ }
276
+
277
+ return { type: 'response', icon: 'message', label: 'Response' };
278
+ }
279
+
280
+ function App() {
281
+ const [runs, setRuns] = useState<Run[]>([]);
282
+ const [selectedRun, setSelectedRun] = useState<RunDetail | null>(null);
283
+ const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set());
284
+ const [loading, setLoading] = useState(true);
285
+
286
+ const fetchRuns = async () => {
287
+ try {
288
+ const res = await fetch('/api/runs');
289
+ const data = await res.json();
290
+ setRuns(data);
291
+ } catch (error) {
292
+ console.error('Failed to fetch runs:', error);
293
+ } finally {
294
+ setLoading(false);
295
+ }
296
+ };
297
+
298
+ const handleClear = async () => {
299
+ await fetch('/api/clear', { method: 'POST' });
300
+ setRuns([]);
301
+ setSelectedRun(null);
302
+ };
303
+
304
+ // Initial fetch
305
+ useEffect(() => {
306
+ fetchRuns();
307
+ }, []);
308
+
309
+ // SSE connection for real-time updates
310
+ useEffect(() => {
311
+ let eventSource: EventSource | null = null;
312
+ let reconnectTimeout: NodeJS.Timeout | null = null;
313
+
314
+ const connect = () => {
315
+ eventSource = new EventSource('/api/events');
316
+
317
+ eventSource.addEventListener('connected', () => {
318
+ console.log('[DevTools] Connected to real-time updates');
319
+ });
320
+
321
+ eventSource.addEventListener('update', () => {
322
+ // Refresh the runs list when data changes
323
+ fetchRuns();
324
+ // Also refresh selected run if one is selected
325
+ if (selectedRun) {
326
+ fetch(`/api/runs/${selectedRun.run.id}`)
327
+ .then(res => res.json())
328
+ .then(data => {
329
+ if (data && !data.error) {
330
+ setSelectedRun(data);
331
+ }
332
+ })
333
+ .catch(() => {});
334
+ }
335
+ });
336
+
337
+ eventSource.addEventListener('heartbeat', () => {
338
+ // Connection is alive
339
+ });
340
+
341
+ eventSource.onerror = () => {
342
+ console.log('[DevTools] SSE connection lost, reconnecting...');
343
+ eventSource?.close();
344
+ // Reconnect after 2 seconds
345
+ reconnectTimeout = setTimeout(connect, 2000);
346
+ };
347
+ };
348
+
349
+ connect();
350
+
351
+ return () => {
352
+ eventSource?.close();
353
+ if (reconnectTimeout) {
354
+ clearTimeout(reconnectTimeout);
355
+ }
356
+ };
357
+ }, [selectedRun?.run.id]);
358
+
359
+ const selectRun = async (runId: string) => {
360
+ const res = await fetch(`/api/runs/${runId}`);
361
+ const data = await res.json();
362
+ setSelectedRun(data);
363
+ // Start with all steps collapsed
364
+ setExpandedSteps(new Set());
365
+ };
366
+
367
+ const toggleStep = (stepId: string) => {
368
+ setExpandedSteps(prev => {
369
+ const next = new Set(prev);
370
+ if (next.has(stepId)) {
371
+ next.delete(stepId);
372
+ } else {
373
+ next.add(stepId);
374
+ }
375
+ return next;
376
+ });
377
+ };
378
+
379
+ const formatDuration = (ms: number | null) => {
380
+ if (ms === null) return '-';
381
+ if (ms < 1000) return `${ms}ms`;
382
+ return `${(ms / 1000).toFixed(2)}s`;
383
+ };
384
+
385
+ const parseJson = (str: string | null) => {
386
+ if (!str) return null;
387
+ try {
388
+ return JSON.parse(str);
389
+ } catch {
390
+ return str;
391
+ }
392
+ };
393
+
394
+ const getFirstUserMessage = (steps: Step[]): string => {
395
+ const firstStep = steps[0];
396
+ if (!firstStep) return 'Empty run';
397
+ const input = parseJson(firstStep.input);
398
+ const userMsg = input?.prompt?.find((m: any) => m.role === 'user');
399
+ if (userMsg) {
400
+ const content =
401
+ typeof userMsg.content === 'string'
402
+ ? userMsg.content
403
+ : userMsg.content?.[0]?.text || '';
404
+ return content.slice(0, 50) + (content.length > 50 ? '...' : '');
405
+ }
406
+ return 'No user message';
407
+ };
408
+
409
+ const hasRunError = (steps: Step[]): boolean => {
410
+ return steps.some(s => s.error);
411
+ };
412
+
413
+ const getTotalDuration = (steps: Step[]): number => {
414
+ return steps.reduce((acc, s) => acc + (s.duration_ms || 0), 0);
415
+ };
416
+
417
+ const getTotalTokens = (
418
+ steps: Step[],
419
+ ): { input: InputTokenBreakdown; output: OutputTokenBreakdown } => {
420
+ return steps.reduce(
421
+ (acc, s) => {
422
+ const usage = parseJson(s.usage);
423
+ const inputBreakdown = getInputTokenBreakdown(usage?.inputTokens);
424
+ const outputBreakdown = getOutputTokenBreakdown(usage?.outputTokens);
425
+
426
+ const input: InputTokenBreakdown = {
427
+ total: acc.input.total + inputBreakdown.total,
428
+ };
429
+
430
+ // Only add breakdown properties if they exist in either accumulator or current breakdown
431
+ if (
432
+ acc.input.noCache !== undefined ||
433
+ inputBreakdown.noCache !== undefined
434
+ ) {
435
+ input.noCache =
436
+ (acc.input.noCache ?? 0) + (inputBreakdown.noCache ?? 0);
437
+ }
438
+ if (
439
+ acc.input.cacheRead !== undefined ||
440
+ inputBreakdown.cacheRead !== undefined
441
+ ) {
442
+ input.cacheRead =
443
+ (acc.input.cacheRead ?? 0) + (inputBreakdown.cacheRead ?? 0);
444
+ }
445
+ if (
446
+ acc.input.cacheWrite !== undefined ||
447
+ inputBreakdown.cacheWrite !== undefined
448
+ ) {
449
+ input.cacheWrite =
450
+ (acc.input.cacheWrite ?? 0) + (inputBreakdown.cacheWrite ?? 0);
451
+ }
452
+
453
+ const output: OutputTokenBreakdown = {
454
+ total: acc.output.total + outputBreakdown.total,
455
+ };
456
+
457
+ // Only add breakdown properties if they exist in either accumulator or current breakdown
458
+ if (
459
+ acc.output.text !== undefined ||
460
+ outputBreakdown.text !== undefined
461
+ ) {
462
+ output.text = (acc.output.text ?? 0) + (outputBreakdown.text ?? 0);
463
+ }
464
+ if (
465
+ acc.output.reasoning !== undefined ||
466
+ outputBreakdown.reasoning !== undefined
467
+ ) {
468
+ output.reasoning =
469
+ (acc.output.reasoning ?? 0) + (outputBreakdown.reasoning ?? 0);
470
+ }
471
+
472
+ return { input, output };
473
+ },
474
+ {
475
+ input: { total: 0 },
476
+ output: { total: 0 },
477
+ } as { input: InputTokenBreakdown; output: OutputTokenBreakdown },
478
+ );
479
+ };
480
+
481
+ return (
482
+ <div className="flex flex-col h-screen bg-background">
483
+ <header className="flex justify-between items-center px-5 py-3 border-b border-border bg-card">
484
+ <div className="flex items-center gap-2">
485
+ <AISDKLogo />
486
+ <span className="text-base font-medium text-muted-foreground">
487
+ DevTools
488
+ </span>
489
+ </div>
490
+ <div className="flex gap-2">
491
+ <Button
492
+ variant="ghost"
493
+ size="sm"
494
+ onClick={fetchRuns}
495
+ className="h-8 px-3 text-xs"
496
+ >
497
+ <RefreshCw className="size-3.5" />
498
+ Refresh
499
+ </Button>
500
+ <Button
501
+ variant="ghost"
502
+ size="sm"
503
+ onClick={handleClear}
504
+ className="h-8 px-3 text-xs text-destructive-foreground hover:bg-destructive/20"
505
+ >
506
+ <Trash2 className="size-3.5" />
507
+ Clear
508
+ </Button>
509
+ </div>
510
+ </header>
511
+
512
+ <div className="flex flex-1 overflow-hidden">
513
+ {/* Sidebar */}
514
+ <aside className="w-[300px] border-r border-border bg-sidebar flex flex-col">
515
+ <div className="px-4 py-3 border-b border-border">
516
+ <span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
517
+ Runs
518
+ </span>
519
+ </div>
520
+ <ScrollArea className="flex-1 overflow-hidden">
521
+ {loading ? (
522
+ <p className="p-4 text-muted-foreground text-sm">Loading...</p>
523
+ ) : runs.length === 0 ? (
524
+ <p className="p-4 text-muted-foreground text-sm">No runs yet</p>
525
+ ) : (
526
+ <div>
527
+ {runs.map(run => {
528
+ const isSelected = selectedRun?.run.id === run.id;
529
+ return (
530
+ <button
531
+ key={run.id}
532
+ className={`w-full text-left px-4 py-3 border-b border-border/50 transition-colors ${
533
+ isSelected ? 'bg-accent' : 'hover:bg-accent/50'
534
+ }`}
535
+ onClick={() => selectRun(run.id)}
536
+ >
537
+ <div className="flex items-start gap-2 mb-1.5 min-w-0">
538
+ {run.isInProgress ? (
539
+ <Loader2 className="size-3.5 text-blue-400 mt-0.5 shrink-0 animate-spin" />
540
+ ) : run.hasError ? (
541
+ <AlertCircle className="size-3.5 text-destructive-foreground mt-0.5 shrink-0" />
542
+ ) : (
543
+ <MessageSquare className="size-3.5 text-muted-foreground mt-0.5 shrink-0" />
544
+ )}
545
+ <span className="text-[13px] text-foreground leading-tight line-clamp-1 break-all">
546
+ {run.firstMessage || 'Loading...'}
547
+ </span>
548
+ </div>
549
+ <div className="flex items-center gap-2 ml-5.5 text-[11px] text-muted-foreground">
550
+ {run.type && (
551
+ <span
552
+ className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium ${
553
+ run.type === 'stream'
554
+ ? 'bg-blue-500/15 text-blue-400'
555
+ : 'bg-emerald-500/15 text-emerald-400'
556
+ }`}
557
+ >
558
+ {run.type === 'stream' && (
559
+ <Zap className="size-2.5" />
560
+ )}
561
+ {run.type}
562
+ </span>
563
+ )}
564
+ <span>
565
+ {run.stepCount}{' '}
566
+ {run.stepCount === 1 ? 'step' : 'steps'}
567
+ </span>
568
+ <span>·</span>
569
+ <span className="font-mono">
570
+ {new Date(run.started_at).toLocaleTimeString([], {
571
+ hour: '2-digit',
572
+ minute: '2-digit',
573
+ })}
574
+ </span>
575
+ </div>
576
+ </button>
577
+ );
578
+ })}
579
+ </div>
580
+ )}
581
+ </ScrollArea>
582
+ </aside>
583
+
584
+ {/* Main Content */}
585
+ <main className="flex-1 overflow-hidden">
586
+ <ScrollArea className="h-full">
587
+ {!selectedRun ? (
588
+ <div className="flex items-center justify-center min-h-[calc(100vh-57px)] text-muted-foreground">
589
+ <p className="text-sm">Select a run to view details</p>
590
+ </div>
591
+ ) : (
592
+ <div className="p-5">
593
+ {/* Run Header */}
594
+ <div className="flex items-center justify-between mb-5">
595
+ <div className="flex items-center gap-3">
596
+ <h2 className="text-sm font-medium text-foreground">
597
+ {getFirstUserMessage(selectedRun.steps)}
598
+ </h2>
599
+ {selectedRun.run.isInProgress && (
600
+ <Badge
601
+ variant="secondary"
602
+ className="text-[10px] h-5 gap-1.5 bg-blue-500/15 text-blue-400 border-blue-500/30"
603
+ >
604
+ <Loader2 className="size-3 animate-spin" />
605
+ In Progress
606
+ </Badge>
607
+ )}
608
+ {hasRunError(selectedRun.steps) && (
609
+ <Badge variant="destructive" className="text-[10px] h-5">
610
+ Error
611
+ </Badge>
612
+ )}
613
+ </div>
614
+ <div className="flex items-center text-xs text-muted-foreground">
615
+ <span>{selectedRun.steps.length} steps</span>
616
+ <span className="px-3 text-muted-foreground/30">·</span>
617
+ <span className="font-mono">
618
+ {formatDuration(getTotalDuration(selectedRun.steps))}
619
+ </span>
620
+ <span className="px-3 text-muted-foreground/30">·</span>
621
+ <Tooltip>
622
+ <TooltipTrigger asChild>
623
+ <span className="font-mono cursor-help">
624
+ input:{' '}
625
+ {formatInputTokens(
626
+ getTotalTokens(selectedRun.steps).input,
627
+ )}{' '}
628
+ <span className="text-muted-foreground/50">→</span>{' '}
629
+ output:{' '}
630
+ {formatOutputTokens(
631
+ getTotalTokens(selectedRun.steps).output,
632
+ )}
633
+ </span>
634
+ </TooltipTrigger>
635
+ <TooltipContent>
636
+ <TokenBreakdownTooltip
637
+ input={getTotalTokens(selectedRun.steps).input}
638
+ output={getTotalTokens(selectedRun.steps).output}
639
+ />
640
+ </TooltipContent>
641
+ </Tooltip>
642
+ <span className="px-3 text-muted-foreground/30">·</span>
643
+ <span>
644
+ {new Date(selectedRun.run.started_at).toLocaleString()}
645
+ </span>
646
+ </div>
647
+ </div>
648
+
649
+ {/* Steps */}
650
+ <div className="flex flex-col gap-3">
651
+ {selectedRun.steps.map((step, index) => {
652
+ const isExpanded = expandedSteps.has(step.id);
653
+ const isLastStep = index === selectedRun.steps.length - 1;
654
+ const isActiveStep =
655
+ isLastStep && selectedRun.run.isInProgress;
656
+ const input = parseJson(step.input);
657
+ const output = parseJson(step.output);
658
+ const usage = parseJson(step.usage);
659
+
660
+ // Get tool results from next step's input
661
+ const nextStep = selectedRun.steps[index + 1];
662
+ const nextInput = nextStep
663
+ ? parseJson(nextStep.input)
664
+ : null;
665
+ const toolResults =
666
+ nextInput?.prompt
667
+ ?.filter((msg: any) => msg.role === 'tool')
668
+ ?.flatMap((msg: any) => msg.content) ?? [];
669
+
670
+ const summary = getStepSummary(output, step.error);
671
+ const isFirstStep = index === 0;
672
+ const inputSummary = getStepInputSummary(
673
+ input,
674
+ isFirstStep,
675
+ );
676
+
677
+ return (
678
+ <Collapsible
679
+ key={step.id}
680
+ open={isExpanded}
681
+ onOpenChange={() => toggleStep(step.id)}
682
+ >
683
+ <Card
684
+ className={`overflow-hidden py-0 gap-0 ${isActiveStep ? 'ring-2 ring-blue-500/50 ring-offset-1 ring-offset-background' : ''}`}
685
+ >
686
+ {/* Step Header */}
687
+ <CollapsibleTrigger asChild>
688
+ <button
689
+ className={`w-full flex items-center justify-between px-4 py-3 text-left transition-colors hover:bg-accent/50 ${
690
+ isExpanded ? 'border-b border-border' : ''
691
+ }`}
692
+ >
693
+ <div className="flex items-center gap-3">
694
+ <span className="text-xs text-muted-foreground font-mono w-4">
695
+ {step.step_number}
696
+ </span>
697
+ <div className="flex items-center gap-2">
698
+ {/* Input side */}
699
+ {inputSummary && (
700
+ <>
701
+ {inputSummary.type === 'user' ? (
702
+ <MessageSquare className="size-3.5 text-muted-foreground" />
703
+ ) : (
704
+ <Wrench className="size-3.5 text-muted-foreground" />
705
+ )}
706
+ {inputSummary.fullText ||
707
+ inputSummary.toolDetails ? (
708
+ <Tooltip>
709
+ <TooltipTrigger asChild>
710
+ <span
711
+ className={`text-sm text-muted-foreground ${inputSummary.type === 'tool' ? 'font-mono' : ''}`}
712
+ >
713
+ {inputSummary.label}
714
+ </span>
715
+ </TooltipTrigger>
716
+ <TooltipContent
717
+ side="bottom"
718
+ className="max-w-sm max-h-40 overflow-hidden"
719
+ >
720
+ <span className="line-clamp-6">
721
+ {inputSummary.fullText ||
722
+ inputSummary.toolDetails}
723
+ </span>
724
+ </TooltipContent>
725
+ </Tooltip>
726
+ ) : (
727
+ <span
728
+ className={`text-sm text-muted-foreground ${inputSummary.type === 'tool' ? 'font-mono' : ''}`}
729
+ >
730
+ {inputSummary.label}
731
+ </span>
732
+ )}
733
+ <span className="text-muted-foreground/50 mx-1">
734
+
735
+ </span>
736
+ </>
737
+ )}
738
+
739
+ {/* Output side */}
740
+ {summary.icon === 'wrench' && (
741
+ <Wrench className="size-3.5 text-muted-foreground" />
742
+ )}
743
+ {summary.icon === 'message' && (
744
+ <MessageSquare className="size-3.5 text-muted-foreground" />
745
+ )}
746
+ {summary.icon === 'alert' && (
747
+ <AlertCircle className="size-3.5 text-destructive" />
748
+ )}
749
+
750
+ {summary.toolDetails ? (
751
+ <Tooltip>
752
+ <TooltipTrigger asChild>
753
+ <span className="text-sm font-medium font-mono">
754
+ {summary.label}
755
+ </span>
756
+ </TooltipTrigger>
757
+ <TooltipContent>
758
+ {summary.toolDetails}
759
+ </TooltipContent>
760
+ </Tooltip>
761
+ ) : (
762
+ <span
763
+ className={`text-sm font-medium ${
764
+ summary.icon === 'wrench'
765
+ ? 'font-mono'
766
+ : ''
767
+ }`}
768
+ >
769
+ {summary.label}
770
+ </span>
771
+ )}
772
+ </div>
773
+ </div>
774
+
775
+ <div className="flex items-center gap-4">
776
+ {isActiveStep ? (
777
+ <span className="text-[11px] text-blue-400 font-medium flex items-center gap-1.5">
778
+ <Loader2 className="size-3 animate-spin" />
779
+ streaming
780
+ </span>
781
+ ) : (
782
+ <span className="text-[11px] text-muted-foreground font-mono">
783
+ {formatDuration(step.duration_ms)}
784
+ </span>
785
+ )}
786
+ {usage && (
787
+ <Tooltip>
788
+ <TooltipTrigger asChild>
789
+ <span className="text-[11px] font-mono text-muted-foreground cursor-help">
790
+ {formatInputTokens(
791
+ getInputTokenBreakdown(
792
+ usage.inputTokens,
793
+ ),
794
+ )}{' '}
795
+ <span className="text-muted-foreground/50">
796
+
797
+ </span>{' '}
798
+ {formatOutputTokens(
799
+ getOutputTokenBreakdown(
800
+ usage.outputTokens,
801
+ ),
802
+ )}
803
+ </span>
804
+ </TooltipTrigger>
805
+ <TooltipContent>
806
+ <TokenBreakdownTooltip
807
+ input={getInputTokenBreakdown(
808
+ usage.inputTokens,
809
+ )}
810
+ output={getOutputTokenBreakdown(
811
+ usage.outputTokens,
812
+ )}
813
+ raw={usage.raw}
814
+ />
815
+ </TooltipContent>
816
+ </Tooltip>
817
+ )}
818
+ <ChevronDown
819
+ className={`size-4 text-muted-foreground transition-transform ${
820
+ isExpanded ? 'rotate-180' : ''
821
+ }`}
822
+ />
823
+ </div>
824
+ </button>
825
+ </CollapsibleTrigger>
826
+
827
+ {/* Step Content */}
828
+ <CollapsibleContent>
829
+ {/* Config Bar */}
830
+ <StepConfigBar
831
+ modelId={step.model_id}
832
+ provider={step.provider}
833
+ input={input}
834
+ providerOptions={parseJson(step.provider_options)}
835
+ usage={usage}
836
+ />
837
+
838
+ {/* Two Panel Layout */}
839
+ <div className="grid grid-cols-2 divide-x divide-border">
840
+ {/* INPUT Panel */}
841
+ <div className="bg-card/50">
842
+ <InputPanel input={input} />
843
+ </div>
844
+
845
+ {/* OUTPUT Panel */}
846
+ <div className="p-4 bg-background min-h-full">
847
+ <h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-3">
848
+ Output
849
+ </h3>
850
+
851
+ {step.error ? (
852
+ <div className="p-3 rounded-md bg-destructive/10 border border-destructive/30 text-sm text-destructive-foreground font-mono">
853
+ {step.error}
854
+ </div>
855
+ ) : output ? (
856
+ <OutputDisplay
857
+ output={output}
858
+ toolResults={toolResults}
859
+ />
860
+ ) : isActiveStep ? (
861
+ <div className="flex items-center gap-2 text-sm text-blue-400">
862
+ <Loader2 className="size-4 animate-spin" />
863
+ <span>Waiting for response...</span>
864
+ </div>
865
+ ) : (
866
+ <p className="text-sm text-muted-foreground">
867
+ No output
868
+ </p>
869
+ )}
870
+ </div>
871
+ </div>
872
+
873
+ {/* Raw Data Toggle */}
874
+ <RawDataSection
875
+ rawRequest={step.raw_request}
876
+ rawResponse={step.raw_response}
877
+ rawChunks={step.raw_chunks}
878
+ isStream={step.type === 'stream'}
879
+ />
880
+ </CollapsibleContent>
881
+ </Card>
882
+ </Collapsible>
883
+ );
884
+ })}
885
+ </div>
886
+ </div>
887
+ )}
888
+ </ScrollArea>
889
+ </main>
890
+ </div>
891
+ </div>
892
+ );
893
+ }
894
+
895
+ function StepConfigBar({
896
+ modelId,
897
+ provider,
898
+ input,
899
+ providerOptions,
900
+ usage,
901
+ }: {
902
+ modelId?: string;
903
+ provider?: string | null;
904
+ input: any;
905
+ providerOptions?: any;
906
+ usage?: any;
907
+ }) {
908
+ const toolCount = input?.tools?.length ?? 0;
909
+
910
+ // Collect parameters
911
+ const params: { label: string; value: string }[] = [];
912
+ if (input?.temperature != null)
913
+ params.push({ label: 'temp', value: String(input.temperature) });
914
+ if (input?.maxOutputTokens != null)
915
+ params.push({ label: 'max tokens', value: String(input.maxOutputTokens) });
916
+ if (input?.topP != null)
917
+ params.push({ label: 'topP', value: String(input.topP) });
918
+ if (input?.topK != null)
919
+ params.push({ label: 'topK', value: String(input.topK) });
920
+ if (input?.toolChoice != null) {
921
+ const choice =
922
+ typeof input.toolChoice === 'string'
923
+ ? input.toolChoice
924
+ : input.toolChoice.type;
925
+ params.push({ label: 'tool choice', value: choice });
926
+ }
927
+
928
+ return (
929
+ <div className="flex items-center gap-2 px-4 py-2 bg-muted/20 border-b border-border text-[11px] text-muted-foreground">
930
+ {provider && (
931
+ <span className="px-1.5 py-0.5 rounded bg-sidebar-primary/10 text-sidebar-primary text-[10px] font-medium">
932
+ {provider}
933
+ </span>
934
+ )}
935
+ <span className="font-mono">{modelId}</span>
936
+
937
+ {params.length > 0 && (
938
+ <>
939
+ <span className="text-muted-foreground/30">·</span>
940
+ {params.map((p, i) => (
941
+ <span key={i}>
942
+ {p.label}: <span className="text-foreground">{p.value}</span>
943
+ {i < params.length - 1 && (
944
+ <span className="text-muted-foreground/30 mx-1">·</span>
945
+ )}
946
+ </span>
947
+ ))}
948
+ </>
949
+ )}
950
+
951
+ {toolCount > 0 && (
952
+ <>
953
+ <span className="text-muted-foreground/30">·</span>
954
+ <Drawer direction="right">
955
+ <DrawerTrigger asChild>
956
+ <button className="inline-flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer">
957
+ <Wrench className="size-3" />
958
+ {toolCount} available {toolCount === 1 ? 'tool' : 'tools'}
959
+ </button>
960
+ </DrawerTrigger>
961
+ <DrawerContent className="h-full w-[800px] sm:max-w-[800px] overflow-hidden">
962
+ <DrawerHeader className="border-b border-border shrink-0">
963
+ <DrawerTitle>Available Tools ({toolCount})</DrawerTitle>
964
+ </DrawerHeader>
965
+ <div className="flex-1 overflow-y-auto p-4">
966
+ <div className="space-y-3">
967
+ {input?.tools?.map((tool: any, i: number) => (
968
+ <ToolItem key={i} tool={tool} />
969
+ ))}
970
+ </div>
971
+ </div>
972
+ </DrawerContent>
973
+ </Drawer>
974
+ </>
975
+ )}
976
+
977
+ {providerOptions && Object.keys(providerOptions).length > 0 && (
978
+ <>
979
+ <span className="text-muted-foreground/30">·</span>
980
+ <Drawer direction="right">
981
+ <DrawerTrigger asChild>
982
+ <button className="inline-flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer">
983
+ <Settings className="size-3" />
984
+ Provider options
985
+ </button>
986
+ </DrawerTrigger>
987
+ <DrawerContent className="h-full w-[800px] sm:max-w-[800px] overflow-hidden">
988
+ <DrawerHeader className="border-b border-border shrink-0">
989
+ <DrawerTitle>Provider Options</DrawerTitle>
990
+ </DrawerHeader>
991
+ <div className="flex-1 overflow-y-auto p-4">
992
+ <JsonBlock data={providerOptions} size="lg" />
993
+ </div>
994
+ </DrawerContent>
995
+ </Drawer>
996
+ </>
997
+ )}
998
+
999
+ {usage && (
1000
+ <>
1001
+ <span className="text-muted-foreground/30">·</span>
1002
+ <Drawer direction="right">
1003
+ <DrawerTrigger asChild>
1004
+ <button className="inline-flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer">
1005
+ <BarChart3 className="size-3" />
1006
+ Usage
1007
+ </button>
1008
+ </DrawerTrigger>
1009
+ <DrawerContent className="h-full w-[800px] sm:max-w-[800px] overflow-hidden">
1010
+ <DrawerHeader className="border-b border-border shrink-0">
1011
+ <DrawerTitle>Token Usage</DrawerTitle>
1012
+ </DrawerHeader>
1013
+ <div className="flex-1 overflow-y-auto p-4">
1014
+ <UsageDetails usage={usage} />
1015
+ </div>
1016
+ </DrawerContent>
1017
+ </Drawer>
1018
+ </>
1019
+ )}
1020
+ </div>
1021
+ );
1022
+ }
1023
+
1024
+ function InputPanel({ input }: { input: any }) {
1025
+ const messages = input?.prompt ?? [];
1026
+ const messageCount = messages.length;
1027
+
1028
+ // Get last two messages to display
1029
+ const lastTwoMessages = messages.slice(-2);
1030
+ const previousMessageCount = Math.max(0, messageCount - 2);
1031
+
1032
+ return (
1033
+ <Drawer direction="right">
1034
+ <DrawerTrigger asChild>
1035
+ <button className="w-full h-full text-left p-4 hover:bg-accent/30 transition-colors cursor-pointer flex flex-col justify-start">
1036
+ <h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-3">
1037
+ Input
1038
+ </h3>
1039
+
1040
+ <div className="space-y-3">
1041
+ {/* Previous messages indicator */}
1042
+ {previousMessageCount > 0 && (
1043
+ <div className="text-[11px] text-muted-foreground/60 text-center py-1.5 rounded-md bg-muted/30">
1044
+ + {previousMessageCount} previous{' '}
1045
+ {previousMessageCount === 1 ? 'message' : 'messages'}
1046
+ </div>
1047
+ )}
1048
+
1049
+ {/* Last two messages displayed inline */}
1050
+ {lastTwoMessages.map((msg: any, i: number) => (
1051
+ <InputMessagePreview
1052
+ key={i}
1053
+ message={msg}
1054
+ index={previousMessageCount + i + 1}
1055
+ />
1056
+ ))}
1057
+
1058
+ {/* Empty state */}
1059
+ {messageCount === 0 && (
1060
+ <p className="text-sm text-muted-foreground">No messages</p>
1061
+ )}
1062
+ </div>
1063
+ </button>
1064
+ </DrawerTrigger>
1065
+ <DrawerContent className="h-full w-[800px] sm:max-w-[800px] overflow-hidden">
1066
+ <DrawerHeader className="border-b border-border shrink-0">
1067
+ <DrawerTitle>All Messages ({messageCount})</DrawerTitle>
1068
+ </DrawerHeader>
1069
+ <div className="flex-1 overflow-y-auto p-4">
1070
+ <div className="space-y-3">
1071
+ {messages.map((msg: any, i: number) => (
1072
+ <MessageBubble key={i} message={msg} index={i + 1} />
1073
+ ))}
1074
+ </div>
1075
+ </div>
1076
+ </DrawerContent>
1077
+ </Drawer>
1078
+ );
1079
+ }
1080
+
1081
+ function InputMessagePreview({
1082
+ message,
1083
+ index,
1084
+ }: {
1085
+ message: any;
1086
+ index?: number;
1087
+ }) {
1088
+ const role = message.role;
1089
+ const content = message.content;
1090
+
1091
+ const roleLabels: Record<string, string> = {
1092
+ user: 'User',
1093
+ assistant: 'Assistant',
1094
+ system: 'System',
1095
+ tool: 'Tool',
1096
+ };
1097
+
1098
+ // Get text content
1099
+ const getTextContent = (content: any): string => {
1100
+ if (typeof content === 'string') return content;
1101
+ if (Array.isArray(content)) {
1102
+ return content
1103
+ .filter(p => p.type === 'text')
1104
+ .map(p => p.text)
1105
+ .join('');
1106
+ }
1107
+ return '';
1108
+ };
1109
+
1110
+ // Get tool calls from assistant message
1111
+ const getToolCalls = (content: any): any[] => {
1112
+ if (Array.isArray(content)) {
1113
+ return content.filter(p => p.type === 'tool-call');
1114
+ }
1115
+ return [];
1116
+ };
1117
+
1118
+ // Get tool results from tool message
1119
+ const getToolResults = (content: any): any[] => {
1120
+ if (Array.isArray(content)) {
1121
+ return content.filter(p => p.type === 'tool-result');
1122
+ }
1123
+ return [];
1124
+ };
1125
+
1126
+ // Get reasoning/thinking content
1127
+ const getReasoningContent = (content: any): string => {
1128
+ if (Array.isArray(content)) {
1129
+ return content
1130
+ .filter(p => p.type === 'thinking' || p.type === 'reasoning')
1131
+ .map(p => p.thinking || p.text || p.reasoning)
1132
+ .join('');
1133
+ }
1134
+ return '';
1135
+ };
1136
+
1137
+ const textContent = getTextContent(content);
1138
+ const toolCalls = getToolCalls(content);
1139
+ const toolResults = getToolResults(content);
1140
+ const reasoningContent = getReasoningContent(content);
1141
+
1142
+ // Count total parts
1143
+ const partCount =
1144
+ (textContent ? 1 : 0) +
1145
+ (reasoningContent ? 1 : 0) +
1146
+ toolCalls.length +
1147
+ toolResults.length;
1148
+
1149
+ return (
1150
+ <div className="rounded-md border border-border/50 bg-background/50 p-2.5 space-y-2">
1151
+ <div className="flex items-center gap-2">
1152
+ {index && (
1153
+ <span className="text-[10px] font-mono text-muted-foreground/50">
1154
+ {index}
1155
+ </span>
1156
+ )}
1157
+ <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
1158
+ {roleLabels[role] || role}
1159
+ </span>
1160
+ {partCount > 1 && (
1161
+ <span className="text-[10px] text-muted-foreground/60">
1162
+ {partCount} parts
1163
+ </span>
1164
+ )}
1165
+ </div>
1166
+
1167
+ {/* Reasoning/Thinking content */}
1168
+ {reasoningContent && (
1169
+ <div className="text-xs text-amber-500/60">[thinking]</div>
1170
+ )}
1171
+
1172
+ {/* Text content */}
1173
+ {textContent && (
1174
+ <div className="text-xs text-foreground/90 line-clamp-3">
1175
+ {textContent}
1176
+ </div>
1177
+ )}
1178
+
1179
+ {/* Tool calls (from assistant) */}
1180
+ {toolCalls.length > 0 && (
1181
+ <div className="space-y-1">
1182
+ {toolCalls.slice(0, 3).map((call: any, i: number) => {
1183
+ const args = call.args ?? call.input;
1184
+ const parsedArgs =
1185
+ typeof args === 'string' ? safeParseJson(args) : args;
1186
+ return (
1187
+ <div
1188
+ key={i}
1189
+ className="text-[11px] font-mono text-muted-foreground truncate"
1190
+ >
1191
+ {call.toolName}({formatToolParamsInline(parsedArgs)})
1192
+ </div>
1193
+ );
1194
+ })}
1195
+ {toolCalls.length > 3 && (
1196
+ <div className="text-[11px] text-muted-foreground/60">
1197
+ +{toolCalls.length - 3} more tool{' '}
1198
+ {toolCalls.length - 3 === 1 ? 'call' : 'calls'}
1199
+ </div>
1200
+ )}
1201
+ </div>
1202
+ )}
1203
+
1204
+ {/* Tool results */}
1205
+ {toolResults.length > 0 && (
1206
+ <div className="space-y-1">
1207
+ {toolResults.slice(0, 3).map((result: any, i: number) => {
1208
+ const resultContent = result.result ?? result.output ?? result;
1209
+ const resultPreview = formatResultPreview(resultContent);
1210
+ return (
1211
+ <div
1212
+ key={i}
1213
+ className="text-[11px] font-mono text-muted-foreground truncate"
1214
+ >
1215
+ {result.toolName || 'tool'}(…) =&gt; {resultPreview}
1216
+ </div>
1217
+ );
1218
+ })}
1219
+ {toolResults.length > 3 && (
1220
+ <div className="text-[11px] text-muted-foreground/60">
1221
+ +{toolResults.length - 3} more tool{' '}
1222
+ {toolResults.length - 3 === 1 ? 'result' : 'results'}
1223
+ </div>
1224
+ )}
1225
+ </div>
1226
+ )}
1227
+
1228
+ {/* Empty content fallback */}
1229
+ {!textContent &&
1230
+ !reasoningContent &&
1231
+ toolCalls.length === 0 &&
1232
+ toolResults.length === 0 && (
1233
+ <div className="text-[11px] text-muted-foreground italic">
1234
+ Empty message
1235
+ </div>
1236
+ )}
1237
+ </div>
1238
+ );
1239
+ }
1240
+
1241
+ function ToolItem({ tool }: { tool: any }) {
1242
+ const [expanded, setExpanded] = useState(false);
1243
+
1244
+ return (
1245
+ <div className="rounded-md border border-border bg-background overflow-hidden">
1246
+ <button
1247
+ className="w-full flex items-center justify-between p-2.5 hover:bg-accent/50 transition-colors"
1248
+ onClick={() => setExpanded(!expanded)}
1249
+ >
1250
+ <span className="text-sm font-mono text-purple">{tool.name}</span>
1251
+ {tool.parameters && (
1252
+ <ChevronRight
1253
+ className={`size-3 text-muted-foreground transition-transform ${
1254
+ expanded ? 'rotate-90' : ''
1255
+ }`}
1256
+ />
1257
+ )}
1258
+ </button>
1259
+ {expanded && tool.parameters && (
1260
+ <div className="px-2.5 pb-2.5 border-t border-border">
1261
+ {tool.description && (
1262
+ <p className="text-xs text-muted-foreground mb-2 pt-2">
1263
+ {tool.description}
1264
+ </p>
1265
+ )}
1266
+ <JsonBlock data={tool.parameters} compact />
1267
+ </div>
1268
+ )}
1269
+ {!expanded && tool.description && (
1270
+ <div className="px-2.5 pb-2 -mt-1">
1271
+ <p className="text-[11px] text-muted-foreground truncate">
1272
+ {tool.description}
1273
+ </p>
1274
+ </div>
1275
+ )}
1276
+ </div>
1277
+ );
1278
+ }
1279
+
1280
+ function CollapsibleToolCall({
1281
+ toolName,
1282
+ toolCallId,
1283
+ data,
1284
+ }: {
1285
+ toolName: string;
1286
+ toolCallId?: string;
1287
+ data: any;
1288
+ }) {
1289
+ const [expanded, setExpanded] = useState(false);
1290
+ const parsedData = typeof data === 'string' ? safeParseJson(data) : data;
1291
+
1292
+ return (
1293
+ <div className="rounded-md border border-purple/30 overflow-hidden">
1294
+ <button
1295
+ className="w-full flex items-center gap-2 px-3 py-2 bg-purple/10 hover:bg-purple/20 transition-colors"
1296
+ onClick={() => setExpanded(!expanded)}
1297
+ >
1298
+ <ChevronRight
1299
+ className={`size-3 text-purple transition-transform shrink-0 ${
1300
+ expanded ? 'rotate-90' : ''
1301
+ }`}
1302
+ />
1303
+ <Wrench className="size-3 text-purple shrink-0" />
1304
+ <span className="text-xs font-mono font-medium text-purple">
1305
+ {toolName}
1306
+ </span>
1307
+ {!expanded && parsedData && (
1308
+ <span className="text-[11px] font-mono text-purple/70 truncate">
1309
+ {formatToolParams(parsedData)}
1310
+ </span>
1311
+ )}
1312
+ {toolCallId && (
1313
+ <span className="text-[10px] font-mono text-muted-foreground/60 ml-auto shrink-0">
1314
+ {toolCallId}
1315
+ </span>
1316
+ )}
1317
+ </button>
1318
+ {expanded && (
1319
+ <div className="p-3 bg-card/50 border-t border-purple/30">
1320
+ <JsonBlock data={parsedData} />
1321
+ </div>
1322
+ )}
1323
+ </div>
1324
+ );
1325
+ }
1326
+
1327
+ function CollapsibleToolResult({
1328
+ toolName,
1329
+ toolCallId,
1330
+ data,
1331
+ }: {
1332
+ toolName?: string;
1333
+ toolCallId?: string;
1334
+ data: any;
1335
+ }) {
1336
+ const [expanded, setExpanded] = useState(false);
1337
+
1338
+ return (
1339
+ <div className="rounded-md border border-success/30 overflow-hidden">
1340
+ <button
1341
+ className="w-full flex items-center gap-2 px-3 py-2 bg-success/10 hover:bg-success/20 transition-colors"
1342
+ onClick={() => setExpanded(!expanded)}
1343
+ >
1344
+ <ChevronRight
1345
+ className={`size-3 text-success transition-transform shrink-0 ${
1346
+ expanded ? 'rotate-90' : ''
1347
+ }`}
1348
+ />
1349
+ <span className="text-xs font-medium text-success">Result</span>
1350
+ {toolName && (
1351
+ <span className="text-[11px] font-mono text-muted-foreground">
1352
+ {toolName}
1353
+ </span>
1354
+ )}
1355
+ {toolCallId && (
1356
+ <span className="text-[10px] font-mono text-muted-foreground/60 ml-auto shrink-0">
1357
+ {toolCallId}
1358
+ </span>
1359
+ )}
1360
+ </button>
1361
+ {expanded && (
1362
+ <div className="p-3 bg-card/50 border-t border-success/30">
1363
+ <JsonBlock data={data} />
1364
+ </div>
1365
+ )}
1366
+ </div>
1367
+ );
1368
+ }
1369
+
1370
+ function MessageBubble({ message, index }: { message: any; index?: number }) {
1371
+ const role = message.role;
1372
+ const content = message.content;
1373
+
1374
+ const roleLabels: Record<string, string> = {
1375
+ user: 'User',
1376
+ assistant: 'Assistant',
1377
+ system: 'System',
1378
+ tool: 'Tool',
1379
+ };
1380
+
1381
+ const getTextContent = (content: any): string => {
1382
+ if (typeof content === 'string') return content;
1383
+ if (Array.isArray(content)) {
1384
+ return content
1385
+ .filter(p => p.type === 'text')
1386
+ .map(p => p.text)
1387
+ .join('');
1388
+ }
1389
+ return '';
1390
+ };
1391
+
1392
+ const getToolCalls = (content: any): any[] => {
1393
+ if (Array.isArray(content)) {
1394
+ return content.filter(p => p.type === 'tool-call');
1395
+ }
1396
+ return [];
1397
+ };
1398
+
1399
+ const getToolResults = (content: any): any[] => {
1400
+ if (Array.isArray(content)) {
1401
+ return content.filter(p => p.type === 'tool-result');
1402
+ }
1403
+ return [];
1404
+ };
1405
+
1406
+ const getReasoningContent = (content: any): string => {
1407
+ if (Array.isArray(content)) {
1408
+ return content
1409
+ .filter(p => p.type === 'thinking' || p.type === 'reasoning')
1410
+ .map(p => p.thinking || p.text || p.reasoning)
1411
+ .join('');
1412
+ }
1413
+ return '';
1414
+ };
1415
+
1416
+ const textContent = getTextContent(content);
1417
+ const toolCalls = getToolCalls(content);
1418
+ const toolResults = getToolResults(content);
1419
+ const reasoningContent = getReasoningContent(content);
1420
+
1421
+ // Count total parts
1422
+ const partCount =
1423
+ (textContent ? 1 : 0) +
1424
+ (reasoningContent ? 1 : 0) +
1425
+ toolCalls.length +
1426
+ toolResults.length;
1427
+
1428
+ return (
1429
+ <div className="rounded-md border border-border/50 bg-background/50 p-3 space-y-2">
1430
+ <div className="flex items-center gap-2">
1431
+ {index && (
1432
+ <span className="text-[10px] font-mono text-muted-foreground/50">
1433
+ {index}
1434
+ </span>
1435
+ )}
1436
+ <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
1437
+ {roleLabels[role] || role}
1438
+ </span>
1439
+ {partCount > 1 && (
1440
+ <span className="text-[10px] text-muted-foreground/60">
1441
+ {partCount} parts
1442
+ </span>
1443
+ )}
1444
+ </div>
1445
+
1446
+ {/* Reasoning/Thinking content - full display */}
1447
+ {reasoningContent && <ReasoningBlock content={reasoningContent} />}
1448
+
1449
+ {/* Full text content */}
1450
+ {textContent && (
1451
+ <TextBlock
1452
+ content={textContent}
1453
+ defaultExpanded={
1454
+ !reasoningContent &&
1455
+ toolCalls.length === 0 &&
1456
+ toolResults.length === 0
1457
+ }
1458
+ isSystem={role === 'system'}
1459
+ />
1460
+ )}
1461
+
1462
+ {/* Tool calls */}
1463
+ {toolCalls.length > 0 && (
1464
+ <div className="space-y-2">
1465
+ {toolCalls.map((call: any, i: number) => (
1466
+ <CollapsibleToolCall
1467
+ key={i}
1468
+ toolName={call.toolName}
1469
+ toolCallId={call.toolCallId}
1470
+ data={call.args ?? call.input}
1471
+ />
1472
+ ))}
1473
+ </div>
1474
+ )}
1475
+
1476
+ {/* Tool results */}
1477
+ {toolResults.length > 0 && (
1478
+ <div className="space-y-2">
1479
+ {toolResults.map((result: any, i: number) => (
1480
+ <CollapsibleToolResult
1481
+ key={i}
1482
+ toolName={result.toolName}
1483
+ toolCallId={result.toolCallId}
1484
+ data={result.result ?? result.output ?? result}
1485
+ />
1486
+ ))}
1487
+ </div>
1488
+ )}
1489
+
1490
+ {/* Empty content fallback */}
1491
+ {!textContent &&
1492
+ !reasoningContent &&
1493
+ toolCalls.length === 0 &&
1494
+ toolResults.length === 0 && (
1495
+ <div className="text-[11px] text-muted-foreground italic">
1496
+ Empty message
1497
+ </div>
1498
+ )}
1499
+ </div>
1500
+ );
1501
+ }
1502
+
1503
+ function OutputDisplay({
1504
+ output,
1505
+ toolResults = [],
1506
+ }: {
1507
+ output: any;
1508
+ toolResults?: any[];
1509
+ }) {
1510
+ const getToolResult = (toolCallId: string) => {
1511
+ return toolResults.find((r: any) => r.toolCallId === toolCallId);
1512
+ };
1513
+
1514
+ const toolCalls =
1515
+ output?.toolCalls ||
1516
+ output?.content?.filter((p: any) => p.type === 'tool-call') ||
1517
+ [];
1518
+
1519
+ const textParts =
1520
+ output?.textParts ||
1521
+ output?.content?.filter((p: any) => p.type === 'text') ||
1522
+ [];
1523
+
1524
+ // Get reasoning/thinking parts
1525
+ const reasoningParts =
1526
+ output?.reasoningParts ||
1527
+ output?.content?.filter(
1528
+ (p: any) => p.type === 'thinking' || p.type === 'reasoning',
1529
+ ) ||
1530
+ [];
1531
+
1532
+ const textContent = textParts.map((p: any) => p.text).join('');
1533
+ const reasoningContent = reasoningParts.map((p: any) => p.text).join('');
1534
+
1535
+ // Check if text is the only content
1536
+ const isTextOnly = textContent && !reasoningContent && toolCalls.length === 0;
1537
+
1538
+ return (
1539
+ <div className="space-y-3">
1540
+ {/* Reasoning/Thinking */}
1541
+ {reasoningContent && <ReasoningBlock content={reasoningContent} />}
1542
+
1543
+ {/* Text Response */}
1544
+ {textContent && (
1545
+ <TextBlock content={textContent} defaultExpanded={isTextOnly} />
1546
+ )}
1547
+
1548
+ {/* Tool Calls with Results */}
1549
+ {toolCalls.map((call: any, i: number) => {
1550
+ const result = getToolResult(call.toolCallId);
1551
+ return (
1552
+ <ToolCallCard
1553
+ key={i}
1554
+ toolName={call.toolName}
1555
+ args={call.args ?? call.input}
1556
+ result={result?.output ?? result?.result}
1557
+ />
1558
+ );
1559
+ })}
1560
+ </div>
1561
+ );
1562
+ }
1563
+
1564
+ function ToolCallCard({
1565
+ toolName,
1566
+ args,
1567
+ result,
1568
+ }: {
1569
+ toolName: string;
1570
+ args: any;
1571
+ result?: any;
1572
+ }) {
1573
+ const [expanded, setExpanded] = useState(false);
1574
+ const parsedArgs = typeof args === 'string' ? safeParseJson(args) : args;
1575
+ const parsedResult =
1576
+ typeof result === 'string' ? safeParseJson(result) : result;
1577
+
1578
+ return (
1579
+ <div className="rounded-md border border-purple/30 overflow-hidden">
1580
+ {/* Tool Call Header */}
1581
+ <button
1582
+ className="w-full flex items-center gap-2 px-3 py-2 bg-purple/10 hover:bg-purple/20 transition-colors"
1583
+ onClick={() => setExpanded(!expanded)}
1584
+ >
1585
+ <ChevronRight
1586
+ className={`size-3 text-purple transition-transform shrink-0 ${
1587
+ expanded ? 'rotate-90' : ''
1588
+ }`}
1589
+ />
1590
+ <Wrench className="size-3 text-purple shrink-0" />
1591
+ <span className="text-xs font-mono font-medium text-purple">
1592
+ {toolName}
1593
+ </span>
1594
+ {!expanded && parsedArgs && (
1595
+ <span className="text-[11px] font-mono text-purple/70 truncate">
1596
+ {formatToolParams(parsedArgs)}
1597
+ </span>
1598
+ )}
1599
+ </button>
1600
+
1601
+ {expanded && (
1602
+ <>
1603
+ {/* Arguments */}
1604
+ <div className="p-3 bg-card/50 border-t border-purple/30">
1605
+ <div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mb-2">
1606
+ Input
1607
+ </div>
1608
+ <JsonBlock data={parsedArgs} />
1609
+ </div>
1610
+
1611
+ {/* Result */}
1612
+ {parsedResult && (
1613
+ <div className="p-3 border-t border-border bg-success/5">
1614
+ <div className="text-[10px] font-medium uppercase tracking-wider text-success mb-2">
1615
+ Output
1616
+ </div>
1617
+ <JsonBlock data={parsedResult} />
1618
+ </div>
1619
+ )}
1620
+ </>
1621
+ )}
1622
+ </div>
1623
+ );
1624
+ }
1625
+
1626
+ function ReasoningBlock({ content }: { content: string }) {
1627
+ const [expanded, setExpanded] = useState(false);
1628
+
1629
+ // Truncate preview to first 200 characters
1630
+ const previewContent =
1631
+ content.length > 200 ? content.slice(0, 200) + '…' : content;
1632
+
1633
+ return (
1634
+ <div className="rounded-md border border-amber-500/30 overflow-hidden">
1635
+ <button
1636
+ className="w-full flex items-center gap-2 px-3 py-2 bg-amber-500/10 hover:bg-amber-500/20 transition-colors"
1637
+ onClick={() => setExpanded(!expanded)}
1638
+ >
1639
+ <ChevronRight
1640
+ className={`size-3 text-amber-500 transition-transform shrink-0 ${
1641
+ expanded ? 'rotate-90' : ''
1642
+ }`}
1643
+ />
1644
+ <Brain className="size-3 text-amber-500 shrink-0" />
1645
+ <span className="text-xs font-medium text-amber-500">Thinking</span>
1646
+ {!expanded && (
1647
+ <span className="text-[11px] text-amber-500/70 truncate ml-1">
1648
+ {previewContent}
1649
+ </span>
1650
+ )}
1651
+ </button>
1652
+
1653
+ {expanded && (
1654
+ <div className="p-3 bg-card/50 border-t border-amber-500/30">
1655
+ <div className="text-xs text-foreground/80 leading-relaxed whitespace-pre-wrap">
1656
+ {content}
1657
+ </div>
1658
+ </div>
1659
+ )}
1660
+ </div>
1661
+ );
1662
+ }
1663
+
1664
+ function TextBlock({
1665
+ content,
1666
+ defaultExpanded = false,
1667
+ isSystem = false,
1668
+ }: {
1669
+ content: string;
1670
+ defaultExpanded?: boolean;
1671
+ isSystem?: boolean;
1672
+ }) {
1673
+ const [expanded, setExpanded] = useState(defaultExpanded);
1674
+ const [copied, setCopied] = useState(false);
1675
+
1676
+ // Truncate preview to first 200 characters
1677
+ const previewContent =
1678
+ content.length > 200 ? content.slice(0, 200) + '…' : content;
1679
+
1680
+ const borderColor = isSystem ? 'border-blue-500/30' : 'border-border';
1681
+ const bgColor = isSystem ? 'bg-blue-500/10' : 'bg-muted/30';
1682
+ const hoverBgColor = isSystem ? 'hover:bg-blue-500/20' : 'hover:bg-muted/50';
1683
+ const iconColor = isSystem ? 'text-blue-400' : 'text-muted-foreground';
1684
+ const labelColor = isSystem ? 'text-blue-400' : 'text-foreground';
1685
+
1686
+ const handleCopy = async (e: React.MouseEvent) => {
1687
+ e.stopPropagation();
1688
+ await navigator.clipboard.writeText(content);
1689
+ setCopied(true);
1690
+ setTimeout(() => setCopied(false), 2000);
1691
+ };
1692
+
1693
+ return (
1694
+ <div className={`rounded-md border ${borderColor} overflow-hidden`}>
1695
+ <button
1696
+ className={`w-full flex items-center gap-2 px-3 py-2 ${bgColor} ${hoverBgColor} transition-colors`}
1697
+ onClick={() => setExpanded(!expanded)}
1698
+ >
1699
+ <ChevronRight
1700
+ className={`size-3 ${iconColor} transition-transform shrink-0 ${
1701
+ expanded ? 'rotate-90' : ''
1702
+ }`}
1703
+ />
1704
+ <MessageSquare className={`size-3 ${iconColor} shrink-0`} />
1705
+ <span className={`text-xs font-medium ${labelColor}`}>Text</span>
1706
+ {!expanded && (
1707
+ <span
1708
+ className={`text-[11px] ${isSystem ? 'text-blue-400/70' : 'text-muted-foreground'} truncate ml-1`}
1709
+ >
1710
+ {previewContent}
1711
+ </span>
1712
+ )}
1713
+ </button>
1714
+
1715
+ {expanded && (
1716
+ <div
1717
+ className={`p-3 bg-card/50 border-t ${borderColor} group relative`}
1718
+ >
1719
+ <button
1720
+ onClick={handleCopy}
1721
+ className="absolute top-1.5 right-1.5 p-1.5 rounded-md border border-border bg-background opacity-0 group-hover:opacity-100 transition-opacity z-10"
1722
+ title="Copy to clipboard"
1723
+ >
1724
+ {copied ? (
1725
+ <Check className="size-3 text-success" />
1726
+ ) : (
1727
+ <Copy className="size-3 text-muted-foreground" />
1728
+ )}
1729
+ </button>
1730
+ <div className="text-xs text-foreground leading-relaxed whitespace-pre-wrap max-h-60 overflow-y-auto">
1731
+ {content}
1732
+ </div>
1733
+ </div>
1734
+ )}
1735
+ </div>
1736
+ );
1737
+ }
1738
+
1739
+ function JsonBlock({
1740
+ data,
1741
+ compact = false,
1742
+ size = 'sm',
1743
+ }: {
1744
+ data: any;
1745
+ compact?: boolean;
1746
+ size?: 'sm' | 'base' | 'lg';
1747
+ }) {
1748
+ const [copied, setCopied] = useState(false);
1749
+
1750
+ const jsonString = JSON.stringify(data, null, 2);
1751
+ const displayString =
1752
+ compact && jsonString.length > 200 ? JSON.stringify(data) : jsonString;
1753
+
1754
+ const sizeClasses = {
1755
+ sm: 'text-xs',
1756
+ base: 'text-sm',
1757
+ lg: 'text-base',
1758
+ };
1759
+
1760
+ const handleCopy = async () => {
1761
+ await navigator.clipboard.writeText(jsonString);
1762
+ setCopied(true);
1763
+ setTimeout(() => setCopied(false), 2000);
1764
+ };
1765
+
1766
+ return (
1767
+ <div className="relative group">
1768
+ <button
1769
+ onClick={handleCopy}
1770
+ className="absolute top-1.5 right-1.5 p-1.5 rounded-md border border-border bg-background opacity-0 group-hover:opacity-100 transition-opacity z-10"
1771
+ title="Copy to clipboard"
1772
+ >
1773
+ {copied ? (
1774
+ <Check className="size-3 text-success" />
1775
+ ) : (
1776
+ <Copy className="size-3 text-muted-foreground" />
1777
+ )}
1778
+ </button>
1779
+ <pre
1780
+ className={`font-mono ${sizeClasses[size]} text-muted-foreground whitespace-pre-wrap wrap-break-words bg-background rounded p-2 ${
1781
+ compact ? 'max-h-20 overflow-hidden' : ''
1782
+ }`}
1783
+ >
1784
+ {displayString}
1785
+ </pre>
1786
+ </div>
1787
+ );
1788
+ }
1789
+
1790
+ function RawDataSection({
1791
+ rawRequest,
1792
+ rawResponse,
1793
+ rawChunks,
1794
+ isStream,
1795
+ }: {
1796
+ rawRequest: string | null;
1797
+ rawResponse: string | null;
1798
+ rawChunks: string | null;
1799
+ isStream: boolean;
1800
+ }) {
1801
+ const [expanded, setExpanded] = useState(false);
1802
+ const [responseView, setResponseView] = useState<'parsed' | 'raw'>('parsed');
1803
+
1804
+ if (!rawRequest && !rawResponse) return null;
1805
+
1806
+ const hasRawChunks = isStream && rawChunks;
1807
+
1808
+ return (
1809
+ <div className="border-t border-border">
1810
+ <button
1811
+ className="w-full flex items-center gap-2 px-4 py-2.5 text-[11px] text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
1812
+ onClick={() => setExpanded(!expanded)}
1813
+ >
1814
+ <ChevronRight
1815
+ className={`size-3 transition-transform ${expanded ? 'rotate-90' : ''}`}
1816
+ />
1817
+ <span className="font-medium uppercase tracking-wider">
1818
+ Request / Response
1819
+ </span>
1820
+ </button>
1821
+
1822
+ {expanded && (
1823
+ <div className="px-4 pb-4 grid grid-cols-2 gap-4">
1824
+ {rawRequest && (
1825
+ <div className="flex flex-col">
1826
+ <div className="h-7 flex items-end mb-2">
1827
+ <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
1828
+ Request
1829
+ </span>
1830
+ </div>
1831
+ <div className="max-h-[400px] overflow-auto rounded-md border border-border">
1832
+ <JsonBlock data={safeParseJson(rawRequest)} />
1833
+ </div>
1834
+ </div>
1835
+ )}
1836
+ {(rawResponse || rawChunks) && (
1837
+ <div className="flex flex-col">
1838
+ <div className="h-7 flex items-end justify-between mb-2">
1839
+ <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
1840
+ {isStream ? 'Stream' : 'Response'}
1841
+ </span>
1842
+ {hasRawChunks && (
1843
+ <div className="inline-flex rounded-md border border-border bg-muted/30 p-0.5">
1844
+ <button
1845
+ onClick={() => setResponseView('parsed')}
1846
+ className={`px-2 py-0.5 text-[10px] font-medium rounded transition-colors ${
1847
+ responseView === 'parsed'
1848
+ ? 'bg-background text-foreground shadow-sm'
1849
+ : 'text-muted-foreground hover:text-foreground'
1850
+ }`}
1851
+ >
1852
+ AI SDK
1853
+ </button>
1854
+ <button
1855
+ onClick={() => setResponseView('raw')}
1856
+ className={`px-2 py-0.5 text-[10px] font-medium rounded transition-colors ${
1857
+ responseView === 'raw'
1858
+ ? 'bg-background text-foreground shadow-sm'
1859
+ : 'text-muted-foreground hover:text-foreground'
1860
+ }`}
1861
+ >
1862
+ Provider
1863
+ </button>
1864
+ </div>
1865
+ )}
1866
+ </div>
1867
+ <div className="max-h-[400px] overflow-auto rounded-md border border-border">
1868
+ <JsonBlock
1869
+ data={safeParseJson(
1870
+ hasRawChunks && responseView === 'raw'
1871
+ ? rawChunks
1872
+ : rawResponse,
1873
+ )}
1874
+ />
1875
+ </div>
1876
+ </div>
1877
+ )}
1878
+ </div>
1879
+ )}
1880
+ </div>
1881
+ );
1882
+ }
1883
+
1884
+ function UsageDetails({ usage }: { usage: any }) {
1885
+ const inputBreakdown = getInputTokenBreakdown(usage?.inputTokens);
1886
+ const outputBreakdown = getOutputTokenBreakdown(usage?.outputTokens);
1887
+
1888
+ return (
1889
+ <div className="space-y-6">
1890
+ {/* Summary Cards */}
1891
+ <div className="grid grid-cols-2 gap-4">
1892
+ <div className="rounded-lg border border-border bg-card p-4">
1893
+ <div className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2">
1894
+ Input Tokens
1895
+ </div>
1896
+ <div className="text-2xl font-semibold">{inputBreakdown.total}</div>
1897
+ {(inputBreakdown.cacheRead !== undefined ||
1898
+ inputBreakdown.cacheWrite !== undefined) && (
1899
+ <div className="mt-3 space-y-1 text-sm text-muted-foreground">
1900
+ {inputBreakdown.cacheRead !== undefined && (
1901
+ <div className="flex justify-between">
1902
+ <span>Cache read</span>
1903
+ <span className="font-mono">{inputBreakdown.cacheRead}</span>
1904
+ </div>
1905
+ )}
1906
+ {inputBreakdown.cacheWrite !== undefined && (
1907
+ <div className="flex justify-between">
1908
+ <span>Cache write</span>
1909
+ <span className="font-mono">{inputBreakdown.cacheWrite}</span>
1910
+ </div>
1911
+ )}
1912
+ {inputBreakdown.noCache !== undefined && (
1913
+ <div className="flex justify-between">
1914
+ <span>No cache</span>
1915
+ <span className="font-mono">{inputBreakdown.noCache}</span>
1916
+ </div>
1917
+ )}
1918
+ </div>
1919
+ )}
1920
+ </div>
1921
+
1922
+ <div className="rounded-lg border border-border bg-card p-4">
1923
+ <div className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2">
1924
+ Output Tokens
1925
+ </div>
1926
+ <div className="text-2xl font-semibold">{outputBreakdown.total}</div>
1927
+ {(outputBreakdown.text !== undefined ||
1928
+ outputBreakdown.reasoning !== undefined) && (
1929
+ <div className="mt-3 space-y-1 text-sm text-muted-foreground">
1930
+ {outputBreakdown.text !== undefined && (
1931
+ <div className="flex justify-between">
1932
+ <span>Text</span>
1933
+ <span className="font-mono">{outputBreakdown.text}</span>
1934
+ </div>
1935
+ )}
1936
+ {outputBreakdown.reasoning !== undefined && (
1937
+ <div className="flex justify-between">
1938
+ <span>Reasoning</span>
1939
+ <span className="font-mono">{outputBreakdown.reasoning}</span>
1940
+ </div>
1941
+ )}
1942
+ </div>
1943
+ )}
1944
+ </div>
1945
+ </div>
1946
+
1947
+ {/* Raw Usage Data */}
1948
+ {usage?.raw && (
1949
+ <div>
1950
+ <div className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2">
1951
+ Raw Provider Usage
1952
+ </div>
1953
+ <JsonBlock data={usage.raw} size="sm" />
1954
+ </div>
1955
+ )}
1956
+
1957
+ {/* Full Usage Object */}
1958
+ <div>
1959
+ <div className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2">
1960
+ Full Usage Object
1961
+ </div>
1962
+ <JsonBlock data={usage} size="sm" />
1963
+ </div>
1964
+ </div>
1965
+ );
1966
+ }
1967
+
1968
+ function TokenBreakdownTooltip({
1969
+ input,
1970
+ output,
1971
+ raw,
1972
+ }: {
1973
+ input: InputTokenBreakdown;
1974
+ output: OutputTokenBreakdown;
1975
+ raw?: unknown;
1976
+ }) {
1977
+ // Check if we have any breakdown metadata (even if values are 0)
1978
+ const hasInputBreakdown =
1979
+ input.noCache !== undefined ||
1980
+ input.cacheRead !== undefined ||
1981
+ input.cacheWrite !== undefined;
1982
+ const hasOutputBreakdown =
1983
+ output.text !== undefined || output.reasoning !== undefined;
1984
+ const hasBreakdown = hasInputBreakdown || hasOutputBreakdown;
1985
+
1986
+ if (!hasBreakdown) {
1987
+ return (
1988
+ <div className="text-xs">
1989
+ <div>Input: {input.total}</div>
1990
+ <div>Output: {output.total}</div>
1991
+ </div>
1992
+ );
1993
+ }
1994
+
1995
+ return (
1996
+ <div className="text-xs space-y-2">
1997
+ <div>
1998
+ <div className="font-medium mb-1">Input: {input.total}</div>
1999
+ {input.cacheRead !== undefined && (
2000
+ <div className="text-muted-foreground ml-2">
2001
+ Cache read: {input.cacheRead}
2002
+ </div>
2003
+ )}
2004
+ {input.cacheWrite !== undefined && (
2005
+ <div className="text-muted-foreground ml-2">
2006
+ Cache write: {input.cacheWrite}
2007
+ </div>
2008
+ )}
2009
+ </div>
2010
+ <div>
2011
+ <div className="font-medium mb-1">Output: {output.total}</div>
2012
+ {output.text !== undefined && (
2013
+ <div className="text-muted-foreground ml-2">Text: {output.text}</div>
2014
+ )}
2015
+ {output.reasoning !== undefined && (
2016
+ <div className="text-muted-foreground ml-2">
2017
+ Reasoning: {output.reasoning}
2018
+ </div>
2019
+ )}
2020
+ </div>
2021
+ {raw !== undefined && (
2022
+ <div className="pt-1 border-t border-border mt-1">
2023
+ <div className="text-muted-foreground/70 font-mono text-[10px] max-w-[200px] truncate">
2024
+ Raw: {JSON.stringify(raw)}
2025
+ </div>
2026
+ </div>
2027
+ )}
2028
+ </div>
2029
+ );
2030
+ }
2031
+
2032
+ function safeParseJson(value: any): any {
2033
+ if (typeof value === 'string') {
2034
+ try {
2035
+ return JSON.parse(value);
2036
+ } catch {
2037
+ return value;
2038
+ }
2039
+ }
2040
+ return value;
2041
+ }
2042
+
2043
+ /**
2044
+ * Token usage can be either a number (old format) or an object with breakdown (ai@6.0.0-beta.139+).
2045
+ * Old format: inputTokens: 4456
2046
+ * New format for input: { total: 4456, noCache: 9, cacheRead: 4100, cacheWrite: 347 }
2047
+ * New format for output: { total: 1262, text: 1262, reasoning: 0 }
2048
+ */
2049
+ interface InputTokenBreakdown {
2050
+ total: number;
2051
+ noCache?: number;
2052
+ cacheRead?: number;
2053
+ cacheWrite?: number;
2054
+ }
2055
+
2056
+ interface OutputTokenBreakdown {
2057
+ total: number;
2058
+ text?: number;
2059
+ reasoning?: number;
2060
+ }
2061
+
2062
+ function getInputTokenBreakdown(
2063
+ tokens: number | InputTokenBreakdown | null | undefined,
2064
+ ): InputTokenBreakdown {
2065
+ if (tokens == null) return { total: 0 };
2066
+ if (typeof tokens === 'number') return { total: tokens };
2067
+ if (typeof tokens === 'object') {
2068
+ // Handle case where total might be missing or not a number
2069
+ const total =
2070
+ 'total' in tokens && typeof tokens.total === 'number' ? tokens.total : 0;
2071
+ return {
2072
+ total,
2073
+ // Only include cache fields if they are actual numbers
2074
+ ...(typeof tokens.noCache === 'number' && { noCache: tokens.noCache }),
2075
+ ...(typeof tokens.cacheRead === 'number' && {
2076
+ cacheRead: tokens.cacheRead,
2077
+ }),
2078
+ ...(typeof tokens.cacheWrite === 'number' && {
2079
+ cacheWrite: tokens.cacheWrite,
2080
+ }),
2081
+ };
2082
+ }
2083
+ return { total: 0 };
2084
+ }
2085
+
2086
+ function getOutputTokenBreakdown(
2087
+ tokens: number | OutputTokenBreakdown | null | undefined,
2088
+ ): OutputTokenBreakdown {
2089
+ if (tokens == null) return { total: 0 };
2090
+ if (typeof tokens === 'number') return { total: tokens };
2091
+ if (typeof tokens === 'object') {
2092
+ // Handle case where total might be missing or not a number
2093
+ const total =
2094
+ 'total' in tokens && typeof tokens.total === 'number' ? tokens.total : 0;
2095
+ return {
2096
+ total,
2097
+ // Only include text/reasoning if they are actual numbers
2098
+ ...(typeof tokens.text === 'number' && { text: tokens.text }),
2099
+ ...(typeof tokens.reasoning === 'number' && {
2100
+ reasoning: tokens.reasoning,
2101
+ }),
2102
+ };
2103
+ }
2104
+ return { total: 0 };
2105
+ }
2106
+
2107
+ /**
2108
+ * Formats input token count with cache info in parentheses if available.
2109
+ * e.g., "4456" or "4456 (4100 cached)"
2110
+ */
2111
+ function formatInputTokens(breakdown: InputTokenBreakdown): string {
2112
+ const { total, cacheRead } = breakdown;
2113
+ if (cacheRead && cacheRead > 0) {
2114
+ return `${total} (${cacheRead} cached)`;
2115
+ }
2116
+ return String(total);
2117
+ }
2118
+
2119
+ /**
2120
+ * Formats output token count with reasoning info if available.
2121
+ * e.g., "1054" or "1054 (500 reasoning)"
2122
+ */
2123
+ function formatOutputTokens(breakdown: OutputTokenBreakdown): string {
2124
+ const { total, reasoning } = breakdown;
2125
+ if (reasoning && reasoning > 0) {
2126
+ return `${total} (${reasoning} reasoning)`;
2127
+ }
2128
+ return String(total);
2129
+ }
2130
+
2131
+ /**
2132
+ * Truncates tool call parameters for preview display.
2133
+ */
2134
+ function formatToolParams(args: any, maxLength = 40): string {
2135
+ if (!args || typeof args !== 'object') return '';
2136
+
2137
+ const entries = Object.entries(args);
2138
+ if (entries.length === 0) return '';
2139
+
2140
+ const formatValue = (value: any, maxLen = 20): string => {
2141
+ if (value === null) return 'null';
2142
+ if (value === undefined) return 'undefined';
2143
+ if (typeof value === 'object') {
2144
+ if (Array.isArray(value)) {
2145
+ return `[${value.length}]`;
2146
+ }
2147
+ return '{…}';
2148
+ }
2149
+ if (typeof value === 'string') {
2150
+ if (value.length > maxLen) {
2151
+ return `"${value.slice(0, maxLen)}…"`;
2152
+ }
2153
+ return `"${value}"`;
2154
+ }
2155
+ return String(value);
2156
+ };
2157
+
2158
+ const firstEntry = entries[0];
2159
+ if (!firstEntry) return '';
2160
+
2161
+ const [firstKey, firstValue] = firstEntry;
2162
+ const firstFormatted = `${firstKey}: ${formatValue(firstValue)}`;
2163
+
2164
+ if (entries.length === 1) {
2165
+ return `{ ${firstFormatted} }`;
2166
+ }
2167
+
2168
+ return `{ ${firstFormatted}, … }`;
2169
+ }
2170
+
2171
+ /**
2172
+ * Formats tool params inline for JS-like syntax: { key: "value", … }
2173
+ */
2174
+ function formatToolParamsInline(args: any): string {
2175
+ if (!args || typeof args !== 'object') return '';
2176
+
2177
+ const entries = Object.entries(args);
2178
+ if (entries.length === 0) return '';
2179
+
2180
+ const formatValue = (value: any, maxLen = 20): string => {
2181
+ if (value === null) return 'null';
2182
+ if (value === undefined) return 'undefined';
2183
+ if (typeof value === 'object') {
2184
+ if (Array.isArray(value)) {
2185
+ return `[${value.length}]`;
2186
+ }
2187
+ return '{…}';
2188
+ }
2189
+ if (typeof value === 'string') {
2190
+ if (value.length > maxLen) {
2191
+ return `"${value.slice(0, maxLen)}…"`;
2192
+ }
2193
+ return `"${value}"`;
2194
+ }
2195
+ return String(value);
2196
+ };
2197
+
2198
+ const firstEntry = entries[0];
2199
+ if (!firstEntry) return '';
2200
+
2201
+ const [firstKey, firstValue] = firstEntry;
2202
+ const firstFormatted = `${firstKey}: ${formatValue(firstValue)}`;
2203
+
2204
+ if (entries.length === 1) {
2205
+ return `{ ${firstFormatted} }`;
2206
+ }
2207
+
2208
+ return `{ ${firstFormatted}, … }`;
2209
+ }
2210
+
2211
+ /**
2212
+ * Formats tool result for preview display in JS-like syntax
2213
+ */
2214
+ function formatResultPreview(result: any): string {
2215
+ if (result === null) return 'null';
2216
+ if (result === undefined) return 'undefined';
2217
+
2218
+ if (typeof result === 'string') {
2219
+ if (result.length > 30) {
2220
+ return `"${result.slice(0, 30)}…"`;
2221
+ }
2222
+ return `"${result}"`;
2223
+ }
2224
+
2225
+ if (Array.isArray(result)) {
2226
+ return `[${result.length}]`;
2227
+ }
2228
+
2229
+ if (typeof result === 'object') {
2230
+ const entries = Object.entries(result);
2231
+ if (entries.length === 0) return '{}';
2232
+
2233
+ const firstEntry = entries[0];
2234
+ if (!firstEntry) return '{…}';
2235
+
2236
+ const [firstKey, firstValue] = firstEntry;
2237
+ let valueStr: string;
2238
+ if (typeof firstValue === 'string' && firstValue.length > 15) {
2239
+ valueStr = `"${firstValue.slice(0, 15)}…"`;
2240
+ } else if (typeof firstValue === 'string') {
2241
+ valueStr = `"${firstValue}"`;
2242
+ } else if (typeof firstValue === 'object') {
2243
+ valueStr = Array.isArray(firstValue) ? `[${firstValue.length}]` : '{…}';
2244
+ } else {
2245
+ valueStr = String(firstValue);
2246
+ }
2247
+
2248
+ if (entries.length === 1) {
2249
+ return `{ ${firstKey}: ${valueStr} }`;
2250
+ }
2251
+ return `{ ${firstKey}: ${valueStr}, … }`;
2252
+ }
2253
+
2254
+ return String(result);
2255
+ }
2256
+
2257
+ export default App;