@narumitw/pi-inspect 0.0.0-stage → 0.1.0

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/LICENSE +21 -0
  2. package/README.md +107 -2
  3. package/dist/app.css +1 -0
  4. package/dist/app.js +129 -0
  5. package/dist/index.html +1 -0
  6. package/docs/REVIEW.md +189 -0
  7. package/docs/VERIFICATION.md +186 -0
  8. package/docs/viewer.md +63 -0
  9. package/package.json +67 -4
  10. package/src/ancestry.ts +32 -0
  11. package/src/collector.ts +143 -0
  12. package/src/context.ts +115 -0
  13. package/src/correlation.ts +27 -0
  14. package/src/entry-index.ts +17 -0
  15. package/src/extension.ts +259 -0
  16. package/src/feed.ts +145 -0
  17. package/src/identity.ts +112 -0
  18. package/src/index.ts +1 -0
  19. package/src/model.ts +113 -0
  20. package/src/native-projection.ts +28 -0
  21. package/src/privacy.ts +136 -0
  22. package/src/projection.ts +361 -0
  23. package/src/server.ts +202 -0
  24. package/src/system-message.ts +117 -0
  25. package/src/web/api.ts +137 -0
  26. package/src/web/app.tsx +439 -0
  27. package/src/web/components.css +261 -0
  28. package/src/web/components.tsx +289 -0
  29. package/src/web/composition.css +554 -0
  30. package/src/web/context-composition.tsx +324 -0
  31. package/src/web/entry-calls.ts +14 -0
  32. package/src/web/format.ts +92 -0
  33. package/src/web/hierarchy.ts +117 -0
  34. package/src/web/inline-entry.tsx +124 -0
  35. package/src/web/inspector.tsx +337 -0
  36. package/src/web/json-tree.tsx +74 -0
  37. package/src/web/live-drawer.tsx +175 -0
  38. package/src/web/overview.tsx +67 -0
  39. package/src/web/panels.css +267 -0
  40. package/src/web/panes.tsx +123 -0
  41. package/src/web/search.ts +16 -0
  42. package/src/web/style.css +332 -0
  43. package/src/web/styles.d.ts +1 -0
  44. package/src/web/timeline.tsx +33 -0
  45. package/src/web/timing.ts +26 -0
  46. package/src/web/trace.css +410 -0
  47. package/src/web/trace.tsx +273 -0
  48. package/src/web/tree.tsx +281 -0
  49. package/src/web/virtual-context.ts +138 -0
@@ -0,0 +1,324 @@
1
+ import { ChevronDownIcon, ChevronRightIcon, MagnifyingGlassIcon } from "@radix-ui/react-icons";
2
+ import { Heading, TextField } from "@radix-ui/themes";
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import type { ContextComposition as Composition, ContextSegment, Snapshot } from "../model.js";
5
+ import { Data, Glyph, Metadata } from "./components.js";
6
+ import { output, record, time } from "./format.js";
7
+ import { boundedSearch, searchNeedle } from "./search.js";
8
+ import { useVirtualContext } from "./virtual-context.js";
9
+
10
+ const categories = ["system", "user", "assistant", "toolCall", "toolResult", "other"] as const;
11
+ const names: Record<string, string> = {
12
+ system: "System prompt",
13
+ user: "User message",
14
+ assistant: "Assistant",
15
+ toolCall: "Tool call",
16
+ toolResult: "Tool result",
17
+ other: "Other",
18
+ };
19
+ export function ContextComposition({
20
+ context,
21
+ payload,
22
+ }: {
23
+ context?: Composition;
24
+ payload?: Snapshot["providerObservation"];
25
+ }) {
26
+ const [query, search] = useState("");
27
+ const [category, filter] = useState("all");
28
+ const [expanded, expand] = useState(new Set<string>());
29
+ const [selected, select] = useState("");
30
+ const segments = context?.segments;
31
+ const needle = useMemo(() => searchNeedle(query), [query]);
32
+ const searchable = useMemo(
33
+ () => context?.messages.map((message) => output(message.value).toLowerCase()) ?? [],
34
+ [context?.messages],
35
+ );
36
+ const rows = useMemo(
37
+ () =>
38
+ (segments ?? []).filter((segment) => {
39
+ const message = searchable[segment.messageIndex] ?? "";
40
+ return (
41
+ (category === "all" || segment.category === category) &&
42
+ (`${segment.role} ${segment.kind} ${segment.preview}`.toLowerCase().includes(needle) ||
43
+ message.includes(needle))
44
+ );
45
+ }),
46
+ [segments, category, needle, searchable],
47
+ );
48
+ const ids = useMemo(() => rows.map((row) => row.id), [rows]);
49
+ const virtual = useVirtualContext(ids);
50
+ const [focused, focus] = useState("");
51
+ const visibleRows = rows.slice(virtual.range.start, virtual.range.end);
52
+ const tabTarget = visibleRows.some((row) => row.id === focused) ? focused : visibleRows[0]?.id;
53
+ useEffect(() => {
54
+ const valid = new Set(segments?.map((row) => row.id));
55
+ expand((old) => new Set([...old].filter((id) => valid.has(id))));
56
+ if (selected && !valid.has(selected)) select("");
57
+ }, [segments, selected]);
58
+ function toggle(id: string): void {
59
+ select(id);
60
+ expand((old) => {
61
+ const next = new Set(old);
62
+ if (next.has(id)) next.delete(id);
63
+ else next.add(id);
64
+ return next;
65
+ });
66
+ }
67
+ const bucket = Math.max(1, Math.ceil(rows.length / 160));
68
+ return (
69
+ <section className="context-composition" aria-label="Context composition">
70
+ <div className="composition-heading">
71
+ <div>
72
+ <Heading size="5">Context composition</Heading>
73
+ <span className="composition-count">
74
+ {rows.length} captured segments · {context?.totalMessages ?? 0} messages · segment tokens unavailable
75
+ </span>
76
+ </div>
77
+ <fieldset className="context-filters" aria-label="Context category">
78
+ {[
79
+ ["all", "All"],
80
+ ["system", "System"],
81
+ ["user", "User"],
82
+ ["assistant", "Assistant"],
83
+ ["toolCall", "Tool call"],
84
+ ["toolResult", "Tool result"],
85
+ ].map(([value, label]) => (
86
+ <button
87
+ type="button"
88
+ key={value}
89
+ aria-pressed={category === value}
90
+ onClick={() => {
91
+ filter(value ?? "all");
92
+ virtual.reset();
93
+ }}
94
+ >
95
+ {label}
96
+ </button>
97
+ ))}
98
+ </fieldset>
99
+ <TextField.Root
100
+ aria-label="Search context"
101
+ placeholder="Search captured context…"
102
+ value={query}
103
+ onChange={(event) => {
104
+ search(boundedSearch(event.target.value));
105
+ virtual.reset();
106
+ }}
107
+ >
108
+ <TextField.Slot>
109
+ <MagnifyingGlassIcon />
110
+ </TextField.Slot>
111
+ </TextField.Root>
112
+ </div>
113
+ <div className="context-provenance" role="status">
114
+ {context?.source === "observed-pi-context"
115
+ ? "Last observed Pi context · context_with_system stage; later hooks and provider serialization may differ."
116
+ : "Session-derived context · native active-leaf projection; request-local hooks and provider serialization are unavailable."}
117
+ {context?.observedAt && (
118
+ <span>
119
+ {" "}
120
+ Observed {new Date(context.observedAt).toLocaleTimeString()} · leaf {context.leafId ?? "unavailable"}
121
+ </span>
122
+ )}
123
+ {context?.incomplete && (
124
+ <strong> Bounded capture is incomplete; only available captured content is searchable.</strong>
125
+ )}
126
+ {context?.unavailable && <strong> Unavailable: {context.unavailable}.</strong>}
127
+ </div>
128
+ <div className="composition-body">
129
+ <section
130
+ className="context-scroll"
131
+ ref={virtual.scroller}
132
+ onScroll={virtual.refresh}
133
+ aria-label="Context segments"
134
+ >
135
+ <div className="virtual-segments" style={{ height: virtual.totalHeight }}>
136
+ {visibleRows.map((segment, localIndex) => {
137
+ const index = virtual.range.start + localIndex;
138
+ const position = virtual.positions[index];
139
+ const open = expanded.has(segment.id);
140
+ return (
141
+ <div
142
+ key={segment.id}
143
+ className="context-segment"
144
+ data-segment-id={segment.id}
145
+ data-category={segment.category}
146
+ style={{ top: position?.top }}
147
+ ref={(element) => {
148
+ if (element) virtual.elements.current.set(segment.id, element);
149
+ else virtual.elements.current.delete(segment.id);
150
+ }}
151
+ >
152
+ <button
153
+ type="button"
154
+ className={`segment-row ${selected === segment.id ? "selected" : ""}`}
155
+ tabIndex={tabTarget === segment.id ? 0 : -1}
156
+ aria-expanded={open}
157
+ aria-label={`${segment.position} ${segment.role === "developer" ? "Developer" : names[segment.category]} ${segment.preview}`}
158
+ onFocus={() => focus(segment.id)}
159
+ onClick={() => toggle(segment.id)}
160
+ onKeyDown={(event) => {
161
+ if (event.target !== event.currentTarget || event.ctrlKey || event.altKey || event.metaKey)
162
+ return;
163
+ if (event.key === "Enter" || event.key === " ") {
164
+ event.preventDefault();
165
+ toggle(segment.id);
166
+ } else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
167
+ event.preventDefault();
168
+ virtual.jump(
169
+ event.key === "Home"
170
+ ? 0
171
+ : event.key === "End"
172
+ ? rows.length - 1
173
+ : Math.max(0, Math.min(rows.length - 1, index + (event.key === "ArrowDown" ? 1 : -1))),
174
+ true,
175
+ );
176
+ } else if ((event.key === "ArrowRight" && !open) || (event.key === "ArrowLeft" && open)) {
177
+ event.preventDefault();
178
+ toggle(segment.id);
179
+ }
180
+ }}
181
+ >
182
+ <span className="segment-position">{segment.position}</span>
183
+ <Glyph kind={segment.category} />
184
+ <strong>
185
+ {segment.role === "developer" ? "Developer" : names[segment.category]}
186
+ {segment.category === "assistant" && segment.kind !== "assistant" && segment.kind !== "text"
187
+ ? ` · ${segment.kind}`
188
+ : ""}
189
+ </strong>
190
+ <span className="segment-preview" title={segment.preview}>
191
+ {segment.preview}
192
+ </span>
193
+ {segment.timestamp && <time title={segment.timestamp}>{time(segment.timestamp)}</time>}
194
+ <span className="segment-chevron" aria-hidden="true">
195
+ {open ? <ChevronDownIcon /> : <ChevronRightIcon />}
196
+ </span>
197
+ </button>
198
+ {open && context && <SegmentDetails key={segment.id} segment={segment} context={context} />}
199
+ </div>
200
+ );
201
+ })}
202
+ </div>
203
+ {!rows.length && (
204
+ <div className="empty-state">
205
+ No captured segments match. This view does not include unrelated session events.
206
+ </div>
207
+ )}
208
+ </section>
209
+ <nav className="context-minimap" aria-label="Context minimap">
210
+ {Array.from({ length: Math.ceil(rows.length / bucket) }, (_, index) => {
211
+ const start = index * bucket;
212
+ const end = Math.min(rows.length, start + bucket);
213
+ const group = rows.slice(start, end);
214
+ return (
215
+ <button
216
+ type="button"
217
+ key={group[0]?.id}
218
+ aria-label={`Jump to context position ${group[0]?.position}`}
219
+ title={`Filtered rows ${start + 1}–${end}`}
220
+ aria-current={start < virtual.viewport.end && end > virtual.viewport.start ? "true" : undefined}
221
+ onClick={() => virtual.jump(start, true)}
222
+ >
223
+ {categories.map((value) => {
224
+ const size = group.filter((row) => row.category === value).length;
225
+ return size ? <span key={value} data-category={value} style={{ flex: size }} /> : null;
226
+ })}
227
+ </button>
228
+ );
229
+ })}
230
+ </nav>
231
+ </div>
232
+ <div className="composition-footer">
233
+ <span>Messages and supported content blocks in source order · filtered minimap</span>
234
+ <span>
235
+ {Math.min(rows.length, virtual.range.start + 1)}–{Math.min(rows.length, virtual.range.end)} / {rows.length}
236
+ </span>
237
+ </div>
238
+ {payload && (
239
+ <details className="provider-observation">
240
+ <summary>
241
+ Advanced · independent provider observation (request association unavailable; may include warming/retries)
242
+ </summary>
243
+ <span>
244
+ Observed {new Date(payload.observedAt).toLocaleTimeString()} · not attributed to the context leaf/turn
245
+ </span>
246
+ <Data
247
+ label="Observed provider payload · redacted bounded copy"
248
+ data={payload.data}
249
+ scope="observed-provider-payload"
250
+ />
251
+ </details>
252
+ )}
253
+ </section>
254
+ );
255
+ }
256
+ function SegmentDetails({ segment, context }: { segment: ContextSegment; context: Composition }) {
257
+ const raw = context.messages[segment.messageIndex];
258
+ const message = record(raw?.value);
259
+ const block =
260
+ segment.blockIndex === undefined
261
+ ? message?.content === "" && message?.sections
262
+ ? message.sections
263
+ : (message?.content ?? message?.sections ?? raw?.value)
264
+ : Array.isArray(message?.content)
265
+ ? message.content[segment.blockIndex]
266
+ : undefined;
267
+ const item = record(block);
268
+ const content = item?.text ?? item?.thinking ?? item?.arguments ?? block;
269
+ return (
270
+ <div className="segment-details">
271
+ <section className="message-details">
272
+ <h3>Message details</h3>
273
+ <Metadata
274
+ rows={[
275
+ ["Role", segment.role],
276
+ ["Type", segment.kind],
277
+ ["Position", `${segment.position} / ${context.segments.length}`],
278
+ ["Source message", `${segment.messageIndex + 1} / ${context.totalMessages}`],
279
+ ...(segment.blockIndex === undefined
280
+ ? []
281
+ : [["Content block", String(segment.blockIndex + 1)] as [string, string]]),
282
+ ["Timestamp", segment.timestamp],
283
+ ["Tokens", "Unavailable for this segment"],
284
+ ]}
285
+ />
286
+ </section>
287
+ <div className="segment-content">
288
+ <Data
289
+ label="Content"
290
+ data={raw ? { value: content ?? null, truncated: raw.truncated } : undefined}
291
+ scope={`${segment.id}-content`}
292
+ />
293
+ {item?.type === "toolCall" && (
294
+ <Data
295
+ label={`Tool arguments · ${typeof item.name === "string" ? item.name : "unknown"}`}
296
+ data={{ value: item.arguments ?? null, truncated: Boolean(raw?.truncated) }}
297
+ scope={`${segment.id}-arguments`}
298
+ defaultOpen={false}
299
+ />
300
+ )}
301
+ <Data
302
+ label="Structured content"
303
+ data={raw ? { value: block ?? null, truncated: raw.truncated } : undefined}
304
+ scope={`${segment.id}-structured`}
305
+ defaultOpen={false}
306
+ />
307
+ <Data
308
+ label="Metadata"
309
+ data={
310
+ raw
311
+ ? {
312
+ value: Object.fromEntries(Object.entries(message ?? {}).filter(([key]) => key !== "content")),
313
+ truncated: raw.truncated,
314
+ }
315
+ : undefined
316
+ }
317
+ scope={`${segment.id}-metadata`}
318
+ defaultOpen={false}
319
+ />
320
+ <Data label="Raw JSON · captured message" data={raw} scope={`${segment.id}-raw`} defaultOpen={false} />
321
+ </div>
322
+ </div>
323
+ );
324
+ }
@@ -0,0 +1,14 @@
1
+ import { correlatedCalls } from "../correlation.js";
2
+ import type { Call, Capture } from "../model.js";
3
+ import { record } from "./format.js";
4
+ export function entryCalls(raw: Capture | undefined, calls: Call[], anchor?: string): Call[] {
5
+ const message = record(record(raw?.value)?.message);
6
+ const ids = new Set<string>();
7
+ if (message?.role === "toolResult" && typeof message.toolCallId === "string") ids.add(message.toolCallId);
8
+ if (message?.role === "assistant" && Array.isArray(message.content))
9
+ for (const block of message.content) {
10
+ const item = record(block);
11
+ if (item?.type === "toolCall" && typeof item.id === "string") ids.add(item.id);
12
+ }
13
+ return correlatedCalls(ids, anchor, calls);
14
+ }
@@ -0,0 +1,92 @@
1
+ import type { Call, Capture, EntrySummary, Json } from "../model.js";
2
+
3
+ export function output(value: Json): string {
4
+ return typeof value === "string" ? value : JSON.stringify(value, null, 2);
5
+ }
6
+ export function record(value: Json | undefined): { [key: string]: Json } | undefined {
7
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
8
+ }
9
+ export function duration(ms?: number): string {
10
+ return ms === undefined ? "—" : ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
11
+ }
12
+ export function count(value?: number): string {
13
+ return value === undefined ? "—" : value.toLocaleString("en-US");
14
+ }
15
+ export function time(value: string): string {
16
+ return /^\d{4}-\d\d-\d\dT/.test(value) ? value.slice(11, 19) : value || "—";
17
+ }
18
+ export function group(kind: string): string {
19
+ return ["assistant", "model_change", "thinking_level_change"].includes(kind)
20
+ ? "Model"
21
+ : kind === "toolResult"
22
+ ? "Tool"
23
+ : ["custom", "custom_message"].includes(kind)
24
+ ? "Custom"
25
+ : "Other";
26
+ }
27
+ export function eventName(kind: string): string {
28
+ return kind === "assistant" ? "model" : kind === "toolResult" ? "tool_result" : kind;
29
+ }
30
+ export interface Filters {
31
+ query: string;
32
+ kind: string;
33
+ groups: string[];
34
+ errorsOnly: boolean;
35
+ slowOnly: boolean;
36
+ }
37
+ export function matches(entry: EntrySummary, filters: Filters, calls: Call[]): boolean {
38
+ const call = calls.find(
39
+ (call) =>
40
+ call.id === entry.toolCallId &&
41
+ call.branchAnchor === entry.toolAnchor &&
42
+ !call.parentOccurrenceId &&
43
+ !call.correlationUnavailable,
44
+ );
45
+ return (
46
+ (filters.kind === "all" || entry.kind === filters.kind) &&
47
+ (!filters.groups.length || filters.groups.includes(group(entry.kind))) &&
48
+ (!filters.errorsOnly || entry.status === "error" || call?.status === "error") &&
49
+ (!filters.slowOnly || (call?.durationMs ?? 0) > 10000) &&
50
+ `${entry.kind} ${entry.name ?? ""} ${entry.label} ${entry.id}`.toLowerCase().includes(filters.query)
51
+ );
52
+ }
53
+ export function matchesCall(call: Call, filters: Filters): boolean {
54
+ return (
55
+ (!filters.groups.length || filters.groups.includes("Tool")) &&
56
+ (!filters.errorsOnly || call.status === "error") &&
57
+ (!filters.slowOnly || (call.durationMs ?? 0) > 10000) &&
58
+ `${call.name} ${call.id}`.toLowerCase().includes(filters.query) &&
59
+ (filters.kind === "all" || filters.kind === "toolResult")
60
+ );
61
+ }
62
+ export function promptDiff(before?: Capture, after?: Capture): Capture {
63
+ const left = before ? output(before.value).split("\n") : [];
64
+ const right = after ? output(after.value).split("\n") : [];
65
+ let start = 0;
66
+ let end = 0;
67
+ while (start < left.length && start < right.length && left[start] === right[start]) start++;
68
+ while (
69
+ end < left.length - start &&
70
+ end < right.length - start &&
71
+ left[left.length - end - 1] === right[right.length - end - 1]
72
+ )
73
+ end++;
74
+ return {
75
+ value:
76
+ [
77
+ ...left.slice(start, left.length - end).map((line) => `- ${line}`),
78
+ ...right.slice(start, right.length - end).map((line) => `+ ${line}`),
79
+ ].join("\n") || "No prompt change",
80
+ truncated: Boolean(before?.truncated || after?.truncated),
81
+ };
82
+ }
83
+ export function scripts(data?: Capture): Capture | undefined {
84
+ const message = record(record(data?.value)?.message);
85
+ if (!message || !Array.isArray(message.content)) return;
86
+ const code = message.content.flatMap((block) => {
87
+ const item = record(block);
88
+ const args = record(item?.arguments);
89
+ return item?.type === "toolCall" && item.name === "codemode" && typeof args?.code === "string" ? [args.code] : [];
90
+ });
91
+ return code.length ? { value: code.join("\n\n"), truncated: Boolean(data?.truncated) } : undefined;
92
+ }
@@ -0,0 +1,117 @@
1
+ export interface ParentNode {
2
+ id: string;
3
+ parentId: string | null;
4
+ }
5
+ export interface Hierarchy<T extends ParentNode> {
6
+ nodes: Map<string, T>;
7
+ children: Map<string, T[]>;
8
+ roots: T[];
9
+ }
10
+ export interface HierarchyRow<T> {
11
+ node: T;
12
+ depth: number;
13
+ childCount: number;
14
+ }
15
+ export function hierarchy<T extends ParentNode>(items: T[]): Hierarchy<T> {
16
+ const nodes = new Map(items.map((node) => [node.id, node]));
17
+ const children = new Map<string, T[]>();
18
+ const roots: T[] = [];
19
+ for (const node of items) {
20
+ if (!node.parentId || !nodes.has(node.parentId)) roots.push(node);
21
+ else {
22
+ const siblings = children.get(node.parentId) ?? [];
23
+ siblings.push(node);
24
+ children.set(node.parentId, siblings);
25
+ }
26
+ }
27
+ // Malformed rootless cycles remain inspectable; original parent IDs are never rewritten.
28
+ const reached = new Set<string>();
29
+ const stack = roots.slice();
30
+ const visit = () => {
31
+ while (stack.length) {
32
+ const node = stack.pop();
33
+ if (!node || reached.has(node.id)) continue;
34
+ reached.add(node.id);
35
+ stack.push(...(children.get(node.id) ?? []));
36
+ }
37
+ };
38
+ visit();
39
+ for (const node of items)
40
+ if (!reached.has(node.id)) {
41
+ roots.push(node);
42
+ stack.push(node);
43
+ visit();
44
+ }
45
+ return { nodes, children, roots };
46
+ }
47
+ export function ancestors<T extends ParentNode>(tree: Hierarchy<T>, id: string): string[] {
48
+ const result: string[] = [];
49
+ const seen = new Set([id]);
50
+ let parent = tree.nodes.get(id)?.parentId;
51
+ while (parent && tree.nodes.has(parent) && !seen.has(parent)) {
52
+ seen.add(parent);
53
+ result.push(parent);
54
+ parent = tree.nodes.get(parent)?.parentId;
55
+ }
56
+ return result;
57
+ }
58
+ export function withAncestors<T extends ParentNode>(tree: Hierarchy<T>, matches: Set<string>): Set<string> {
59
+ const keep = new Set(matches);
60
+ // Each parent edge is visited once even for very long linear histories.
61
+ for (const id of matches) {
62
+ let current = tree.nodes.get(id)?.parentId;
63
+ const seen = new Set([id]);
64
+ while (current && tree.nodes.has(current) && !seen.has(current)) {
65
+ seen.add(current);
66
+ if (keep.has(current)) break;
67
+ keep.add(current);
68
+ current = tree.nodes.get(current)?.parentId;
69
+ }
70
+ }
71
+ return keep;
72
+ }
73
+ export function flatten<T extends ParentNode>(
74
+ tree: Hierarchy<T>,
75
+ expanded: Set<string>,
76
+ keep?: Set<string>,
77
+ ): HierarchyRow<T>[] {
78
+ const rows: HierarchyRow<T>[] = [];
79
+ const seen = new Set<string>();
80
+ const stack = tree.roots
81
+ .slice()
82
+ .reverse()
83
+ .map((node) => ({ node, depth: 0 }));
84
+ while (stack.length) {
85
+ const row = stack.pop();
86
+ if (!row || seen.has(row.node.id) || (keep && !keep.has(row.node.id))) continue;
87
+ seen.add(row.node.id);
88
+ const children = (tree.children.get(row.node.id) ?? []).filter((node) => !keep || keep.has(node.id));
89
+ rows.push({ ...row, childCount: children.length });
90
+ if (expanded.has(row.node.id))
91
+ for (const node of children.slice().reverse()) stack.push({ node, depth: row.depth + 1 });
92
+ }
93
+ return rows;
94
+ }
95
+ export function reveal<T extends ParentNode>(tree: Hierarchy<T>, expanded: Set<string>, id: string): Set<string> {
96
+ return new Set([...expanded, ...ancestors(tree, id)]);
97
+ }
98
+ export function label(node: { kind: string; name?: string; label: string }): string {
99
+ if (node.name) return node.name;
100
+ if (node.label && !/^[[{]/.test(node.label.trim())) return node.label;
101
+ switch (node.kind) {
102
+ case "context_edit":
103
+ return "Context contribution updated";
104
+ case "compaction":
105
+ return "Compaction checkpoint";
106
+ case "branch_summary":
107
+ return "Branch summary";
108
+ case "custom":
109
+ return "Custom session entry";
110
+ case "label":
111
+ return "Entry label updated";
112
+ case "session_info":
113
+ return "Session metadata";
114
+ default:
115
+ return "Recorded session entry";
116
+ }
117
+ }
@@ -0,0 +1,124 @@
1
+ import { Button, Tabs, Text } from "@radix-ui/themes";
2
+ import type { EntrySummary } from "../model.js";
3
+ import { useDetail } from "./api.js";
4
+ import { Data, Metadata } from "./components.js";
5
+ import { count, eventName, record } from "./format.js";
6
+ import { label } from "./hierarchy.js";
7
+
8
+ export function InlineEntry({ entry, inspect }: { entry: EntrySummary; inspect(id: string): void }) {
9
+ const { detail, error, retry } = useDetail(entry.id);
10
+ const raw = record(detail?.raw.value);
11
+ const message = record(raw?.message);
12
+ const content = raw && Object.hasOwn(raw, "data") ? raw.data : message?.content;
13
+ const textBlocks =
14
+ Array.isArray(content) &&
15
+ content.every((block) => {
16
+ const item = record(block);
17
+ return item?.type === "text" && typeof item.text === "string";
18
+ })
19
+ ? content.map((block) => record(block)?.text).join("\n")
20
+ : undefined;
21
+ const preview = textBlocks ?? content;
22
+ const inputs = Array.isArray(message?.content)
23
+ ? message.content.flatMap((block) => {
24
+ const item = record(block);
25
+ return item?.type === "toolCall" ? [item.arguments ?? null] : [];
26
+ })
27
+ : [];
28
+ const usage = record(message?.usage);
29
+ const input = typeof usage?.input === "number" && usage.input >= 0 ? usage.input : undefined;
30
+ const output = typeof usage?.output === "number" && usage.output >= 0 ? usage.output : undefined;
31
+ const known = input !== undefined && output !== undefined;
32
+ return (
33
+ <div className="inline-entry">
34
+ <Tabs.Root defaultValue="overview" className="inline-tabs">
35
+ <Tabs.List>
36
+ <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
37
+ <Tabs.Trigger value="content">Content</Tabs.Trigger>
38
+ <Tabs.Trigger value="raw">Raw</Tabs.Trigger>
39
+ <Tabs.Trigger value="metadata">Metadata</Tabs.Trigger>
40
+ </Tabs.List>
41
+ <Tabs.Content value="overview">
42
+ <div className="inline-summary">
43
+ <Metadata
44
+ rows={[
45
+ ["Type", eventName(entry.kind)],
46
+ ["Description", label(entry)],
47
+ ["Parent ID", entry.parentId ?? "root"],
48
+ ["Timestamp", entry.timestamp],
49
+ ...(entry.tokens === undefined ? [] : [["Tokens", count(entry.tokens)] as [string, string]]),
50
+ ]}
51
+ />
52
+ {known && (
53
+ <div className="usage-breakdown">
54
+ <div className="usage-bar">
55
+ <span style={{ width: `${input + output ? (input / (input + output)) * 100 : 0}%` }} />
56
+ <span style={{ width: `${input + output ? (output / (input + output)) * 100 : 0}%` }} />
57
+ </div>
58
+ <span>
59
+ {count(input)} input · {count(output)} output (cache not included)
60
+ </span>
61
+ </div>
62
+ )}
63
+ </div>
64
+ {content !== undefined && (
65
+ <Data
66
+ label={entry.kind === "toolResult" ? "Recorded tool output" : "Recorded content"}
67
+ data={{ value: preview ?? null, truncated: Boolean(detail?.raw.truncated) }}
68
+ scope={`${entry.id}-content`}
69
+ />
70
+ )}
71
+ {inputs.length > 0 && (
72
+ <Data
73
+ label="Recorded tool arguments"
74
+ data={{ value: inputs, truncated: Boolean(detail?.raw.truncated) }}
75
+ scope={`${entry.id}-arguments`}
76
+ defaultOpen={false}
77
+ />
78
+ )}
79
+ <Button size="1" variant="ghost" onClick={() => inspect(entry.id)}>
80
+ Inspect complete entry →
81
+ </Button>
82
+ </Tabs.Content>
83
+ <Tabs.Content value="content">
84
+ <Data
85
+ label="Recorded content"
86
+ data={
87
+ content === undefined ? undefined : { value: preview ?? null, truncated: Boolean(detail?.raw.truncated) }
88
+ }
89
+ scope={`${entry.id}-content`}
90
+ />
91
+ </Tabs.Content>
92
+ <Tabs.Content value="raw">
93
+ <Data label="Raw entry · redacted display copy" data={detail?.raw} scope={`${entry.id}-raw`} />
94
+ </Tabs.Content>
95
+ <Tabs.Content value="metadata">
96
+ <Metadata
97
+ rows={[
98
+ ["ID", entry.id],
99
+ ["Parent ID", entry.parentId ?? "root"],
100
+ ["Timestamp", entry.timestamp],
101
+ ["Source", "Session log entry, not an execution span"],
102
+ ]}
103
+ />
104
+ <Data label="Contribution at this node" data={detail?.projected} scope={`${entry.id}-projection`} />
105
+ </Tabs.Content>
106
+ </Tabs.Root>
107
+ {error && (
108
+ <div role="alert">
109
+ <Text size="1" color="red">
110
+ {error}
111
+ </Text>
112
+ <Button size="1" variant="ghost" onClick={retry}>
113
+ Retry inline details
114
+ </Button>
115
+ </div>
116
+ )}
117
+ {!detail && !error && (
118
+ <Text size="1" color="gray">
119
+ Loading bounded entry details…
120
+ </Text>
121
+ )}
122
+ </div>
123
+ );
124
+ }