@narumitw/pi-inspect 0.0.0-stage → 0.2.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -2
  3. package/dist/app.css +1 -0
  4. package/dist/app.js +128 -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 +74 -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/entry-summary.ts +34 -0
  16. package/src/extension.ts +259 -0
  17. package/src/feed.ts +145 -0
  18. package/src/identity.ts +112 -0
  19. package/src/index.ts +1 -0
  20. package/src/model.ts +117 -0
  21. package/src/native-projection.ts +28 -0
  22. package/src/privacy.ts +136 -0
  23. package/src/projection.ts +374 -0
  24. package/src/server.ts +202 -0
  25. package/src/system-message.ts +117 -0
  26. package/src/web/api.ts +137 -0
  27. package/src/web/app.tsx +570 -0
  28. package/src/web/captured-executions.tsx +177 -0
  29. package/src/web/components.css +261 -0
  30. package/src/web/components.tsx +241 -0
  31. package/src/web/composition.css +554 -0
  32. package/src/web/context-composition.tsx +329 -0
  33. package/src/web/context-inventory.tsx +65 -0
  34. package/src/web/entry-calls.ts +14 -0
  35. package/src/web/format.ts +94 -0
  36. package/src/web/hierarchy.ts +117 -0
  37. package/src/web/history.ts +56 -0
  38. package/src/web/inline-entry.tsx +124 -0
  39. package/src/web/inspector.tsx +386 -0
  40. package/src/web/json-tree.tsx +74 -0
  41. package/src/web/overview.tsx +67 -0
  42. package/src/web/panels.css +267 -0
  43. package/src/web/panes.tsx +123 -0
  44. package/src/web/recorded-content.tsx +49 -0
  45. package/src/web/search.ts +16 -0
  46. package/src/web/session-filters.tsx +100 -0
  47. package/src/web/session.css +114 -0
  48. package/src/web/style.css +332 -0
  49. package/src/web/styles.d.ts +1 -0
  50. package/src/web/timeline.tsx +33 -0
  51. package/src/web/timing.ts +26 -0
  52. package/src/web/trace.css +411 -0
  53. package/src/web/trace.tsx +313 -0
  54. package/src/web/virtual-context.ts +138 -0
@@ -0,0 +1,177 @@
1
+ import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons";
2
+ import { Button, Heading, Text } from "@radix-ui/themes";
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import type { Call, EntrySummary } from "../model.js";
5
+ import { Status } from "./components.js";
6
+ import { duration, type Filters, matchesCall, output } from "./format.js";
7
+ import { flatten, hierarchy, reveal, withAncestors } from "./hierarchy.js";
8
+ import { searchNeedle } from "./search.js";
9
+ import { TimelineMark } from "./timeline.js";
10
+ import { axis } from "./timing.js";
11
+
12
+ export function CapturedExecutions({
13
+ calls,
14
+ entries,
15
+ selected,
16
+ select,
17
+ open,
18
+ view,
19
+ changeView,
20
+ dropped,
21
+ invalidEvents,
22
+ filters,
23
+ revealSelected,
24
+ }: {
25
+ calls: Call[];
26
+ entries: EntrySummary[];
27
+ selected?: string;
28
+ select(id: string): void;
29
+ open: boolean;
30
+ view: string;
31
+ changeView(view: string): void;
32
+ dropped: number;
33
+ invalidEvents: number;
34
+ filters: Filters;
35
+ revealSelected: boolean;
36
+ }) {
37
+ const preparedFilters = useMemo(() => ({ ...filters, query: searchNeedle(filters.query) }), [filters]);
38
+ const tree = useMemo(
39
+ () =>
40
+ hierarchy(
41
+ calls.map((call) => ({
42
+ ...call,
43
+ id: call.occurrenceId,
44
+ rawId: call.id,
45
+ rawParentId: call.parentId,
46
+ parentId: call.parentOccurrenceId ?? null,
47
+ })),
48
+ ),
49
+ [calls],
50
+ );
51
+ const keep = useMemo(
52
+ () =>
53
+ withAncestors(
54
+ tree,
55
+ new Set([
56
+ ...calls.filter((call) => matchesCall(call, preparedFilters)).map((call) => call.occurrenceId),
57
+ ...(revealSelected && selected ? [selected] : []),
58
+ ]),
59
+ ),
60
+ [tree, calls, preparedFilters, revealSelected, selected],
61
+ );
62
+ const [expanded, setExpanded] = useState(new Set<string>());
63
+ const previous = useRef<string | undefined>(undefined);
64
+ useEffect(() => {
65
+ const chain: string[] = [];
66
+ let current = selected;
67
+ const visited = new Set<string>();
68
+ while (current && tree.nodes.has(current) && !visited.has(current)) {
69
+ visited.add(current);
70
+ chain.push(current);
71
+ current = tree.nodes.get(current)?.parentId ?? undefined;
72
+ }
73
+ const identity = chain.join("/");
74
+ if (selected && identity !== previous.current && tree.nodes.has(selected)) {
75
+ previous.current = identity;
76
+ setExpanded((old) => reveal(tree, old, selected));
77
+ }
78
+ }, [selected, tree]);
79
+ const rows = flatten(tree, expanded, keep);
80
+ const indent = Math.min(12, 140 / Math.max(1, ...rows.map((row) => row.depth)));
81
+ const range = axis(entries, calls);
82
+ return (
83
+ <section className={`live-drawer ${open ? "drawer-open" : ""}`}>
84
+ <div className="live-drawer-toggle">
85
+ <Heading size="3">Captured executions</Heading>
86
+ <span>
87
+ {calls.length} captured · {calls.filter((call) => call.status === "error").length} errors · {dropped} evicted
88
+ </span>
89
+ <span className="drawer-scope">Session-wide · observed events</span>
90
+ </div>
91
+ {open && (
92
+ <div className="live-drawer-body">
93
+ {invalidEvents > 0 && (
94
+ <Text size="1" color="gray">
95
+ {invalidEvents} invalid live events omitted; correlation unavailable
96
+ </Text>
97
+ )}
98
+ <div className="live-controls">
99
+ <Text size="1" color="gray">
100
+ Reported durations are monotonic execute() timings; bars are observed callback intervals.
101
+ </Text>
102
+ <div className="view-switch">
103
+ {["list", "timeline"].map((mode) => (
104
+ <button type="button" key={mode} aria-pressed={view === mode} onClick={() => changeView(mode)}>
105
+ {mode === "list" ? "List" : "Timeline"}
106
+ </button>
107
+ ))}
108
+ </div>
109
+ <Button size="1" variant="ghost" disabled={!keep.size} onClick={() => setExpanded(new Set(keep))}>
110
+ Expand calls
111
+ </Button>
112
+ <Button size="1" variant="ghost" onClick={() => setExpanded(new Set())}>
113
+ Collapse calls
114
+ </Button>
115
+ </div>
116
+ {!rows.length && <div className="empty-state">No captured executions match these filters.</div>}
117
+ {rows.map(({ node: call, depth }) => (
118
+ <div
119
+ key={call.id}
120
+ id={`live-call-${call.occurrenceId}`}
121
+ className="live-trace-item"
122
+ data-parent-id={call.parentId ?? ""}
123
+ data-raw-id={call.rawId}
124
+ style={{ marginLeft: depth * indent }}
125
+ >
126
+ <div className={`live-call-row ${selected === call.id ? "selected" : ""}`}>
127
+ <button
128
+ type="button"
129
+ className="expand-button"
130
+ aria-label={`Expand call ${call.id}`}
131
+ aria-expanded={expanded.has(call.id)}
132
+ onClick={() =>
133
+ setExpanded((old) => {
134
+ const next = new Set(old);
135
+ if (next.has(call.id)) next.delete(call.id);
136
+ else next.add(call.id);
137
+ return next;
138
+ })
139
+ }
140
+ >
141
+ {expanded.has(call.id) ? <ChevronDownIcon /> : <ChevronRightIcon />}
142
+ </button>
143
+ <button
144
+ type="button"
145
+ className="call-trigger"
146
+ aria-label={`${call.name} · ${call.status}`}
147
+ onClick={() => select(call.id)}
148
+ >
149
+ <strong title={call.name}>{call.name}</strong>
150
+ <span className="call-summary" title={output(call.args.value)}>
151
+ {output(call.args.value).replace(/\s+/g, " ").slice(0, 120)}
152
+ </span>
153
+ <code title={call.rawId}>{call.rawId.slice(0, 16)}</code>
154
+ <Status value={call.status} />
155
+ <span>{duration(call.durationMs)}</span>
156
+ </button>
157
+ {view === "timeline" && (
158
+ <TimelineMark
159
+ range={range}
160
+ call={{
161
+ ...call,
162
+ id: call.rawId,
163
+ parentId: call.parentOccurrenceId
164
+ ? (calls.find((parent) => parent.occurrenceId === call.parentOccurrenceId)?.id ??
165
+ call.rawParentId)
166
+ : call.rawParentId,
167
+ }}
168
+ />
169
+ )}
170
+ </div>
171
+ </div>
172
+ ))}
173
+ </div>
174
+ )}
175
+ </section>
176
+ );
177
+ }
@@ -0,0 +1,261 @@
1
+ .event-glyph {
2
+ width: 20px;
3
+ height: 20px;
4
+ flex-shrink: 0;
5
+ display: inline-grid;
6
+ place-items: center;
7
+ color: var(--secondary);
8
+ }
9
+ .event-glyph svg {
10
+ width: 15px;
11
+ height: 15px;
12
+ }
13
+ .glyph-model {
14
+ color: #b2a4dd;
15
+ }
16
+ .glyph-tool {
17
+ color: #72b4c2;
18
+ }
19
+ .glyph-custom {
20
+ color: #af9ecb;
21
+ }
22
+ .glyph-thinking {
23
+ color: #78bdab;
24
+ }
25
+ .status {
26
+ display: inline-flex;
27
+ align-items: center;
28
+ gap: 4px;
29
+ font-size: 10px;
30
+ line-height: 15px;
31
+ color: var(--muted);
32
+ white-space: nowrap;
33
+ }
34
+ .status svg {
35
+ width: 11px;
36
+ height: 11px;
37
+ }
38
+ .status-success,
39
+ .status-ok {
40
+ color: #32c48d;
41
+ }
42
+ .status-error,
43
+ .status-cancelled {
44
+ color: #ed7373;
45
+ }
46
+ .status-running {
47
+ color: #e5b45c;
48
+ }
49
+ .status-dot {
50
+ width: 5px;
51
+ height: 5px;
52
+ border-radius: 50%;
53
+ display: inline-block;
54
+ background: currentColor;
55
+ }
56
+ .view-switch {
57
+ display: inline-flex;
58
+ gap: 1px;
59
+ padding: 2px;
60
+ border-radius: 4px;
61
+ background: var(--code-bg);
62
+ }
63
+ .view-switch button {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 5px;
67
+ padding: 4px 8px;
68
+ border: 0;
69
+ border-radius: 3px;
70
+ color: var(--secondary);
71
+ background: transparent;
72
+ font-size: 11px;
73
+ }
74
+ .view-switch button[aria-pressed="true"] {
75
+ background: var(--selected-bg);
76
+ color: var(--blue-11);
77
+ }
78
+ .metadata {
79
+ margin: 0;
80
+ font-size: 11px;
81
+ line-height: 17px;
82
+ }
83
+ .metadata > div {
84
+ display: grid;
85
+ grid-template-columns: minmax(85px, 36%) minmax(0, 1fr);
86
+ gap: 10px;
87
+ padding: 4px 0;
88
+ }
89
+ .metadata dt {
90
+ color: var(--secondary);
91
+ }
92
+ .metadata dd {
93
+ margin: 0;
94
+ min-width: 0;
95
+ font-variant-numeric: tabular-nums;
96
+ overflow-wrap: anywhere;
97
+ }
98
+ .call {
99
+ padding: 8px 10px;
100
+ background: var(--code-bg);
101
+ border-left: 1px solid var(--panel-border);
102
+ }
103
+ .call .rt-Text {
104
+ font-size: 12px;
105
+ }
106
+ .parent-link {
107
+ display: flex;
108
+ align-items: center;
109
+ gap: 5px;
110
+ color: var(--blue-11);
111
+ font-size: 11px;
112
+ margin: 7px 0;
113
+ }
114
+ .data {
115
+ border-top: 1px solid var(--panel-border);
116
+ margin-top: 7px;
117
+ min-width: 0;
118
+ }
119
+ .data-toolbar {
120
+ display: flex;
121
+ justify-content: space-between;
122
+ align-items: center;
123
+ gap: 6px;
124
+ padding: 5px 0;
125
+ min-width: 0;
126
+ }
127
+ .section-toggle {
128
+ display: flex;
129
+ flex: 1;
130
+ min-width: 0;
131
+ align-items: center;
132
+ gap: 5px;
133
+ background: none;
134
+ border: 0;
135
+ color: var(--secondary);
136
+ text-align: left;
137
+ font-size: 11px;
138
+ line-height: 16px;
139
+ padding: 2px 0;
140
+ }
141
+ .section-toggle svg {
142
+ width: 12px;
143
+ height: 12px;
144
+ flex-shrink: 0;
145
+ }
146
+ .chevron-open {
147
+ transform: rotate(90deg);
148
+ }
149
+ .copy-button {
150
+ color: var(--muted);
151
+ font-size: 10px;
152
+ }
153
+ .data-mode {
154
+ display: flex;
155
+ align-items: center;
156
+ gap: 3px;
157
+ font-size: 10px;
158
+ margin-bottom: 4px;
159
+ color: var(--muted);
160
+ }
161
+ .data-mode button {
162
+ border: 0;
163
+ background: none;
164
+ color: var(--secondary);
165
+ padding: 3px 5px;
166
+ font-size: 10px;
167
+ }
168
+ .data-mode button[aria-pressed="true"] {
169
+ color: var(--blue-11);
170
+ background: var(--selected-bg);
171
+ border-radius: 3px;
172
+ }
173
+ .data-mode > span {
174
+ margin-left: auto;
175
+ }
176
+ .code-preview {
177
+ font:
178
+ 11px / 1.6 "Cascadia Code",
179
+ Consolas,
180
+ monospace;
181
+ white-space: pre;
182
+ overflow-x: auto;
183
+ margin: 0;
184
+ padding: 4px 0 8px;
185
+ }
186
+ .code-line {
187
+ display: flex;
188
+ min-width: max-content;
189
+ }
190
+ .line-number {
191
+ width: 32px;
192
+ text-align: right;
193
+ padding-right: 10px;
194
+ flex-shrink: 0;
195
+ color: var(--muted);
196
+ user-select: none;
197
+ }
198
+ .code-line code {
199
+ font: inherit;
200
+ }
201
+ .json-key {
202
+ color: #82b5e8;
203
+ }
204
+ .json-string {
205
+ color: #d8b28c;
206
+ }
207
+ .json-number {
208
+ color: #b7a4d9;
209
+ }
210
+ .light .json-key {
211
+ color: #245585;
212
+ }
213
+ .light .json-string {
214
+ color: #885523;
215
+ }
216
+ .light .json-number {
217
+ color: #6b409c;
218
+ }
219
+ .json-tree {
220
+ font:
221
+ 11px / 1.7 Consolas,
222
+ monospace;
223
+ border: 0;
224
+ margin: 0;
225
+ padding: 2px 0 6px;
226
+ min-width: 0;
227
+ overflow-x: auto;
228
+ }
229
+ .json-object {
230
+ min-width: max-content;
231
+ }
232
+ .json-object summary {
233
+ cursor: pointer;
234
+ white-space: nowrap;
235
+ color: var(--secondary);
236
+ }
237
+ .json-summary {
238
+ color: var(--muted);
239
+ font-size: 10px;
240
+ }
241
+ .json-children {
242
+ padding-left: 16px;
243
+ border-left: 1px solid var(--panel-border);
244
+ margin-left: 5px;
245
+ }
246
+ .json-leaf {
247
+ white-space: pre;
248
+ min-width: max-content;
249
+ }
250
+ .preview-limit {
251
+ display: block;
252
+ padding: 6px;
253
+ color: var(--amber-11);
254
+ white-space: normal;
255
+ }
256
+ .preview-image {
257
+ max-width: 100%;
258
+ max-height: 220px;
259
+ object-fit: contain;
260
+ margin-top: 6px;
261
+ }
@@ -0,0 +1,241 @@
1
+ import {
2
+ ChatBubbleIcon,
3
+ CheckIcon,
4
+ ChevronRightIcon,
5
+ ClipboardCopyIcon,
6
+ CubeIcon,
7
+ ExclamationTriangleIcon,
8
+ FileTextIcon,
9
+ GearIcon,
10
+ LightningBoltIcon,
11
+ PersonIcon,
12
+ StackIcon,
13
+ } from "@radix-ui/react-icons";
14
+ import { Badge, Button, Flex } from "@radix-ui/themes";
15
+ import { useEffect, useRef, useState } from "react";
16
+ import type { Capture, Json } from "../model.js";
17
+ import { group, output } from "./format.js";
18
+ import { JsonTree } from "./json-tree.js";
19
+
20
+ export function Glyph({ kind }: { kind: string }) {
21
+ const Icon =
22
+ kind === "toolResult"
23
+ ? StackIcon
24
+ : kind === "toolCall"
25
+ ? GearIcon
26
+ : kind === "assistant"
27
+ ? ChatBubbleIcon
28
+ : kind === "user"
29
+ ? PersonIcon
30
+ : kind === "thinking_level_change"
31
+ ? GearIcon
32
+ : group(kind) === "Model"
33
+ ? CubeIcon
34
+ : group(kind) === "Tool"
35
+ ? LightningBoltIcon
36
+ : kind === "system"
37
+ ? FileTextIcon
38
+ : CubeIcon;
39
+ return (
40
+ <span
41
+ className={`event-glyph glyph-${group(kind).toLowerCase()} ${kind === "thinking_level_change" ? "glyph-thinking" : ""}`}
42
+ >
43
+ <Icon />
44
+ </span>
45
+ );
46
+ }
47
+ export function Status({ value = "recorded" }: { value?: string }) {
48
+ const label =
49
+ value === "ok" || value === "success"
50
+ ? "Success"
51
+ : value === "error"
52
+ ? "Error"
53
+ : value === "running"
54
+ ? "Running"
55
+ : value === "cancelled"
56
+ ? "Cancelled"
57
+ : value === "unfinished"
58
+ ? "Unfinished"
59
+ : "Recorded";
60
+ return (
61
+ <span className={`status status-${value}`}>
62
+ {label === "Success" ? (
63
+ <CheckIcon />
64
+ ) : label === "Error" ? (
65
+ <ExclamationTriangleIcon />
66
+ ) : (
67
+ <span className="status-dot" />
68
+ )}
69
+ {label}
70
+ </span>
71
+ );
72
+ }
73
+ function images(value: Json, found: { data: string; mimeType: string }[] = []): typeof found {
74
+ if (!value || typeof value !== "object" || found.length >= 4) return found;
75
+ if (
76
+ !Array.isArray(value) &&
77
+ value.type === "image" &&
78
+ typeof value.data === "string" &&
79
+ typeof value.mimeType === "string" &&
80
+ /^(image\/(png|jpeg|gif|webp))$/.test(value.mimeType) &&
81
+ value.data.length <= 16384 &&
82
+ /^[A-Za-z0-9+/]*={0,2}$/.test(value.data)
83
+ ) {
84
+ if (!found.some((image) => image.data === value.data && image.mimeType === value.mimeType))
85
+ found.push({ data: value.data, mimeType: value.mimeType });
86
+ } else for (const child of Object.values(value)) images(child, found);
87
+ return found;
88
+ }
89
+ export function Copy({ value }: { value: string }) {
90
+ const mounted = useRef(false);
91
+ const [feedback, setFeedback] = useState("");
92
+ const [pending, setPending] = useState(false);
93
+ useEffect(() => {
94
+ mounted.current = true;
95
+ return () => {
96
+ mounted.current = false;
97
+ };
98
+ }, []);
99
+ async function copy() {
100
+ setPending(true);
101
+ try {
102
+ await navigator.clipboard.writeText(value);
103
+ if (mounted.current) setFeedback("Copied");
104
+ } catch {
105
+ if (mounted.current) setFeedback("Copy failed");
106
+ } finally {
107
+ if (mounted.current) setPending(false);
108
+ }
109
+ }
110
+ return (
111
+ <Button
112
+ size="1"
113
+ variant="ghost"
114
+ className="copy-button"
115
+ disabled={pending}
116
+ onClick={() => void copy()}
117
+ aria-label="Copy display data"
118
+ >
119
+ <ClipboardCopyIcon />
120
+ {feedback || "Copy"}
121
+ </Button>
122
+ );
123
+ }
124
+ function colored(line: string, budget: { remaining: number }) {
125
+ if (line.length > 2000 || budget.remaining <= 0) return line;
126
+ const fragments = [];
127
+ let cursor = 0;
128
+ for (const match of line.matchAll(/("(?:[^"\\]|\\.)*"|\b(?:true|false|null|\d+(?:\.\d+)?)\b)/g)) {
129
+ if (--budget.remaining < 0) return line;
130
+ if (match.index > cursor) fragments.push(line.slice(cursor, match.index));
131
+ const part = match[0];
132
+ fragments.push(
133
+ <span
134
+ key={match.index}
135
+ className={
136
+ part.startsWith('"')
137
+ ? /^\s*:/.test(line.slice(match.index + part.length))
138
+ ? "json-key"
139
+ : "json-string"
140
+ : "json-number"
141
+ }
142
+ >
143
+ {part}
144
+ </span>,
145
+ );
146
+ cursor = match.index + part.length;
147
+ }
148
+ fragments.push(line.slice(cursor));
149
+ return fragments;
150
+ }
151
+ export function Data({
152
+ data,
153
+ label,
154
+ defaultOpen = true,
155
+ scope = label,
156
+ }: {
157
+ data?: Capture;
158
+ label: string;
159
+ defaultOpen?: boolean;
160
+ scope?: string;
161
+ }) {
162
+ const [open, setOpen] = useState(defaultOpen);
163
+ const [format, setFormat] = useState("tree");
164
+ const value = data ? output(data.value) : "unavailable";
165
+ const lines = value.split("\n");
166
+ const budget = { remaining: 512 };
167
+ let sourceOffset = 0;
168
+ const structured = data?.value !== null && typeof data?.value === "object";
169
+ return (
170
+ <section className="data">
171
+ <div className="data-toolbar">
172
+ <button type="button" className="section-toggle" aria-expanded={open} onClick={() => setOpen(!open)}>
173
+ <ChevronRightIcon className={open ? "chevron-open" : ""} />
174
+ <span>{label}</span>
175
+ </button>
176
+ <Flex align="center" gap="2">
177
+ {data?.truncated && <Badge color="amber">truncated</Badge>}
178
+ {data && <Copy key={value} value={value} />}
179
+ </Flex>
180
+ </div>
181
+ {open && (
182
+ <div>
183
+ {structured && (
184
+ <div className="data-mode">
185
+ {["tree", "text"].map((mode) => (
186
+ <button type="button" key={mode} aria-pressed={format === mode} onClick={() => setFormat(mode)}>
187
+ {mode === "tree" ? "Objects" : "JSON text"}
188
+ </button>
189
+ ))}
190
+ <span>Redacted display copy</span>
191
+ </div>
192
+ )}
193
+ {structured && format === "tree" && data ? (
194
+ <JsonTree value={data.value} scope={scope} />
195
+ ) : (
196
+ <pre className="code-preview">
197
+ {lines.slice(0, 1000).map((line, index) => {
198
+ const start = sourceOffset;
199
+ sourceOffset += line.length + 1;
200
+ return (
201
+ <span className="code-line" key={`line-${start}`}>
202
+ <span className="line-number" aria-hidden="true">
203
+ {index + 1}
204
+ </span>
205
+ <code>{colored(line, budget)}</code>
206
+ </span>
207
+ );
208
+ })}
209
+ {lines.length > 1000 && (
210
+ <span className="preview-limit">
211
+ Preview limited to 1,000 lines; Copy includes the bounded display value.
212
+ </span>
213
+ )}
214
+ </pre>
215
+ )}
216
+ {data &&
217
+ images(data.value).map((image) => (
218
+ <img
219
+ key={`${image.mimeType}-${image.data}`}
220
+ className="preview-image"
221
+ src={`data:${image.mimeType};base64,${image.data}`}
222
+ alt="Captured raster tool output"
223
+ />
224
+ ))}
225
+ </div>
226
+ )}
227
+ </section>
228
+ );
229
+ }
230
+ export function Metadata({ rows }: { rows: [string, string | undefined][] }) {
231
+ return (
232
+ <dl className="metadata">
233
+ {rows.map(([key, value]) => (
234
+ <div key={key}>
235
+ <dt>{key}</dt>
236
+ <dd title={value}>{value || "unavailable"}</dd>
237
+ </div>
238
+ ))}
239
+ </dl>
240
+ );
241
+ }