@linxiraos/pi-stats 1.1.6 → 1.1.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/dist/client/index.css +1 -1
  3. package/dist/client/index.js +101 -101
  4. package/dist/client/styles.css +362 -98
  5. package/dist/types/client/api.d.ts +7 -1
  6. package/dist/types/client/app/routes.d.ts +1 -1
  7. package/dist/types/client/data/useHashRoute.d.ts +2 -0
  8. package/dist/types/client/routes/TracesRoute.d.ts +11 -0
  9. package/dist/types/client/routes/index.d.ts +1 -0
  10. package/dist/types/client/traces/AggregatesPanel.d.ts +9 -0
  11. package/dist/types/client/traces/Minimap.d.ts +14 -0
  12. package/dist/types/client/traces/SpanDrawer.d.ts +14 -0
  13. package/dist/types/client/traces/SummaryStrip.d.ts +9 -0
  14. package/dist/types/client/traces/TimelineCanvas.d.ts +25 -0
  15. package/dist/types/client/traces/TraceView.d.ts +11 -0
  16. package/dist/types/client/traces/TranscriptList.d.ts +24 -0
  17. package/dist/types/client/traces/time-scale.d.ts +40 -0
  18. package/dist/types/client/traces/trace-colors.d.ts +31 -0
  19. package/dist/types/db.d.ts +24 -1
  20. package/dist/types/index.d.ts +1 -1
  21. package/dist/types/parser.d.ts +8 -0
  22. package/dist/types/server.d.ts +3 -2
  23. package/dist/types/shared-types.d.ts +130 -0
  24. package/dist/types/trace.d.ts +33 -0
  25. package/dist/types/types.d.ts +35 -1
  26. package/package.json +4 -4
  27. package/src/client/App.tsx +11 -1
  28. package/src/client/api.ts +23 -0
  29. package/src/client/app/routes.ts +7 -0
  30. package/src/client/data/useHashRoute.ts +28 -10
  31. package/src/client/routes/ProvidersRoute.tsx +1 -0
  32. package/src/client/routes/TracesRoute.tsx +182 -0
  33. package/src/client/routes/index.ts +1 -0
  34. package/src/client/styles.css +433 -111
  35. package/src/client/traces/AggregatesPanel.tsx +88 -0
  36. package/src/client/traces/Minimap.tsx +177 -0
  37. package/src/client/traces/SpanDrawer.tsx +309 -0
  38. package/src/client/traces/SummaryStrip.tsx +37 -0
  39. package/src/client/traces/TimelineCanvas.tsx +768 -0
  40. package/src/client/traces/TraceView.tsx +344 -0
  41. package/src/client/traces/TranscriptList.tsx +166 -0
  42. package/src/client/traces/time-scale.ts +250 -0
  43. package/src/client/traces/trace-colors.ts +77 -0
  44. package/src/db.ts +77 -0
  45. package/src/index.ts +1 -6
  46. package/src/parser.ts +55 -2
  47. package/src/server.ts +69 -17
  48. package/src/shared-types.ts +138 -0
  49. package/src/trace.ts +1002 -0
  50. package/src/types.ts +45 -1
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Timeline overview strip: per-category density over the full virtual domain
3
+ * plus a draggable/resizable viewport brush, mirroring DevTools' overview.
4
+ */
5
+
6
+ import { useCallback, useEffect, useRef, useState } from "react";
7
+ import type { TraceSpanKind, TraceTrack } from "../types";
8
+ import { useSystemTheme } from "../useSystemTheme";
9
+ import type { TimelineViewport } from "./TimelineCanvas";
10
+ import type { TraceScale } from "./time-scale";
11
+ import { TRACE_THEMES } from "./trace-colors";
12
+
13
+ export interface MinimapProps {
14
+ tracks: TraceTrack[];
15
+ scale: TraceScale;
16
+ viewport: TimelineViewport;
17
+ onViewportChange: (viewport: TimelineViewport) => void;
18
+ }
19
+
20
+ const HEIGHT = 44;
21
+ const STRIP_KINDS: TraceSpanKind[] = ["turn", "model", "tool", "subagent"];
22
+ const EDGE_PX = 6;
23
+ const MIN_WINDOW_U = 10;
24
+
25
+ type DragMode = "move" | "left" | "right" | "create";
26
+
27
+ export function Minimap({ tracks, scale, viewport, onViewportChange }: MinimapProps) {
28
+ const theme = useSystemTheme();
29
+ const colors = TRACE_THEMES[theme];
30
+ const canvasRef = useRef<HTMLCanvasElement>(null);
31
+ const [width, setWidth] = useState(800);
32
+ const dragRef = useRef<{ mode: DragMode; startX: number; startViewport: TimelineViewport } | null>(null);
33
+
34
+ useEffect(() => {
35
+ const canvas = canvasRef.current;
36
+ if (!canvas) return;
37
+ const observer = new ResizeObserver(entries => {
38
+ setWidth(Math.max(100, Math.floor(entries[0].contentRect.width)));
39
+ });
40
+ observer.observe(canvas);
41
+ return () => observer.disconnect();
42
+ }, []);
43
+
44
+ const [d0, d1] = scale.domain;
45
+ const dSpan = Math.max(d1 - d0, 1e-9);
46
+ const toX = useCallback((u: number) => ((u - d0) / dSpan) * width, [d0, dSpan, width]);
47
+ const toU = useCallback((x: number) => d0 + (x / Math.max(width, 1)) * dSpan, [d0, dSpan, width]);
48
+
49
+ useEffect(() => {
50
+ const canvas = canvasRef.current;
51
+ if (!canvas) return;
52
+ const ctx = canvas.getContext("2d");
53
+ if (!ctx) return;
54
+ const dpr = devicePixelRatio;
55
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
56
+ ctx.clearRect(0, 0, width, HEIGHT);
57
+
58
+ // Density strips: bucket span coverage per pixel per category.
59
+ const stripH = HEIGHT / STRIP_KINDS.length;
60
+ for (let k = 0; k < STRIP_KINDS.length; k++) {
61
+ const kind = STRIP_KINDS[k];
62
+ const buckets = new Float32Array(width);
63
+ for (const track of tracks) {
64
+ for (const span of track.spans) {
65
+ if (span.kind !== kind) continue;
66
+ const x0 = Math.max(0, Math.floor(toX(scale.toU(span.start))));
67
+ const x1 = Math.min(width - 1, Math.ceil(toX(scale.toU(span.end))));
68
+ for (let x = x0; x <= x1; x++) buckets[x] = Math.min(1, buckets[x] + 0.34);
69
+ }
70
+ }
71
+ ctx.fillStyle = colors.category[kind];
72
+ for (let x = 0; x < width; x++) {
73
+ if (buckets[x] <= 0) continue;
74
+ ctx.globalAlpha = 0.25 + buckets[x] * 0.75;
75
+ ctx.fillRect(x, k * stripH + 1, 1, stripH - 2);
76
+ }
77
+ ctx.globalAlpha = 1;
78
+ }
79
+
80
+ // Viewport brush.
81
+ const vx0 = toX(viewport.u0);
82
+ const vx1 = toX(viewport.u1);
83
+ ctx.fillStyle = theme === "dark" ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)";
84
+ ctx.fillRect(vx0, 0, vx1 - vx0, HEIGHT);
85
+ ctx.strokeStyle = colors.selection;
86
+ ctx.strokeRect(vx0 + 0.5, 0.5, vx1 - vx0 - 1, HEIGHT - 1);
87
+ ctx.fillStyle = colors.selection;
88
+ ctx.fillRect(vx0, 0, 2, HEIGHT);
89
+ ctx.fillRect(vx1 - 2, 0, 2, HEIGHT);
90
+ }, [tracks, scale, viewport, colors, theme, width, toX]);
91
+
92
+ const handlePointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {
93
+ const rect = event.currentTarget.getBoundingClientRect();
94
+ const x = event.clientX - rect.left;
95
+ const vx0 = toX(viewport.u0);
96
+ const vx1 = toX(viewport.u1);
97
+ let mode: DragMode;
98
+ if (Math.abs(x - vx0) <= EDGE_PX) mode = "left";
99
+ else if (Math.abs(x - vx1) <= EDGE_PX) mode = "right";
100
+ else if (x > vx0 && x < vx1) mode = "move";
101
+ else {
102
+ mode = "create";
103
+ const u = toU(x);
104
+ onViewportChange(clamp({ u0: u, u1: u + MIN_WINDOW_U }, d0, d1));
105
+ }
106
+ dragRef.current = { mode, startX: x, startViewport: viewport };
107
+ event.currentTarget.setPointerCapture(event.pointerId);
108
+ };
109
+
110
+ const handlePointerMove = (event: React.PointerEvent<HTMLCanvasElement>) => {
111
+ const drag = dragRef.current;
112
+ if (!drag) return;
113
+ const rect = event.currentTarget.getBoundingClientRect();
114
+ const x = event.clientX - rect.left;
115
+ const deltaU = toU(x) - toU(drag.startX);
116
+ const { u0, u1 } = drag.startViewport;
117
+ if (drag.mode === "move") {
118
+ onViewportChange(clamp({ u0: u0 + deltaU, u1: u1 + deltaU }, d0, d1));
119
+ } else if (drag.mode === "left") {
120
+ onViewportChange(clamp({ u0: Math.min(u0 + deltaU, u1 - MIN_WINDOW_U), u1 }, d0, d1));
121
+ } else if (drag.mode === "right") {
122
+ onViewportChange(clamp({ u0, u1: Math.max(u1 + deltaU, u0 + MIN_WINDOW_U) }, d0, d1));
123
+ } else {
124
+ const anchor = toU(drag.startX);
125
+ const current = toU(x);
126
+ onViewportChange(
127
+ clamp(
128
+ {
129
+ u0: Math.min(anchor, current),
130
+ u1: Math.max(anchor, current, Math.min(anchor, current) + MIN_WINDOW_U),
131
+ },
132
+ d0,
133
+ d1,
134
+ ),
135
+ );
136
+ }
137
+ };
138
+
139
+ const handlePointerUp = () => {
140
+ dragRef.current = null;
141
+ };
142
+
143
+ return (
144
+ <canvas
145
+ ref={canvasRef}
146
+ width={Math.floor(width * devicePixelRatio)}
147
+ height={Math.floor(HEIGHT * devicePixelRatio)}
148
+ style={{
149
+ width: "100%",
150
+ height: HEIGHT,
151
+ display: "block",
152
+ cursor: "crosshair",
153
+ borderRadius: 4,
154
+ border: "1px solid var(--border)",
155
+ touchAction: "none",
156
+ }}
157
+ role="slider"
158
+ aria-label="Timeline overview brush"
159
+ aria-valuemin={0}
160
+ aria-valuemax={100}
161
+ aria-valuenow={Math.round(((viewport.u0 - d0) / dSpan) * 100)}
162
+ tabIndex={-1}
163
+ onPointerDown={handlePointerDown}
164
+ onPointerMove={handlePointerMove}
165
+ onPointerUp={handlePointerUp}
166
+ />
167
+ );
168
+ }
169
+
170
+ function clamp(viewport: TimelineViewport, d0: number, d1: number): TimelineViewport {
171
+ let span = Math.max(MIN_WINDOW_U, viewport.u1 - viewport.u0);
172
+ span = Math.min(span, d1 - d0 || MIN_WINDOW_U);
173
+ let start = viewport.u0;
174
+ if (start < d0) start = d0;
175
+ if (start + span > d1) start = d1 - span;
176
+ return { u0: start, u1: start + span };
177
+ }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Detail drawer for a selected trace span. Fetches the full journal entry on
3
+ * demand (`/api/session/entry`) and renders per-kind detail: model usage grid
4
+ * + text blocks, tool args/result, user text, subagent task text.
5
+ */
6
+
7
+ import { Check, Copy, X } from "lucide-react";
8
+ import { useEffect, useRef, useState } from "react";
9
+ import { getSessionEntryDetail } from "../api";
10
+ import { formatDurationMs, formatInteger } from "../data/formatters";
11
+ import type { TraceSpan, TraceTrack } from "../types";
12
+ import { JsonBlock } from "../ui/JsonBlock";
13
+ import { Skeleton } from "../ui/Skeleton";
14
+ import { StatusPill } from "../ui/StatusPill";
15
+
16
+ export interface SpanDrawerProps {
17
+ span: TraceSpan | null;
18
+ track: TraceTrack | null;
19
+ onClose: () => void;
20
+ /** Selects the first span of the given child track ("open child track"). */
21
+ onOpenChildTrack?: (childTrackId: string) => void;
22
+ }
23
+
24
+ /** Loose structural view of a fetched journal entry (validated field-by-field). */
25
+ interface EntryDetailView {
26
+ message?: {
27
+ role?: string;
28
+ content?: unknown;
29
+ usage?: {
30
+ input?: number;
31
+ output?: number;
32
+ cacheRead?: number;
33
+ cacheWrite?: number;
34
+ totalTokens?: number;
35
+ cost?: { total?: number };
36
+ };
37
+ model?: string;
38
+ provider?: string;
39
+ stopReason?: string;
40
+ errorMessage?: string;
41
+ duration?: number;
42
+ ttft?: number;
43
+ toolName?: string;
44
+ isError?: boolean;
45
+ details?: unknown;
46
+ };
47
+ }
48
+
49
+ function textBlocks(content: unknown): Array<{ kind: string; text: string }> {
50
+ const blocks: Array<{ kind: string; text: string }> = [];
51
+ if (typeof content === "string") {
52
+ if (content.trim()) blocks.push({ kind: "text", text: content });
53
+ return blocks;
54
+ }
55
+ if (!Array.isArray(content)) return blocks;
56
+ for (const block of content) {
57
+ if (!block || typeof block !== "object" || !("type" in block)) continue;
58
+ if ((block.type === "text" || block.type === "thinking") && "text" in block && typeof block.text === "string") {
59
+ blocks.push({ kind: String(block.type), text: block.text });
60
+ }
61
+ }
62
+ return blocks;
63
+ }
64
+
65
+ export function SpanDrawer({ span, track, onClose, onOpenChildTrack }: SpanDrawerProps) {
66
+ const [entry, setEntry] = useState<unknown>(null);
67
+ const [loading, setLoading] = useState(false);
68
+ const [error, setError] = useState<Error | null>(null);
69
+ const [copied, setCopied] = useState(false);
70
+ const closeButtonRef = useRef<HTMLButtonElement>(null);
71
+ const previousActiveElement = useRef<HTMLElement | null>(null);
72
+
73
+ useEffect(() => {
74
+ setEntry(null);
75
+ setError(null);
76
+ setCopied(false);
77
+ if (!span || !track || !span.entryId) return;
78
+ previousActiveElement.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
79
+ setLoading(true);
80
+ const controller = new AbortController();
81
+ getSessionEntryDetail(track.file, span.entryId, controller.signal)
82
+ .then(data => {
83
+ if (controller.signal.aborted) return;
84
+ setEntry(data.entry);
85
+ setTimeout(() => closeButtonRef.current?.focus(), 50);
86
+ })
87
+ .catch(err => {
88
+ if (controller.signal.aborted) return;
89
+ setError(err instanceof Error ? err : new Error(String(err)));
90
+ })
91
+ .finally(() => {
92
+ if (!controller.signal.aborted) setLoading(false);
93
+ });
94
+ return () => controller.abort();
95
+ }, [span, track]);
96
+
97
+ useEffect(() => {
98
+ if (!span) return;
99
+ const handleKeyDown = (event: KeyboardEvent) => {
100
+ if (event.key === "Escape") onClose();
101
+ };
102
+ window.addEventListener("keydown", handleKeyDown);
103
+ return () => {
104
+ window.removeEventListener("keydown", handleKeyDown);
105
+ previousActiveElement.current?.focus();
106
+ };
107
+ }, [span, onClose]);
108
+
109
+ if (!span) return null;
110
+
111
+ const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => {
112
+ if (event.target === event.currentTarget) onClose();
113
+ };
114
+
115
+ const handleCopy = async () => {
116
+ try {
117
+ await navigator.clipboard.writeText(JSON.stringify(entry ?? span, null, 2));
118
+ setCopied(true);
119
+ setTimeout(() => setCopied(false), 1500);
120
+ } catch {
121
+ // Clipboard unavailable (permissions); leave the button state as-is.
122
+ }
123
+ };
124
+
125
+ // Boundary view: server returns opaque journal JSON, fields re-checked below.
126
+ const view: EntryDetailView = entry && typeof entry === "object" ? (entry as EntryDetailView) : {};
127
+ const msg = view.message;
128
+ const usage = msg?.usage;
129
+
130
+ return (
131
+ <div className="stats-drawer-overlay" onClick={handleOverlayClick} role="presentation">
132
+ <div className="stats-drawer" role="dialog" aria-modal="true" aria-label="Span details">
133
+ <div className="stats-drawer-header">
134
+ <div className="stats-drawer-header-left">
135
+ <h2 className="stats-drawer-title truncate" style={{ maxWidth: 320 }}>
136
+ {span.label}
137
+ </h2>
138
+ <StatusPill variant={span.isError ? "danger" : "success"}>{span.kind}</StatusPill>
139
+ </div>
140
+ <div style={{ display: "flex", gap: 8 }}>
141
+ <button
142
+ type="button"
143
+ onClick={handleCopy}
144
+ className="stats-drawer-close-btn"
145
+ aria-label="Copy raw JSON"
146
+ title="Copy raw JSON"
147
+ >
148
+ {copied ? <Check size={16} /> : <Copy size={16} />}
149
+ </button>
150
+ <button
151
+ ref={closeButtonRef}
152
+ type="button"
153
+ onClick={onClose}
154
+ className="stats-drawer-close-btn"
155
+ aria-label="Close span details"
156
+ >
157
+ <X size={18} />
158
+ </button>
159
+ </div>
160
+ </div>
161
+
162
+ <div className="stats-drawer-body">
163
+ <div className="stats-drawer-content">
164
+ <div className="stats-drawer-metrics-grid">
165
+ <div className="stats-drawer-metric-card">
166
+ <div className="stats-drawer-metric-label">Duration</div>
167
+ <div className="stats-drawer-metric-value">{formatDurationMs(span.end - span.start)}</div>
168
+ {span.unterminated && <div className="stats-drawer-metric-sub">unterminated</div>}
169
+ </div>
170
+ <div className="stats-drawer-metric-card">
171
+ <div className="stats-drawer-metric-label">Start</div>
172
+ <div className="stats-drawer-metric-value" style={{ fontSize: 13 }}>
173
+ {new Date(span.start).toLocaleTimeString()}
174
+ </div>
175
+ </div>
176
+ {span.kind === "model" && (
177
+ <>
178
+ <div className="stats-drawer-metric-card">
179
+ <div className="stats-drawer-metric-label">Tokens</div>
180
+ <div className="stats-drawer-metric-value">{formatInteger(span.tokens ?? 0)}</div>
181
+ {usage && (
182
+ <div className="stats-drawer-metric-sub">
183
+ {formatInteger(usage.input ?? 0)} in · {formatInteger(usage.output ?? 0)} out ·{" "}
184
+ {formatInteger(usage.cacheRead ?? 0)} cached
185
+ </div>
186
+ )}
187
+ </div>
188
+ <div className="stats-drawer-metric-card">
189
+ <div className="stats-drawer-metric-label">Cost</div>
190
+ <div className="stats-drawer-metric-value">${(span.cost ?? 0).toFixed(4)}</div>
191
+ </div>
192
+ <div className="stats-drawer-metric-card">
193
+ <div className="stats-drawer-metric-label">TTFT</div>
194
+ <div className="stats-drawer-metric-value">{formatDurationMs(span.ttft ?? null)}</div>
195
+ </div>
196
+ <div className="stats-drawer-metric-card">
197
+ <div className="stats-drawer-metric-label">Model</div>
198
+ <div className="stats-drawer-metric-value" style={{ fontSize: 12 }}>
199
+ {msg?.model ?? span.model ?? "-"}
200
+ </div>
201
+ {msg?.provider && <div className="stats-drawer-metric-sub">{msg.provider}</div>}
202
+ {msg?.stopReason && <div className="stats-drawer-metric-sub">stop: {msg.stopReason}</div>}
203
+ </div>
204
+ </>
205
+ )}
206
+ </div>
207
+
208
+ {msg?.errorMessage && (
209
+ <div className="stats-drawer-error-block">
210
+ <div className="stats-drawer-error-label">Error Message</div>
211
+ <div className="stats-drawer-error-text">{msg.errorMessage}</div>
212
+ </div>
213
+ )}
214
+
215
+ {span.kind === "subagent" && (
216
+ <div>
217
+ {span.detail && (
218
+ <pre
219
+ style={{
220
+ whiteSpace: "pre-wrap",
221
+ wordBreak: "break-word",
222
+ fontSize: 12,
223
+ background: "var(--surface-2)",
224
+ borderRadius: 6,
225
+ padding: 10,
226
+ }}
227
+ >
228
+ {span.detail}
229
+ </pre>
230
+ )}
231
+ {span.childTrackId && onOpenChildTrack && (
232
+ <button
233
+ type="button"
234
+ className="stats-segmented-control-btn"
235
+ style={{ marginTop: 8, border: "1px solid var(--border)", borderRadius: 6 }}
236
+ onClick={() => onOpenChildTrack(span.childTrackId ?? "")}
237
+ >
238
+ Open child track {span.childTrackId}
239
+ </button>
240
+ )}
241
+ </div>
242
+ )}
243
+
244
+ {span.kind === "tool" && span.detail && (
245
+ <div>
246
+ <div className="stats-drawer-metric-label">Args</div>
247
+ <pre
248
+ style={{
249
+ whiteSpace: "pre-wrap",
250
+ wordBreak: "break-word",
251
+ fontSize: 12,
252
+ background: "var(--surface-2)",
253
+ borderRadius: 6,
254
+ padding: 10,
255
+ }}
256
+ >
257
+ {span.detail}
258
+ </pre>
259
+ </div>
260
+ )}
261
+
262
+ {loading && (
263
+ <div>
264
+ <Skeleton variant="rect" width="100%" height={80} className="mb-4" />
265
+ <Skeleton variant="rect" width="100%" height={160} />
266
+ </div>
267
+ )}
268
+ {error && (
269
+ <div className="stats-drawer-error">
270
+ <p className="stats-drawer-error-title">Failed to load entry</p>
271
+ <p className="stats-drawer-error-message">{error.message}</p>
272
+ </div>
273
+ )}
274
+
275
+ {!loading &&
276
+ entry !== null &&
277
+ textBlocks(msg?.content).map((block, index) => (
278
+ <div key={`${block.kind}-${index}`}>
279
+ <div className="stats-drawer-metric-label" style={{ textTransform: "capitalize" }}>
280
+ {block.kind}
281
+ </div>
282
+ <pre
283
+ style={{
284
+ whiteSpace: "pre-wrap",
285
+ wordBreak: "break-word",
286
+ fontSize: 12,
287
+ background: "var(--surface-2)",
288
+ borderRadius: 6,
289
+ padding: 10,
290
+ maxHeight: 360,
291
+ overflowY: "auto",
292
+ }}
293
+ >
294
+ {block.text}
295
+ </pre>
296
+ </div>
297
+ ))}
298
+
299
+ {!loading && entry !== null && (
300
+ <div className="stats-drawer-json-blocks">
301
+ <JsonBlock data={entry} title="Raw Entry" initialCollapsed={true} />
302
+ </div>
303
+ )}
304
+ </div>
305
+ </div>
306
+ </div>
307
+ </div>
308
+ );
309
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Headline chips for one trace: wall/model/tool/idle time, turns, requests,
3
+ * tool calls, agents, tokens, cost.
4
+ */
5
+
6
+ import { formatCompact, formatCost, formatDurationMs, formatInteger } from "../data/formatters";
7
+ import type { TraceSummary } from "../types";
8
+
9
+ export interface SummaryStripProps {
10
+ summary: TraceSummary;
11
+ }
12
+
13
+ export function SummaryStrip({ summary }: SummaryStripProps) {
14
+ const chips: Array<{ label: string; value: string }> = [
15
+ { label: "Wall", value: formatDurationMs(summary.wallMs) },
16
+ { label: "Model", value: formatDurationMs(summary.modelMs) },
17
+ { label: "Tools", value: formatDurationMs(summary.toolMs) },
18
+ { label: "Idle", value: formatDurationMs(summary.idleMs) },
19
+ { label: "Turns", value: formatInteger(summary.turns) },
20
+ { label: "Requests", value: formatInteger(summary.requests) },
21
+ { label: "Tool Calls", value: formatInteger(summary.toolCalls) },
22
+ { label: "Agents", value: formatInteger(summary.subagents) },
23
+ { label: "Tokens", value: formatCompact(summary.totalTokens) },
24
+ { label: "Cost", value: formatCost(summary.costTotal) },
25
+ ];
26
+
27
+ return (
28
+ <div className="stats-trace-summary">
29
+ {chips.map(chip => (
30
+ <div key={chip.label} className="stats-trace-summary-cell">
31
+ <span className="stats-trace-summary-label">{chip.label}</span>
32
+ <span className="stats-trace-summary-value">{chip.value}</span>
33
+ </div>
34
+ ))}
35
+ </div>
36
+ );
37
+ }