@tonyclaw/llm-inspector 1.16.4 → 1.16.5

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 (56) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/CompareDrawer-C1w4KUGZ.js +1 -0
  3. package/.output/public/assets/ReplayDialog-DR2Sgq_g.js +1 -0
  4. package/.output/public/assets/RequestAnatomy-DAre35kj.js +1 -0
  5. package/.output/public/assets/ResponseView-ackes7_g.js +1 -0
  6. package/.output/public/assets/StreamingChunkSequence-GrXwIGKA.js +1 -0
  7. package/.output/public/assets/index-BGzHFOEX.css +1 -0
  8. package/.output/public/assets/index-DX88k9br.js +101 -0
  9. package/.output/public/assets/json-viewer-C_QUhGeu.js +14 -0
  10. package/.output/public/assets/{main-DbWwVQFh.js → main-CDMdNDY_.js} +1 -1
  11. package/.output/server/_libs/lucide-react.mjs +104 -84
  12. package/.output/server/_ssr/CompareDrawer-ftkJxyk6.mjs +1040 -0
  13. package/.output/server/_ssr/ReplayDialog-DcmE3lj5.mjs +321 -0
  14. package/.output/server/_ssr/RequestAnatomy-rK_LNMdG.mjs +351 -0
  15. package/.output/server/_ssr/ResponseView-CbQ4n-aJ.mjs +601 -0
  16. package/.output/server/_ssr/StreamingChunkSequence-84FZkIzv.mjs +301 -0
  17. package/.output/server/_ssr/{index-C-z-fZtq.mjs → index-CDjLoMsk.mjs} +1026 -2455
  18. package/.output/server/_ssr/index.mjs +2 -2
  19. package/.output/server/_ssr/json-viewer-B-qpM5xC.mjs +510 -0
  20. package/.output/server/_ssr/{router-CNM9Kbi0.mjs → router-BrdjOUEW.mjs} +24 -14
  21. package/.output/server/{_tanstack-start-manifest_v-BWfLeIsC.mjs → _tanstack-start-manifest_v-DmOZEcJ3.mjs} +1 -1
  22. package/.output/server/index.mjs +68 -26
  23. package/package.json +1 -1
  24. package/src/components/OnboardingBanner.tsx +2 -2
  25. package/src/components/ProxyViewer.tsx +38 -26
  26. package/src/components/ProxyViewerContainer.tsx +3 -24
  27. package/src/components/proxy-viewer/ConversationGroup.tsx +1 -1
  28. package/src/components/proxy-viewer/ConversationHeader.tsx +4 -1
  29. package/src/components/proxy-viewer/LogEntry.tsx +213 -181
  30. package/src/components/proxy-viewer/LogEntryHeader.tsx +134 -36
  31. package/src/components/proxy-viewer/ThreadConnector.tsx +17 -2
  32. package/src/components/proxy-viewer/TurnGroup.tsx +94 -71
  33. package/src/components/proxy-viewer/anatomy/RequestAnatomy.tsx +98 -0
  34. package/src/components/proxy-viewer/anatomy/SegmentBar.tsx +196 -0
  35. package/src/components/proxy-viewer/anatomy/tokenEstimate.ts +53 -0
  36. package/src/components/proxy-viewer/anatomy/types.ts +39 -0
  37. package/src/components/proxy-viewer/anatomy/useAnatomyJump.ts +114 -0
  38. package/src/components/proxy-viewer/formats/anthropic/ContentBlocks.tsx +3 -23
  39. package/src/components/proxy-viewer/formats/anthropic/thinkingExtract.ts +21 -0
  40. package/src/components/proxy-viewer/formats/openai/ResponseView.tsx +5 -3
  41. package/src/components/proxy-viewer/lazy.ts +37 -0
  42. package/src/components/proxy-viewer/log-formats/anthropic.ts +146 -0
  43. package/src/components/proxy-viewer/log-formats/openai.ts +127 -0
  44. package/src/components/proxy-viewer/log-formats/types.ts +7 -0
  45. package/src/components/proxy-viewer/log-formats/unknown.ts +4 -0
  46. package/src/components/proxy-viewer/logEntryVisibility.ts +39 -0
  47. package/src/components/proxy-viewer/useKeyboardNavigation.ts +190 -0
  48. package/src/components/proxy-viewer/viewerState.ts +8 -0
  49. package/src/components/ui/crab-variants.tsx +11 -0
  50. package/src/components/ui/json-expansion-button.tsx +56 -0
  51. package/src/components/ui/json-viewer-bulk.ts +97 -0
  52. package/src/components/ui/json-viewer.tsx +58 -183
  53. package/src/lib/utils.ts +2 -3
  54. package/src/routes/api/logs.stream.ts +26 -16
  55. package/.output/public/assets/index-DRRCmu5p.css +0 -1
  56. package/.output/public/assets/index-X7CHS7fS.js +0 -107
@@ -1,38 +1,46 @@
1
1
  import {
2
+ AlertTriangle,
2
3
  ArrowDown,
3
4
  ArrowUp,
5
+ Check,
4
6
  ChevronDown,
5
7
  ChevronRight,
8
+ ChevronsDown,
9
+ ChevronsUp,
6
10
  Clock,
11
+ Copy,
7
12
  FileTerminal,
8
13
  Globe,
9
14
  Loader2,
10
15
  MessageSquare,
16
+ OctagonAlert,
11
17
  Radio,
18
+ RotateCcw,
12
19
  Wrench,
13
20
  Zap,
14
21
  } from "lucide-react";
15
- import type { JSX } from "react";
22
+ import type { JSX, MouseEvent } from "react";
16
23
  import { memo, useMemo } from "react";
17
24
  import { cn, formatTokens, getStatusCategory, type StatusCategory } from "../../lib/utils";
18
25
  import type { CapturedLog } from "../../proxy/schemas";
19
26
  import { Badge } from "../ui/badge";
27
+ import { Button } from "../ui/button";
20
28
  import { ProviderLogo, detectProvider } from "../providers/ProviderLogo";
21
29
  import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
22
30
  import type { CacheTrend } from "./cacheTrend";
23
31
 
24
- function formatElapsed(ms: number): string {
25
- if (ms < 1000) return `${ms}ms`;
26
- return `${(ms / 1000).toFixed(1)}s`;
27
- }
28
-
29
32
  const STATUS_BADGE_CLASSES: Record<StatusCategory, string> = {
30
33
  success: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25",
31
34
  client_error: "bg-amber-500/15 text-amber-400 border-amber-500/25",
32
- server_error: "",
35
+ server_error: "bg-rose-500/15 text-rose-400 border-rose-500/25",
33
36
  pending: "bg-muted text-muted-foreground border-border",
34
37
  };
35
38
 
39
+ function formatElapsed(ms: number): string {
40
+ if (ms < 1000) return `${ms}ms`;
41
+ return `${(ms / 1000).toFixed(1)}s`;
42
+ }
43
+
36
44
  /**
37
45
  * Inline trend indicator: small arrow (green up / red down) plus the absolute
38
46
  * delta in compact form. Returns `null` when there is no trend to display.
@@ -68,6 +76,21 @@ export type LogEntryHeaderProps = {
68
76
  * the corresponding cache span renders as it did before — no arrow.
69
77
  */
70
78
  cacheTrend?: { creation: CacheTrend | null; read: CacheTrend | null } | null;
79
+ /** Re-send this request to the provider. Rendered in the header row when
80
+ * `expanded` is true. */
81
+ onReplay?: () => void;
82
+ /** Copy the request body to the clipboard. Omit to hide the button. */
83
+ onCopyRequest?: (event: MouseEvent) => void;
84
+ /** Whether the latest copy of the request body succeeded (shows "Copied!"). */
85
+ requestCopied?: boolean;
86
+ /** Toggle the JSON bulk-expansion state for the request body. */
87
+ onToggleRequestExpansion?: () => void;
88
+ /** Current state of the JSON bulk-expansion button. `null` means the
89
+ * request body is not JSON, so the button is hidden. */
90
+ requestExpansionState?: {
91
+ isExpanded: boolean;
92
+ isPending: boolean;
93
+ } | null;
71
94
  };
72
95
 
73
96
  export const LogEntryHeader = memo(function ({
@@ -78,6 +101,11 @@ export const LogEntryHeader = memo(function ({
78
101
  onToggle,
79
102
  responseToolNames = null,
80
103
  cacheTrend = null,
104
+ onReplay,
105
+ onCopyRequest,
106
+ requestCopied = false,
107
+ onToggleRequestExpansion,
108
+ requestExpansionState = null,
81
109
  }: LogEntryHeaderProps): JSX.Element {
82
110
  const statusCategory = getStatusCategory(log.responseStatus);
83
111
 
@@ -89,10 +117,13 @@ export const LogEntryHeader = memo(function ({
89
117
  <div
90
118
  role="button"
91
119
  tabIndex={0}
120
+ data-nav-id={`log-${log.id}`}
121
+ data-nav-action={expanded ? "collapse" : "expand"}
92
122
  className={cn(
93
123
  "flex items-center gap-2 px-3 py-1 cursor-pointer transition-colors",
94
124
  "hover:bg-muted/50",
95
125
  "select-none",
126
+ "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-none",
96
127
  )}
97
128
  onClick={onToggle}
98
129
  onKeyDown={(e) => {
@@ -119,38 +150,26 @@ export const LogEntryHeader = memo(function ({
119
150
  </Tooltip>
120
151
  )}
121
152
 
122
- {/* Response Status — only shown for non-200 or pending */}
153
+ {/* Response Status — only shown for non-200 or pending. Each category
154
+ carries a distinct icon in addition to color so the meaning is
155
+ legible without color perception. */}
123
156
  {statusCategory !== "success" && (
124
- <>
157
+ <Badge
158
+ variant="outline"
159
+ className={cn(
160
+ "text-[10px] px-1.5 py-0 h-5 font-mono tabular-nums gap-1",
161
+ STATUS_BADGE_CLASSES[statusCategory],
162
+ )}
163
+ >
125
164
  {statusCategory === "server_error" ? (
126
- <Badge
127
- variant="destructive"
128
- className="text-[10px] px-1.5 py-0 h-4 font-mono tabular-nums"
129
- >
130
- {log.responseStatus}
131
- </Badge>
165
+ <OctagonAlert className="size-3" aria-label="Server error" />
166
+ ) : statusCategory === "client_error" ? (
167
+ <AlertTriangle className="size-3" aria-label="Client error" />
132
168
  ) : statusCategory === "pending" ? (
133
- <Badge
134
- variant="outline"
135
- className={cn(
136
- "text-[10px] px-1.5 py-0 h-4 font-mono tabular-nums",
137
- STATUS_BADGE_CLASSES[statusCategory],
138
- )}
139
- >
140
- <Loader2 className="size-3 animate-spin" />
141
- </Badge>
142
- ) : (
143
- <Badge
144
- variant="outline"
145
- className={cn(
146
- "text-[10px] px-1.5 py-0 h-4 font-mono tabular-nums",
147
- STATUS_BADGE_CLASSES[statusCategory],
148
- )}
149
- >
150
- {log.responseStatus}
151
- </Badge>
152
- )}
153
- </>
169
+ <Loader2 className="size-3 animate-spin" aria-label="Pending" />
170
+ ) : null}
171
+ {log.responseStatus}
172
+ </Badge>
154
173
  )}
155
174
 
156
175
  {/* Elapsed time */}
@@ -279,6 +298,85 @@ export const LogEntryHeader = memo(function ({
279
298
  {/* Spacer */}
280
299
  <span className="flex-1 min-w-0" />
281
300
 
301
+ {/* Header actions — only when expanded, so the collapsed view stays
302
+ compact. Buttons stop propagation so they don't toggle the log. */}
303
+ {expanded && (
304
+ <span
305
+ className="flex items-center gap-1.5 shrink-0"
306
+ onClick={(e) => e.stopPropagation()}
307
+ onKeyDown={(e) => e.stopPropagation()}
308
+ >
309
+ {requestExpansionState !== null && onToggleRequestExpansion !== undefined && (
310
+ <Tooltip>
311
+ <TooltipTrigger asChild>
312
+ <Button
313
+ variant="outline"
314
+ size="icon"
315
+ className="size-8"
316
+ onClick={onToggleRequestExpansion}
317
+ disabled={requestExpansionState.isPending}
318
+ aria-pressed={requestExpansionState.isExpanded}
319
+ aria-label={
320
+ requestExpansionState.isExpanded ? "Collapse all JSON" : "Expand all JSON"
321
+ }
322
+ >
323
+ {requestExpansionState.isExpanded ? (
324
+ <ChevronsUp className="size-3.5" />
325
+ ) : (
326
+ <ChevronsDown className="size-3.5" />
327
+ )}
328
+ </Button>
329
+ </TooltipTrigger>
330
+ <TooltipContent>
331
+ {requestExpansionState.isExpanded
332
+ ? "Collapse all JSON nodes"
333
+ : "Expand all JSON nodes"}
334
+ </TooltipContent>
335
+ </Tooltip>
336
+ )}
337
+
338
+ {onCopyRequest !== undefined && (
339
+ <Tooltip>
340
+ <TooltipTrigger asChild>
341
+ <Button
342
+ variant="outline"
343
+ size="icon"
344
+ className="size-8"
345
+ onClick={onCopyRequest}
346
+ aria-label="Copy request body"
347
+ >
348
+ {requestCopied ? (
349
+ <Check className="size-3.5 text-emerald-500" />
350
+ ) : (
351
+ <Copy className="size-3.5" />
352
+ )}
353
+ </Button>
354
+ </TooltipTrigger>
355
+ <TooltipContent>
356
+ {requestCopied ? "Copied to clipboard" : "Copy request body"}
357
+ </TooltipContent>
358
+ </Tooltip>
359
+ )}
360
+
361
+ {onReplay !== undefined && (
362
+ <Tooltip>
363
+ <TooltipTrigger asChild>
364
+ <Button
365
+ variant="outline"
366
+ size="icon"
367
+ className="size-8"
368
+ onClick={onReplay}
369
+ aria-label="Replay request"
370
+ >
371
+ <RotateCcw className="size-3.5" />
372
+ </Button>
373
+ </TooltipTrigger>
374
+ <TooltipContent>Re-send this request to the provider</TooltipContent>
375
+ </Tooltip>
376
+ )}
377
+ </span>
378
+ )}
379
+
282
380
  {/* Expand chevron */}
283
381
  {expanded ? (
284
382
  <ChevronDown className="size-4 text-muted-foreground shrink-0" />
@@ -1,7 +1,7 @@
1
1
  import { type JSX, useMemo } from "react";
2
2
  import { cn } from "../../lib/utils";
3
3
  import type { StopReason } from "../../lib/stopReason";
4
- import { getCrabVariant } from "../ui/crab-variants";
4
+ import { getCrabVariant, getInteriorCrabVariant } from "../ui/crab-variants";
5
5
 
6
6
  export type ThreadConnectorProps = {
7
7
  stopReason: StopReason;
@@ -9,6 +9,8 @@ export type ThreadConnectorProps = {
9
9
  isFirst: boolean;
10
10
  /** True when this entry starts a new turn (first overall, or after end_turn/stop). */
11
11
  isTurnStart: boolean;
12
+ /** True when this entry is the turn's only request. */
13
+ isOnlyEntry?: boolean;
12
14
  /** Seed for crab variant selection (0-11). */
13
15
  crabIndex?: number;
14
16
  /** When true the crab is clickable (collapse / expand a turn). */
@@ -28,13 +30,16 @@ export function ThreadConnector({
28
30
  isPending,
29
31
  isFirst,
30
32
  isTurnStart,
33
+ isOnlyEntry = false,
31
34
  crabIndex = 0,
32
35
  collapsible = false,
33
36
  onToggle,
34
37
  }: ThreadConnectorProps): JSX.Element {
35
38
  const isBoundary = stopReason === "end_turn" || stopReason === "stop";
39
+ const isFusedBoundary = isOnlyEntry && isTurnStart && isBoundary;
36
40
  const isRunning = isPending && !isBoundary;
37
41
  const Crab = useMemo(() => getCrabVariant(crabIndex), [crabIndex]);
42
+ const FusedCrab = useMemo(() => getInteriorCrabVariant(crabIndex), [crabIndex]);
38
43
 
39
44
  const interactiveProps =
40
45
  collapsible && onToggle
@@ -68,7 +73,17 @@ export function ThreadConnector({
68
73
  {/* Crab — pinned to the fixed offset above so it never drifts
69
74
  * when the LogEntry expands / collapses. Clickable when the
70
75
  * turn is collapsible (replaces the old chevron toggle). */}
71
- {isBoundary ? (
76
+ {isFusedBoundary ? (
77
+ <span title="Start and end of turn">
78
+ <FusedCrab
79
+ className={cn(
80
+ "size-3.5 text-[#80FF00]",
81
+ "animate-crab-settle",
82
+ "drop-shadow-[0_0_4px_rgba(128,255,0,0.5)]",
83
+ )}
84
+ />
85
+ </span>
86
+ ) : isBoundary ? (
72
87
  <span
73
88
  title={stopReason === "end_turn" ? "End of Turn (Anthropic)" : "End of Turn (OpenAI)"}
74
89
  {...interactiveProps}
@@ -8,7 +8,7 @@ import { ProviderLogo, detectProvider, type Provider } from "../providers/Provid
8
8
  import type { CacheTrendEntry } from "./cacheTrend";
9
9
  import { LogEntry } from "./LogEntry";
10
10
  import { ThreadConnector } from "./ThreadConnector";
11
- import type { TurnEntry } from "./viewerState";
11
+ import { isTurnCollapsible, type TurnEntry } from "./viewerState";
12
12
 
13
13
  function formatElapsed(ms: number): string {
14
14
  if (ms < 1000) return `${ms}ms`;
@@ -38,17 +38,20 @@ export const TurnGroup = memo(function TurnGroup({
38
38
  const lastStop = entries[lastIdx]?.stopReason ?? null;
39
39
  const isComplete = lastStop !== null ? isTurnBoundary(lastStop) : false;
40
40
  const isPending = entries[lastIdx]?.log.responseStatus === null;
41
- const collapsible = isComplete && !isPending;
41
+ const isSingleLog = entries.length === 1;
42
+ const collapsible = isTurnCollapsible(entries.length, isComplete, isPending);
42
43
  const [collapsed, setCollapsed] = useState(false);
43
44
 
44
45
  // Auto-collapse when the turn finishes (transitions from incomplete → complete)
45
- const prevCompleteRef = useRef(isComplete);
46
+ const prevCompleteRef = useRef(false);
46
47
  useEffect(() => {
47
- if (isComplete && !prevCompleteRef.current) {
48
+ if (!collapsible) {
49
+ setCollapsed(false);
50
+ } else if (isComplete && !prevCompleteRef.current) {
48
51
  setCollapsed(true);
49
52
  }
50
53
  prevCompleteRef.current = isComplete;
51
- }, [isComplete]);
54
+ }, [collapsible, isComplete]);
52
55
 
53
56
  const toggleCollapse = useCallback(() => {
54
57
  if (collapsible) setCollapsed((prev) => !prev);
@@ -124,11 +127,30 @@ export const TurnGroup = memo(function TurnGroup({
124
127
  className={cn("border rounded-lg", isPending ? "border-amber-500/10" : "border-transparent")}
125
128
  >
126
129
  {collapsed ? (
127
- /* ---- Collapsed: dual-crab + summary ---- */
128
- <div className="flex items-stretch">
129
- {/* Dual-crab connector */}
130
+ /* ---- Collapsed: dual-crab (+ summary card for multi-log turns) ---- */
131
+ <div
132
+ data-nav-id={`turn-collapsed-${entries[0]?.log.id ?? turnIndex}`}
133
+ data-nav-action="expand"
134
+ role="button"
135
+ tabIndex={0}
136
+ className="flex items-stretch cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-none rounded-lg"
137
+ onClick={toggleCollapse}
138
+ onKeyDown={(e) => {
139
+ if (e.key === "Enter" || e.key === " ") {
140
+ e.preventDefault();
141
+ toggleCollapse();
142
+ }
143
+ }}
144
+ >
145
+ {/* Turn number */}
146
+ <div className="w-5 shrink-0 flex items-start pt-1.5">
147
+ <span className="text-[10px] text-muted-foreground/50 font-mono tabular-nums leading-none select-none">
148
+ {turnIndex + 1}
149
+ </span>
150
+ </div>
151
+
152
+ {/* Dual-crab connector for collapsed multi-request turns. */}
130
153
  <div className="w-6 shrink-0 flex flex-col items-center pt-0.5 pb-0.5">
131
- {/* Start turn crab (clickable to expand) */}
132
154
  <div className="flex justify-center h-[calc(0.75rem-8px)]" />
133
155
  <span
134
156
  role="button"
@@ -149,18 +171,15 @@ export const TurnGroup = memo(function TurnGroup({
149
171
  <StartCrab
150
172
  className={cn(
151
173
  "size-3.5 text-emerald-400",
152
- "animate-crab-appear",
153
- "drop-shadow-[0_0_4px_rgba(52,211,153,0.5)]",
174
+ "animate-crab-appear drop-shadow-[0_0_4px_rgba(52,211,153,0.5)]",
154
175
  )}
155
176
  />
156
177
  </span>
157
178
 
158
- {/* Connecting line between the two crabs */}
159
179
  <div className="flex-1 flex justify-center min-h-0">
160
180
  <div className="w-0.5 bg-muted-foreground/30 h-full" />
161
181
  </div>
162
182
 
163
- {/* End turn crab (clickable to expand) */}
164
183
  <span
165
184
  role="button"
166
185
  tabIndex={0}
@@ -180,76 +199,70 @@ export const TurnGroup = memo(function TurnGroup({
180
199
  <EndCrab
181
200
  className={cn(
182
201
  "size-3.5 text-amber-400",
183
- "animate-crab-settle",
184
- "drop-shadow-[0_0_4px_rgba(251,191,36,0.5)]",
202
+ "animate-crab-settle drop-shadow-[0_0_4px_rgba(251,191,36,0.5)]",
185
203
  )}
186
204
  />
187
205
  </span>
188
206
  </div>
189
207
 
190
- {/* Summary content — card-like appearance matching LogEntry */}
191
- <div
192
- className={cn(
193
- "flex-1 min-w-0 mb-0.5 rounded-lg border border-border py-1 px-3 flex items-center gap-3 text-xs cursor-pointer",
194
- bgClass,
195
- )}
196
- onClick={toggleCollapse}
197
- role="button"
198
- tabIndex={0}
199
- onKeyDown={(e) => {
200
- if (e.key === "Enter" || e.key === " ") {
201
- e.preventDefault();
202
- toggleCollapse();
203
- }
204
- }}
205
- >
206
- {/* ID range */}
207
- <span className="text-blue-400/80 font-mono font-semibold tabular-nums shrink-0">
208
- #{entries[0]?.log.id ?? "?"} ~ #{entries[lastIdx]?.log.id ?? "?"}
209
- </span>
210
-
211
- {/* Request count */}
212
- <span className="text-muted-foreground shrink-0">
213
- {entries.length} request{entries.length > 1 ? "s" : ""}
214
- </span>
208
+ {/* Summary content — hidden for single-log turns. */}
209
+ {entries.length > 1 && (
210
+ <div
211
+ className={cn(
212
+ "flex-1 min-w-0 mb-0.5 rounded-lg border border-border py-1 px-3 flex items-center gap-3 text-xs",
213
+ bgClass,
214
+ )}
215
+ >
216
+ {/* ID range */}
217
+ <span className="text-blue-400/80 font-mono font-semibold tabular-nums shrink-0">
218
+ #{entries[0]?.log.id ?? "?"} ~ #{entries[lastIdx]?.log.id ?? "?"}
219
+ </span>
215
220
 
216
- {/* Model logos — one per unique provider */}
217
- {uniqueProviders.length > 0 && (
218
- <span className="flex items-center gap-0.5 shrink-0">
219
- {uniqueProviders.map((p) => (
220
- <ProviderLogo key={p} provider={p} className="size-4" />
221
- ))}
221
+ {/* Request count */}
222
+ <span className="text-muted-foreground shrink-0">
223
+ {entries.length} request{entries.length > 1 ? "s" : ""}
222
224
  </span>
223
- )}
224
225
 
225
- {/* Elapsed */}
226
- {aggregate.hasElapsed && (
227
- <span className="flex items-center gap-1 text-muted-foreground shrink-0">
228
- <Clock className="size-3" />
229
- <span className="font-mono tabular-nums">
230
- {formatElapsed(aggregate.totalElapsed)}
226
+ {/* Model logos — one per unique provider */}
227
+ {uniqueProviders.length > 0 && (
228
+ <span className="flex items-center gap-0.5 shrink-0">
229
+ {uniqueProviders.map((p) => (
230
+ <ProviderLogo key={p} provider={p} className="size-4" />
231
+ ))}
231
232
  </span>
232
- </span>
233
- )}
233
+ )}
234
234
 
235
- {/* Tokens */}
236
- {aggregate.hasTokens && (
237
- <span className="flex items-center gap-1 shrink-0">
238
- <Zap className="size-3 text-muted-foreground" />
239
- <span className="font-mono tabular-nums">
240
- <span className="text-blue-400">IN {formatTokens(aggregate.totalInput)}</span>
241
- {" / "}
242
- <span className="text-amber-400">OUT {formatTokens(aggregate.totalOutput)}</span>
235
+ {/* Elapsed */}
236
+ {aggregate.hasElapsed && (
237
+ <span className="flex items-center gap-1 text-muted-foreground shrink-0">
238
+ <Clock className="size-3" />
239
+ <span className="font-mono tabular-nums">
240
+ {formatElapsed(aggregate.totalElapsed)}
241
+ </span>
243
242
  </span>
244
- </span>
245
- )}
243
+ )}
246
244
 
247
- {/* Spacer */}
248
- <span className="flex-1 min-w-0" />
245
+ {/* Tokens */}
246
+ {aggregate.hasTokens && (
247
+ <span className="flex items-center gap-1 shrink-0">
248
+ <Zap className="size-3 text-muted-foreground" />
249
+ <span className="font-mono tabular-nums">
250
+ <span className="text-blue-400">IN {formatTokens(aggregate.totalInput)}</span>
251
+ {" / "}
252
+ <span className="text-amber-400">
253
+ OUT {formatTokens(aggregate.totalOutput)}
254
+ </span>
255
+ </span>
256
+ </span>
257
+ )}
249
258
 
250
- {/* Expand chevron */}
251
- <ChevronRight className="size-4 text-muted-foreground shrink-0" />
252
- </div>
259
+ {/* Spacer */}
260
+ <span className="flex-1 min-w-0" />
261
+
262
+ {/* Expand chevron */}
263
+ <ChevronRight className="size-4 text-muted-foreground shrink-0" />
264
+ </div>
265
+ )}
253
266
  </div>
254
267
  ) : (
255
268
  /* ---- Expanded: full entries ---- */
@@ -259,13 +272,23 @@ export const TurnGroup = memo(function TurnGroup({
259
272
 
260
273
  return (
261
274
  <div key={log.id} className="flex items-stretch">
275
+ {isTurnStart ? (
276
+ <div className="w-5 shrink-0 flex items-start pt-1.5">
277
+ <span className="text-[10px] text-muted-foreground/50 font-mono tabular-nums leading-none select-none">
278
+ {turnIndex + 1}
279
+ </span>
280
+ </div>
281
+ ) : (
282
+ <div className="w-5 shrink-0" />
283
+ )}
262
284
  <ThreadConnector
263
285
  stopReason={reason}
264
286
  isPending={log.responseStatus === null}
265
287
  isFirst={visibleIdx === 0}
266
288
  isTurnStart={isTurnStart}
289
+ isOnlyEntry={isSingleLog}
267
290
  crabIndex={log.id % 12}
268
- collapsible={collapsible && entries.length > 1 && isTurnStart}
291
+ collapsible={collapsible && isTurnStart}
269
292
  onToggle={toggleCollapse}
270
293
  />
271
294
  <div className={cn("flex-1 min-w-0 mb-0.5 rounded-lg", bgClass)}>
@@ -0,0 +1,98 @@
1
+ import { Info } from "lucide-react";
2
+ import { type JSX, useMemo } from "react";
3
+ import { cn, formatTokens } from "../../../lib/utils";
4
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../ui/tooltip";
5
+ import { SegmentBar } from "./SegmentBar";
6
+ import type { AnatomySegment } from "./types";
7
+
8
+ export type RequestAnatomyProps = {
9
+ /** Parsed request body, or `null` if it cannot be parsed. */
10
+ parsed: unknown | null;
11
+ /** Server-reported input token count, or `null` if unknown. */
12
+ inputTokens: number | null;
13
+ /** Optional callback fired when the user activates a segment. */
14
+ onSegmentActivate?: (segment: AnatomySegment) => void;
15
+ /** Pre-computed segments; if provided, `parsed` is ignored. */
16
+ segments?: AnatomySegment[] | null;
17
+ };
18
+
19
+ const DIVERGENCE_AMBER_THRESHOLD = 0.25;
20
+
21
+ /**
22
+ * Render the request anatomy view: a one-line summary plus the
23
+ * stacked segment bar. Returns `null` when the request cannot be
24
+ * parsed or the segment list is empty.
25
+ */
26
+ export function RequestAnatomy({
27
+ parsed,
28
+ inputTokens,
29
+ onSegmentActivate,
30
+ segments: providedSegments,
31
+ }: RequestAnatomyProps): JSX.Element | null {
32
+ // Caller is expected to pre-compute segments in production (see
33
+ // LogEntry). When called standalone, fall back to deriving from
34
+ // `parsed` directly using the unknown adapter is not appropriate —
35
+ // the caller is responsible for choosing the right adapter.
36
+ const segments = useMemo(() => providedSegments ?? null, [providedSegments]);
37
+ const total = useMemo(() => (segments ?? []).reduce((sum, s) => sum + s.size, 0), [segments]);
38
+
39
+ // Show divergence warning when both estimate and server numbers exist.
40
+ const showDivergenceWarning = useMemo(() => {
41
+ if (segments === null || segments === undefined) return false;
42
+ if (inputTokens === null) return false;
43
+ if (total === 0) return false;
44
+ const ratio = Math.abs(inputTokens - total) / Math.max(inputTokens, total);
45
+ return ratio >= DIVERGENCE_AMBER_THRESHOLD;
46
+ }, [inputTokens, segments, total]);
47
+
48
+ const summaryColorClass = useMemo(() => {
49
+ if (inputTokens === null) return "text-muted-foreground";
50
+ if (showDivergenceWarning) return "text-amber-400";
51
+ return "text-muted-foreground";
52
+ }, [inputTokens, showDivergenceWarning]);
53
+
54
+ if (segments === null || segments === undefined) {
55
+ return null;
56
+ }
57
+ if (segments.length === 0) {
58
+ return null;
59
+ }
60
+ if (parsed === null && providedSegments === undefined) {
61
+ return null;
62
+ }
63
+
64
+ return (
65
+ <TooltipProvider delayDuration={150}>
66
+ <div className="px-4 py-3 space-y-2" data-testid="anatomy-root">
67
+ <div className="flex items-center gap-2 text-xs">
68
+ <span className="font-mono tabular-nums text-foreground">
69
+ ~{formatTokens(total)} tokens
70
+ </span>
71
+ {inputTokens !== null && (
72
+ <span className={cn("font-mono tabular-nums", summaryColorClass)}>
73
+ (server: {formatTokens(inputTokens)})
74
+ </span>
75
+ )}
76
+ {showDivergenceWarning && (
77
+ <Tooltip>
78
+ <TooltipTrigger asChild>
79
+ <button
80
+ type="button"
81
+ className="inline-flex items-center text-amber-400 hover:text-amber-300"
82
+ aria-label="Token estimate diverges from server"
83
+ >
84
+ <Info className="size-3.5" />
85
+ </button>
86
+ </TooltipTrigger>
87
+ <TooltipContent className="max-w-xs text-xs">
88
+ Bar uses a token estimate heuristic (~4 ASCII chars / token, ~1 CJK / emoji char per
89
+ token). The server&apos;s reported value is the source of truth for cost.
90
+ </TooltipContent>
91
+ </Tooltip>
92
+ )}
93
+ </div>
94
+ <SegmentBar segments={segments} totalTokens={total} onActivate={onSegmentActivate} />
95
+ </div>
96
+ </TooltipProvider>
97
+ );
98
+ }