@tonyclaw/agent-inspector 2.0.5 → 2.0.7

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 (49) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/CompareDrawer-BhRYPp6T.js +1 -0
  3. package/.output/public/assets/ProxyViewerContainer-BHq0dkQG.js +114 -0
  4. package/.output/public/assets/ReplayDialog-Ch0gwg6B.js +1 -0
  5. package/.output/public/assets/RequestAnatomy-CKbvIgaN.js +1 -0
  6. package/.output/public/assets/ResponseView-hRLar32x.js +1 -0
  7. package/.output/public/assets/StreamingChunkSequence-DCJ_gUHa.js +1 -0
  8. package/.output/public/assets/_sessionId-BJTz6t5l.js +1 -0
  9. package/.output/public/assets/index-Cf9U0Ekq.js +1 -0
  10. package/.output/public/assets/index-DTdv7nzl.css +1 -0
  11. package/.output/public/assets/{main-2NlGzgOe.js → main-BlDtK0Nn.js} +2 -2
  12. package/.output/server/_libs/lucide-react.mjs +170 -108
  13. package/.output/server/_libs/radix-ui__react-collapsible.mjs +2 -2
  14. package/.output/server/{_sessionId-DWCTasJU.mjs → _sessionId-Dy7rsLS4.mjs} +58 -5
  15. package/.output/server/_ssr/{CompareDrawer-DhrN1uC2.mjs → CompareDrawer-CkVX-ARV.mjs} +9 -8
  16. package/.output/server/_ssr/{ProxyViewerContainer-DRl51s_n.mjs → ProxyViewerContainer-B5a9p3j5.mjs} +856 -32
  17. package/.output/server/_ssr/{ReplayDialog-BQT_ygxC.mjs → ReplayDialog-hXY6H9fB.mjs} +7 -8
  18. package/.output/server/_ssr/RequestAnatomy-B6MS7VvA.mjs +576 -0
  19. package/.output/server/_ssr/{ResponseView-e0kL2C3x.mjs → ResponseView-ByjxwUnp.mjs} +5 -74
  20. package/.output/server/_ssr/{StreamingChunkSequence-BJG-m7xs.mjs → StreamingChunkSequence-C5Kb8siT.mjs} +8 -7
  21. package/.output/server/_ssr/{index-Dea3OeRw.mjs → index-BZXtj42m.mjs} +58 -5
  22. package/.output/server/_ssr/index.mjs +2 -2
  23. package/.output/server/_ssr/{router-Dl7oh0zx.mjs → router-CVQaVF_3.mjs} +5 -5
  24. package/.output/server/{_tanstack-start-manifest_v-m-FJNBVf.mjs → _tanstack-start-manifest_v-Bpe5ZC56.mjs} +1 -1
  25. package/.output/server/index.mjs +58 -65
  26. package/package.json +1 -1
  27. package/src/components/proxy-viewer/LogEntry.tsx +18 -4
  28. package/src/components/proxy-viewer/LogEntryHeader.tsx +3 -3
  29. package/src/components/proxy-viewer/RequestToolsPanel.tsx +292 -0
  30. package/src/components/proxy-viewer/anatomy/RequestAnatomy.tsx +196 -45
  31. package/src/components/proxy-viewer/anatomy/SegmentBar.tsx +92 -67
  32. package/src/components/proxy-viewer/anatomy/types.ts +15 -13
  33. package/src/components/proxy-viewer/log-formats/anthropic.ts +1 -1
  34. package/src/components/proxy-viewer/log-formats/openai.ts +1 -1
  35. package/src/components/proxy-viewer/log-formats/types.ts +1 -1
  36. package/src/components/proxy-viewer/requestTools.ts +213 -0
  37. package/src/components/ui/json-viewer.tsx +1 -1
  38. package/.output/public/assets/CompareDrawer-3nRwtk8J.js +0 -1
  39. package/.output/public/assets/ProxyViewerContainer-CbW5VRER.js +0 -101
  40. package/.output/public/assets/ReplayDialog-Cl62N9PI.js +0 -1
  41. package/.output/public/assets/RequestAnatomy-DgQWGvjs.js +0 -1
  42. package/.output/public/assets/ResponseView-Cvc-ct4E.js +0 -1
  43. package/.output/public/assets/StreamingChunkSequence-BCQaCAIe.js +0 -1
  44. package/.output/public/assets/_sessionId-CcD_aLGq.js +0 -1
  45. package/.output/public/assets/index-B_dffD3u.js +0 -1
  46. package/.output/public/assets/index-CX796gvi.css +0 -1
  47. package/.output/public/assets/json-viewer-IXejqXB0.js +0 -14
  48. package/.output/server/_ssr/RequestAnatomy-DS2tZOgq.mjs +0 -353
  49. package/.output/server/_ssr/json-viewer-DDU55MLK.mjs +0 -515
@@ -0,0 +1,292 @@
1
+ import {
2
+ Blocks,
3
+ Check,
4
+ ChevronDown,
5
+ ChevronRight,
6
+ Code2,
7
+ Copy,
8
+ FileText,
9
+ Globe,
10
+ MoreHorizontal,
11
+ MousePointerClick,
12
+ Search,
13
+ Terminal,
14
+ Wrench,
15
+ } from "lucide-react";
16
+ import { memo, useMemo, useState, type JSX } from "react";
17
+ import { cn } from "../../lib/utils";
18
+ import { Badge } from "../ui/badge";
19
+ import { Button } from "../ui/button";
20
+ import { Collapsible, CollapsibleContent } from "../ui/collapsible";
21
+ import { JsonViewer } from "../ui/json-viewer";
22
+ import { safeJsonValue } from "../ui/json-viewer-bulk";
23
+ import { ScrollArea } from "../ui/scroll-area";
24
+ import type {
25
+ RequestToolCategory,
26
+ RequestToolDefinition,
27
+ RequestToolsSummary,
28
+ } from "./requestTools";
29
+ import { useCopyFeedback } from "./useCopyFeedback";
30
+
31
+ type RequestToolsProps = {
32
+ summary: RequestToolsSummary | null;
33
+ calledToolNames: readonly string[];
34
+ };
35
+
36
+ const CATEGORY_LABELS: Record<RequestToolCategory, string> = {
37
+ file: "file",
38
+ shell: "shell",
39
+ browser: "browser",
40
+ web: "web",
41
+ mcp: "MCP",
42
+ code: "code",
43
+ other: "other",
44
+ };
45
+
46
+ const CATEGORY_CLASSES: Record<RequestToolCategory, string> = {
47
+ file: "border-blue-400/20 bg-blue-400/5 text-blue-300",
48
+ shell: "border-teal-400/20 bg-teal-400/5 text-teal-300",
49
+ browser: "border-cyan-400/20 bg-cyan-400/5 text-cyan-300",
50
+ web: "border-sky-400/20 bg-sky-400/5 text-sky-300",
51
+ mcp: "border-violet-400/20 bg-violet-400/5 text-violet-300",
52
+ code: "border-emerald-400/20 bg-emerald-400/5 text-emerald-300",
53
+ other: "border-muted-foreground/20 bg-muted/30 text-muted-foreground",
54
+ };
55
+
56
+ function CategoryIcon({ category }: { category: RequestToolCategory }): JSX.Element {
57
+ switch (category) {
58
+ case "file":
59
+ return <FileText className="size-3.5" />;
60
+ case "shell":
61
+ return <Terminal className="size-3.5" />;
62
+ case "browser":
63
+ return <MousePointerClick className="size-3.5" />;
64
+ case "web":
65
+ return <Globe className="size-3.5" />;
66
+ case "mcp":
67
+ return <Blocks className="size-3.5" />;
68
+ case "code":
69
+ return <Code2 className="size-3.5" />;
70
+ case "other":
71
+ return <MoreHorizontal className="size-3.5" />;
72
+ }
73
+ }
74
+
75
+ function RequiredParameters({ names }: { names: readonly string[] }): JSX.Element | null {
76
+ if (names.length === 0) return null;
77
+ return (
78
+ <div className="flex min-w-0 flex-wrap items-center gap-1">
79
+ <span className="text-[10px] uppercase tracking-wide text-muted-foreground">required</span>
80
+ {names.slice(0, 4).map((name) => (
81
+ <Badge
82
+ key={name}
83
+ variant="outline"
84
+ className="h-5 max-w-36 truncate rounded-md px-1.5 py-0 font-mono text-[10px]"
85
+ >
86
+ {name}
87
+ </Badge>
88
+ ))}
89
+ {names.length > 4 && (
90
+ <span className="font-mono text-[10px] text-muted-foreground">+{names.length - 4}</span>
91
+ )}
92
+ </div>
93
+ );
94
+ }
95
+
96
+ function ToolRow({ tool, called }: { tool: RequestToolDefinition; called: boolean }): JSX.Element {
97
+ const [open, setOpen] = useState(false);
98
+ const nameCopy = useCopyFeedback(tool.name);
99
+ const hasDetails =
100
+ tool.description !== null || tool.requiredParameters.length > 0 || tool.schema !== null;
101
+
102
+ return (
103
+ <Collapsible open={open} onOpenChange={setOpen}>
104
+ <div className="rounded-md border border-border/60 bg-background/40">
105
+ <div className="flex min-w-0 items-center gap-2 px-2.5 py-2">
106
+ <button
107
+ type="button"
108
+ className="flex min-w-0 flex-1 items-center gap-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
109
+ onClick={() => setOpen((value) => !value)}
110
+ aria-expanded={open}
111
+ disabled={!hasDetails}
112
+ >
113
+ <CategoryIcon category={tool.category} />
114
+ <span className="min-w-0 truncate font-mono text-xs font-semibold text-foreground/90">
115
+ {tool.name}
116
+ </span>
117
+ {called && (
118
+ <Badge
119
+ variant="outline"
120
+ className="h-5 rounded-md border-emerald-400/20 bg-emerald-400/5 px-1.5 py-0 text-[10px] text-emerald-300"
121
+ >
122
+ called
123
+ </Badge>
124
+ )}
125
+ <span className="font-mono text-[10px] text-muted-foreground">
126
+ {tool.parameterCount} params
127
+ </span>
128
+ {tool.descriptionPreview !== null && (
129
+ <span className="hidden min-w-0 truncate text-xs text-muted-foreground md:inline">
130
+ {tool.descriptionPreview}
131
+ </span>
132
+ )}
133
+ <span className="flex-1" />
134
+ {hasDetails &&
135
+ (open ? (
136
+ <ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
137
+ ) : (
138
+ <ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
139
+ ))}
140
+ </button>
141
+ <button
142
+ type="button"
143
+ className="inline-flex size-7 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
144
+ onClick={nameCopy.copy}
145
+ aria-label={nameCopy.copied ? "Copied tool name" : "Copy tool name"}
146
+ >
147
+ {nameCopy.copied ? (
148
+ <Check className="size-3.5 text-emerald-400" />
149
+ ) : (
150
+ <Copy className="size-3.5" />
151
+ )}
152
+ </button>
153
+ </div>
154
+ <CollapsibleContent>
155
+ <div className="space-y-2 border-t border-border/60 px-2.5 py-2">
156
+ {tool.description !== null && (
157
+ <p className="text-xs leading-relaxed text-muted-foreground">{tool.description}</p>
158
+ )}
159
+ <RequiredParameters names={tool.requiredParameters} />
160
+ {tool.schema !== null && (
161
+ <ScrollArea className="max-h-64 rounded-md border border-border/60 bg-muted/20 p-2">
162
+ <JsonViewer data={safeJsonValue(tool.schema)} defaultExpandDepth={0} />
163
+ </ScrollArea>
164
+ )}
165
+ </div>
166
+ </CollapsibleContent>
167
+ </div>
168
+ </Collapsible>
169
+ );
170
+ }
171
+
172
+ function matchesQuery(tool: RequestToolDefinition, query: string): boolean {
173
+ if (query.length === 0) return true;
174
+ const haystack = `${tool.name} ${tool.description ?? ""} ${CATEGORY_LABELS[tool.category]}`
175
+ .toLowerCase()
176
+ .trim();
177
+ return haystack.includes(query);
178
+ }
179
+
180
+ export const RequestTools = memo(function RequestTools({
181
+ summary,
182
+ calledToolNames,
183
+ }: RequestToolsProps): JSX.Element | null {
184
+ const [expanded, setExpanded] = useState(false);
185
+ const [query, setQuery] = useState("");
186
+ const calledNames = useMemo(() => new Set(calledToolNames), [calledToolNames]);
187
+ const normalizedQuery = query.trim().toLowerCase();
188
+ const visibleTools = useMemo(
189
+ () =>
190
+ summary === null ? [] : summary.tools.filter((tool) => matchesQuery(tool, normalizedQuery)),
191
+ [normalizedQuery, summary],
192
+ );
193
+
194
+ if (summary === null) return null;
195
+
196
+ const previewTools = summary.tools.slice(0, 6);
197
+ const hiddenPreviewCount = summary.tools.length - previewTools.length;
198
+
199
+ return (
200
+ <section className="mb-3 rounded-lg border border-border/70 bg-muted/20">
201
+ <div className="flex min-w-0 flex-wrap items-center gap-2 px-3 py-2">
202
+ <div className="flex min-w-0 flex-1 items-center gap-2">
203
+ <Wrench className="size-4 shrink-0 text-sky-300/80" />
204
+ <div className="min-w-0">
205
+ <div className="flex min-w-0 flex-wrap items-center gap-1.5">
206
+ <span className="text-sm font-semibold text-foreground">Request Tools</span>
207
+ <Badge variant="outline" className="h-5 rounded-md px-1.5 py-0 font-mono text-[10px]">
208
+ {summary.tools.length}
209
+ </Badge>
210
+ {summary.toolChoiceLabel !== null && (
211
+ <Badge
212
+ variant="outline"
213
+ className="h-5 rounded-md px-1.5 py-0 font-mono text-[10px] text-muted-foreground"
214
+ >
215
+ tool_choice: {summary.toolChoiceLabel}
216
+ </Badge>
217
+ )}
218
+ </div>
219
+ <div className="mt-1 flex min-w-0 flex-wrap gap-1">
220
+ {summary.categories.map((category) => (
221
+ <Badge
222
+ key={category.category}
223
+ variant="outline"
224
+ className={cn(
225
+ "h-5 rounded-md px-1.5 py-0 font-mono text-[10px]",
226
+ CATEGORY_CLASSES[category.category],
227
+ )}
228
+ >
229
+ {category.count} {CATEGORY_LABELS[category.category]}
230
+ </Badge>
231
+ ))}
232
+ </div>
233
+ </div>
234
+ </div>
235
+ <Button
236
+ type="button"
237
+ variant="ghost"
238
+ size="sm"
239
+ className="h-7 px-2"
240
+ onClick={() => setExpanded((value) => !value)}
241
+ aria-expanded={expanded}
242
+ >
243
+ {expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
244
+ {expanded ? "Hide" : "Show"}
245
+ </Button>
246
+ </div>
247
+
248
+ <div className="flex min-w-0 flex-wrap gap-1.5 border-t border-border/60 px-3 py-2">
249
+ {previewTools.map((tool) => (
250
+ <Badge
251
+ key={tool.name}
252
+ variant="outline"
253
+ className={cn(
254
+ "h-5 max-w-44 truncate rounded-md px-1.5 py-0 font-mono text-[10px]",
255
+ calledNames.has(tool.name) && "border-emerald-400/20 text-emerald-300",
256
+ )}
257
+ >
258
+ {tool.name}
259
+ </Badge>
260
+ ))}
261
+ {hiddenPreviewCount > 0 && (
262
+ <span className="font-mono text-[10px] leading-5 text-muted-foreground">
263
+ +{hiddenPreviewCount}
264
+ </span>
265
+ )}
266
+ </div>
267
+
268
+ {expanded && (
269
+ <div className="space-y-2 border-t border-border/60 px-3 py-3">
270
+ <label className="flex h-8 items-center gap-2 rounded-md border border-border/70 bg-background/50 px-2 text-xs">
271
+ <Search className="size-3.5 shrink-0 text-muted-foreground" />
272
+ <input
273
+ value={query}
274
+ onChange={(event) => setQuery(event.target.value)}
275
+ placeholder="Filter tools"
276
+ className="min-w-0 flex-1 bg-transparent font-mono text-xs outline-none placeholder:text-muted-foreground/70"
277
+ />
278
+ </label>
279
+ <div className="grid gap-1.5">
280
+ {visibleTools.length > 0 ? (
281
+ visibleTools.map((tool) => (
282
+ <ToolRow key={tool.name} tool={tool} called={calledNames.has(tool.name)} />
283
+ ))
284
+ ) : (
285
+ <p className="py-2 text-xs text-muted-foreground">No matching tools</p>
286
+ )}
287
+ </div>
288
+ </div>
289
+ )}
290
+ </section>
291
+ );
292
+ });
@@ -1,27 +1,77 @@
1
1
  import { Info } from "lucide-react";
2
- import { type JSX, useMemo } from "react";
2
+ import { type JSX, useMemo, useState } from "react";
3
3
  import { cn, formatTokens } from "../../../lib/utils";
4
4
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../ui/tooltip";
5
- import { SegmentBar } from "./SegmentBar";
6
- import type { AnatomySegment } from "./types";
5
+ import { ROLE_COLOR_CLASSES, SegmentBar } from "./SegmentBar";
6
+ import { ANATOMY_ROLE_LABELS, type AnatomyRole, type AnatomySegment } from "./types";
7
7
 
8
8
  export type RequestAnatomyProps = {
9
9
  /** Parsed request body, or `null` if it cannot be parsed. */
10
10
  parsed: unknown | null;
11
11
  /** Server-reported input token count, or `null` if unknown. */
12
12
  inputTokens: number | null;
13
- /** Optional callback fired when the user activates a segment. */
13
+ /** Optional callback fired when the user activates a concrete request segment. */
14
14
  onSegmentActivate?: (segment: AnatomySegment) => void;
15
15
  /** Pre-computed segments; if provided, `parsed` is ignored. */
16
16
  segments?: AnatomySegment[] | null;
17
17
  };
18
18
 
19
+ type ContextViewMode = "role" | "segment";
20
+
19
21
  const DIVERGENCE_AMBER_THRESHOLD = 0.25;
22
+ const TOP_CONTRIBUTOR_COUNT = 5;
23
+ const ROLE_ORDER: AnatomyRole[] = ["system", "user", "assistant", "tool", "tools"];
24
+
25
+ const ROLE_DESCRIPTIONS: Record<AnatomyRole, string> = {
26
+ system: "instructions",
27
+ user: "user turns",
28
+ assistant: "assistant turns",
29
+ tool: "tool results",
30
+ tools: "tool schemas",
31
+ };
32
+
33
+ const VIEW_MODE_OPTIONS: Array<{ value: ContextViewMode; label: string }> = [
34
+ { value: "role", label: "By Role" },
35
+ { value: "segment", label: "By Segment" },
36
+ ];
37
+
38
+ function formatPercent(value: number): string {
39
+ if (value >= 10) return `${value.toFixed(0)}%`;
40
+ if (value >= 1) return `${value.toFixed(1)}%`;
41
+ if (value > 0) return "<1%";
42
+ return "0%";
43
+ }
44
+
45
+ function aggregateByRole(segments: AnatomySegment[]): AnatomySegment[] {
46
+ const result: AnatomySegment[] = [];
47
+ for (const role of ROLE_ORDER) {
48
+ const matching = segments.filter((segment) => segment.role === role);
49
+ if (matching.length === 0) continue;
50
+ const size = matching.reduce((sum, segment) => sum + segment.size, 0);
51
+ const characters = matching.reduce((sum, segment) => sum + segment.characters, 0);
52
+ const label = ANATOMY_ROLE_LABELS[role];
53
+ const text = `${matching.length} segment${matching.length === 1 ? "" : "s"} grouped as ${
54
+ ROLE_DESCRIPTIONS[role]
55
+ }`;
56
+ result.push({
57
+ role,
58
+ label,
59
+ size,
60
+ characters,
61
+ text,
62
+ path: `role:${role}`,
63
+ });
64
+ }
65
+ return result;
66
+ }
67
+
68
+ function topContributors(segments: AnatomySegment[]): AnatomySegment[] {
69
+ return [...segments].sort((a, b) => b.size - a.size).slice(0, TOP_CONTRIBUTOR_COUNT);
70
+ }
20
71
 
21
72
  /**
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.
73
+ * Render a request context breakdown: headline metrics, a stacked token bar,
74
+ * role legend, and the largest individual context blocks.
25
75
  */
26
76
  export function RequestAnatomy({
27
77
  parsed,
@@ -29,69 +79,170 @@ export function RequestAnatomy({
29
79
  onSegmentActivate,
30
80
  segments: providedSegments,
31
81
  }: 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.
82
+ const [viewMode, setViewMode] = useState<ContextViewMode>("role");
36
83
  const segments = useMemo(() => providedSegments ?? null, [providedSegments]);
37
- const total = useMemo(() => (segments ?? []).reduce((sum, s) => sum + s.size, 0), [segments]);
84
+ const total = useMemo(
85
+ () => (segments ?? []).reduce((sum, segment) => sum + segment.size, 0),
86
+ [segments],
87
+ );
88
+ const roleSegments = useMemo(
89
+ () => (segments === null ? [] : aggregateByRole(segments)),
90
+ [segments],
91
+ );
92
+ const topSegments = useMemo(
93
+ () => (segments === null ? [] : topContributors(segments)),
94
+ [segments],
95
+ );
38
96
 
39
- // Show divergence warning when both estimate and server numbers exist.
40
97
  const showDivergenceWarning = useMemo(() => {
41
- if (segments === null || segments === undefined) return false;
98
+ if (segments === null) return false;
42
99
  if (inputTokens === null) return false;
43
100
  if (total === 0) return false;
44
101
  const ratio = Math.abs(inputTokens - total) / Math.max(inputTokens, total);
45
102
  return ratio >= DIVERGENCE_AMBER_THRESHOLD;
46
103
  }, [inputTokens, segments, total]);
47
104
 
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]);
105
+ if (segments === null) return null;
106
+ if (segments.length === 0) return null;
107
+ if (parsed === null && providedSegments === undefined) return null;
53
108
 
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
- }
109
+ const summaryColorClass =
110
+ inputTokens !== null && showDivergenceWarning ? "text-amber-400" : "text-muted-foreground";
111
+ const displayedSegments = viewMode === "role" ? roleSegments : segments;
112
+ const displayedTotal =
113
+ total > 0 ? total : displayedSegments.reduce((sum, item) => sum + item.size, 0);
114
+ const activateSegment = viewMode === "segment" ? onSegmentActivate : undefined;
115
+ const segmentCountLabel = `${String(segments.length)} segment${segments.length === 1 ? "" : "s"}`;
116
+ const providerInputLabel =
117
+ inputTokens === null ? "Provider input unknown" : `Provider input ${formatTokens(inputTokens)}`;
63
118
 
64
119
  return (
65
120
  <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 && (
121
+ <div className="px-4 py-3 space-y-3" data-testid="anatomy-root">
122
+ <div className="flex flex-wrap items-start justify-between gap-3">
123
+ <div className="min-w-0">
124
+ <div className="text-sm font-semibold text-foreground">Request Context</div>
125
+ <div className={cn("mt-0.5 text-xs font-mono tabular-nums", summaryColorClass)}>
126
+ Estimated ~{formatTokens(total)} tokens | {providerInputLabel} | {segmentCountLabel}
127
+ </div>
128
+ </div>
129
+
130
+ <div
131
+ className="inline-flex shrink-0 rounded border border-border bg-muted/20 p-0.5"
132
+ role="group"
133
+ aria-label="Context breakdown mode"
134
+ >
135
+ {VIEW_MODE_OPTIONS.map((option) => (
136
+ <button
137
+ key={option.value}
138
+ type="button"
139
+ aria-pressed={viewMode === option.value}
140
+ onClick={() => setViewMode(option.value)}
141
+ className={cn(
142
+ "h-6 rounded-sm px-2 text-[11px] font-medium transition-colors",
143
+ viewMode === option.value
144
+ ? "bg-background text-foreground shadow-sm"
145
+ : "text-muted-foreground hover:text-foreground",
146
+ )}
147
+ >
148
+ {option.label}
149
+ </button>
150
+ ))}
151
+ </div>
152
+ </div>
153
+
154
+ {showDivergenceWarning && (
155
+ <div className="inline-flex items-center gap-1.5 text-xs text-amber-400">
77
156
  <Tooltip>
78
157
  <TooltipTrigger asChild>
79
158
  <button
80
159
  type="button"
81
- className="inline-flex items-center text-amber-400 hover:text-amber-300"
82
- aria-label="Token estimate diverges from server"
160
+ className="inline-flex items-center hover:text-amber-300"
161
+ aria-label="Token estimate differs from provider input"
83
162
  >
84
163
  <Info className="size-3.5" />
85
164
  </button>
86
165
  </TooltipTrigger>
87
166
  <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.
167
+ The bar uses a local token estimate. Provider input tokens remain the source of
168
+ truth for billing and context-window usage.
90
169
  </TooltipContent>
91
170
  </Tooltip>
92
- )}
171
+ Estimate differs from provider-reported input.
172
+ </div>
173
+ )}
174
+
175
+ <SegmentBar
176
+ segments={displayedSegments}
177
+ totalTokens={displayedTotal}
178
+ showLabels={viewMode === "segment"}
179
+ onActivate={activateSegment}
180
+ />
181
+
182
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[11px] text-muted-foreground">
183
+ {roleSegments.map((segment) => {
184
+ const percent = total > 0 ? (segment.size / total) * 100 : 0;
185
+ return (
186
+ <div key={segment.role} className="inline-flex items-center gap-1.5">
187
+ <span
188
+ aria-hidden="true"
189
+ className={cn("size-2.5 rounded-[2px]", ROLE_COLOR_CLASSES[segment.role])}
190
+ />
191
+ <span>{segment.label}</span>
192
+ <span className="font-mono text-muted-foreground/70">{formatPercent(percent)}</span>
193
+ </div>
194
+ );
195
+ })}
196
+ </div>
197
+
198
+ <div className="space-y-1.5">
199
+ <div className="text-xs font-medium text-foreground">Top Contributors</div>
200
+ <div className="grid gap-1">
201
+ {topSegments.map((segment, index) => {
202
+ const percent = total > 0 ? (segment.size / total) * 100 : 0;
203
+ const activate = onSegmentActivate;
204
+ const content = (
205
+ <>
206
+ <span className="w-5 shrink-0 text-right font-mono text-muted-foreground/70">
207
+ {String(index + 1)}
208
+ </span>
209
+ <span
210
+ aria-hidden="true"
211
+ className={cn(
212
+ "size-2.5 shrink-0 rounded-[2px]",
213
+ ROLE_COLOR_CLASSES[segment.role],
214
+ )}
215
+ />
216
+ <span className="min-w-0 flex-1 truncate text-left">{segment.label}</span>
217
+ <span className="shrink-0 font-mono text-muted-foreground">
218
+ {formatPercent(percent)} | ~{formatTokens(segment.size)}
219
+ </span>
220
+ </>
221
+ );
222
+ if (activate !== undefined) {
223
+ return (
224
+ <button
225
+ key={`${segment.path}-${index}`}
226
+ type="button"
227
+ onClick={() => activate(segment)}
228
+ className="flex h-7 items-center gap-2 rounded px-1.5 text-xs text-muted-foreground hover:bg-muted/40 hover:text-foreground"
229
+ title="Jump to this request block"
230
+ >
231
+ {content}
232
+ </button>
233
+ );
234
+ }
235
+ return (
236
+ <div
237
+ key={`${segment.path}-${index}`}
238
+ className="flex h-7 items-center gap-2 rounded px-1.5 text-xs text-muted-foreground"
239
+ >
240
+ {content}
241
+ </div>
242
+ );
243
+ })}
244
+ </div>
93
245
  </div>
94
- <SegmentBar segments={segments} totalTokens={total} onActivate={onSegmentActivate} />
95
246
  </div>
96
247
  </TooltipProvider>
97
248
  );