@meterbility/server 0.3.1

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.
@@ -0,0 +1,3595 @@
1
+ // src/pretty.ts
2
+ import { fmtCents, fmtTokens } from "@meterbility/shared";
3
+ var DEFAULT_MAX_STR = 4096;
4
+ var DEFAULT_INDENT = 2;
5
+ var MAX_DEPTH = 16;
6
+ var INLINE_ARRAY_WIDTH = 80;
7
+ var BLOCK = "\u2503";
8
+ var DECISION_PREVIEW_LIMIT = 32e3;
9
+ var ANSI = {
10
+ section: ["\x1B[1m", "\x1B[22m"],
11
+ // bold
12
+ key: ["\x1B[2m", "\x1B[22m"],
13
+ // dim
14
+ str: ["\x1B[35m", "\x1B[39m"],
15
+ // magenta (≈ violet)
16
+ num: ["\x1B[36m", "\x1B[39m"],
17
+ // cyan
18
+ bool: ["\x1B[36m", "\x1B[39m"],
19
+ // cyan
20
+ null: ["\x1B[2m", "\x1B[22m"],
21
+ // dim
22
+ meta: ["\x1B[2m", "\x1B[22m"],
23
+ // dim
24
+ ok: ["\x1B[32m", "\x1B[39m"],
25
+ // green
26
+ error: ["\x1B[31m", "\x1B[39m"],
27
+ // red
28
+ pending: ["\x1B[33m", "\x1B[39m"],
29
+ // yellow
30
+ block: ["\x1B[2m", "\x1B[22m"]
31
+ // dim — for ┃ bar itself
32
+ };
33
+ function paint(text, color, mode) {
34
+ if (mode === "plain") return text;
35
+ if (mode === "html") return `<span class="p-${color}">${escHtml(text)}</span>`;
36
+ const [open, close] = ANSI[color];
37
+ return open + text + close;
38
+ }
39
+ function escHtml(s) {
40
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
41
+ }
42
+ function resolveMode(mode) {
43
+ if (mode === "ansi" && process.env.NO_COLOR) return "plain";
44
+ return mode;
45
+ }
46
+ function emDash(mode) {
47
+ return paint("\u2014", "null", mode);
48
+ }
49
+ function isPlainObject(v) {
50
+ return typeof v === "object" && v !== null && !Array.isArray(v);
51
+ }
52
+ function indentStr(n, opts) {
53
+ return " ".repeat(n * (opts.indent ?? DEFAULT_INDENT));
54
+ }
55
+ function maxKeyWidth(keys) {
56
+ let max = 0;
57
+ for (const k of keys) {
58
+ if (k.length > max) max = k.length;
59
+ }
60
+ return Math.max(8, max);
61
+ }
62
+ function truncateString(s, max, mode) {
63
+ if (s.length <= max) return { text: s, truncated: false };
64
+ const more = s.length - max;
65
+ const hint = paint(`\u2026 (${more} more chars)`, "meta", mode);
66
+ return { text: s.slice(0, max) + " " + hint, truncated: true };
67
+ }
68
+ function prettyMultilineString(s, opts) {
69
+ const mode = resolveMode(opts.mode);
70
+ const max = opts.maxStringLen ?? DEFAULT_MAX_STR;
71
+ let body = s;
72
+ let suffix = "";
73
+ if (body.length > max) {
74
+ const more = body.length - max;
75
+ body = body.slice(0, max);
76
+ suffix = " " + paint(`\u2026 (${more} more chars)`, "meta", mode);
77
+ }
78
+ const normalized = body.replace(/\r\n/g, "\n");
79
+ const lines = normalized.split("\n");
80
+ const bar = paint(BLOCK, "block", mode);
81
+ const rendered = lines.map((l) => `${bar} ${l}`).join("\n");
82
+ return suffix ? rendered + suffix : rendered;
83
+ }
84
+ function prettyValue(v, opts, depth) {
85
+ const mode = resolveMode(opts.mode);
86
+ if (depth > MAX_DEPTH) {
87
+ return paint("\u2026 (deeper structure)", "meta", mode);
88
+ }
89
+ if (v === null || v === void 0) return emDash(mode);
90
+ if (typeof v === "string") {
91
+ if (v.length === 0) return emDash(mode);
92
+ if (v.includes("\n") || v.includes("\r")) {
93
+ return prettyMultilineString(v, { ...opts, mode });
94
+ }
95
+ const { text } = truncateString(v, opts.maxStringLen ?? DEFAULT_MAX_STR, mode);
96
+ return paint(text, "str", mode);
97
+ }
98
+ if (typeof v === "boolean") return paint(String(v), "bool", mode);
99
+ if (typeof v === "number") return paint(String(v), "num", mode);
100
+ if (Array.isArray(v)) return renderArray(v, opts, depth);
101
+ if (isPlainObject(v)) return renderObject(v, opts, depth);
102
+ return paint(JSON.stringify(v) ?? "?", "str", mode);
103
+ }
104
+ function renderArray(arr, opts, depth) {
105
+ const mode = resolveMode(opts.mode);
106
+ if (arr.length === 0) return emDash(mode);
107
+ const allPrimitive = arr.every(
108
+ (x) => x === null || typeof x === "string" || typeof x === "number" || typeof x === "boolean"
109
+ );
110
+ if (allPrimitive) {
111
+ const parts = arr.map((x) => {
112
+ if (typeof x === "string") return JSON.stringify(x);
113
+ if (x === null || x === void 0) return "null";
114
+ return String(x);
115
+ });
116
+ const inline = "[" + parts.join(", ") + "]";
117
+ if (inline.length <= INLINE_ARRAY_WIDTH) {
118
+ const painted = arr.map((x) => {
119
+ if (typeof x === "string") return paint(JSON.stringify(x), "str", mode);
120
+ if (typeof x === "number") return paint(String(x), "num", mode);
121
+ if (typeof x === "boolean") return paint(String(x), "bool", mode);
122
+ return paint("null", "null", mode);
123
+ });
124
+ return "[" + painted.join(", ") + "]";
125
+ }
126
+ const indent2 = indentStr(depth + 1, opts);
127
+ return "\n" + arr.map((x) => {
128
+ if (typeof x === "string") return indent2 + paint(JSON.stringify(x), "str", mode);
129
+ if (typeof x === "number") return indent2 + paint(String(x), "num", mode);
130
+ if (typeof x === "boolean") return indent2 + paint(String(x), "bool", mode);
131
+ return indent2 + paint("null", "null", mode);
132
+ }).join("\n");
133
+ }
134
+ const indent = indentStr(depth + 1, opts);
135
+ return "\n" + arr.map((x) => indent + prettyValue(x, opts, depth + 1)).join("\n");
136
+ }
137
+ function renderObject(obj, opts, depth) {
138
+ const mode = resolveMode(opts.mode);
139
+ const keys = Object.keys(obj);
140
+ if (keys.length === 0) return emDash(mode);
141
+ return renderFields(
142
+ keys.map((k) => ({ key: k, value: obj[k] })),
143
+ opts,
144
+ depth
145
+ );
146
+ }
147
+ function renderFields(fields, opts, depth) {
148
+ const mode = resolveMode(opts.mode);
149
+ if (fields.length === 0) return emDash(mode);
150
+ const keyWidth = maxKeyWidth(fields.map((f) => f.key));
151
+ const indent = indentStr(depth + 1, opts);
152
+ const lines = [];
153
+ for (const f of fields) {
154
+ const rendered = prettyValue(f.value, opts, depth + 1);
155
+ const paddedKey = paint(f.key.padEnd(keyWidth), "key", mode);
156
+ const meta = f.meta ? " " + paint(f.meta, "meta", mode) : "";
157
+ if (rendered.startsWith("\n")) {
158
+ lines.push(`${indent}${paddedKey}${meta}${rendered}`);
159
+ } else if (rendered.includes("\n")) {
160
+ const valueLines = rendered.split("\n");
161
+ const continuation = " ".repeat(indent.length + keyWidth + 2);
162
+ lines.push(`${indent}${paddedKey} ${valueLines[0]}${meta}`);
163
+ for (const l of valueLines.slice(1)) lines.push(continuation + l);
164
+ } else {
165
+ lines.push(`${indent}${paddedKey} ${rendered}${meta}`);
166
+ }
167
+ }
168
+ return "\n" + lines.join("\n");
169
+ }
170
+ function sectionHeader(name, suffix, mode) {
171
+ const base = paint(name, "section", mode);
172
+ if (!suffix) return base;
173
+ return `${base} ${paint(suffix, "meta", mode)}`;
174
+ }
175
+ function renderAction(a, opts) {
176
+ const mode = resolveMode(opts.mode);
177
+ if (a.kind === "none") {
178
+ return `${paint("action", "section", mode)} ${emDash(mode)}`;
179
+ }
180
+ const fields = [];
181
+ fields.push({ key: "kind", value: a.kind });
182
+ if (a.kind === "tool_call") {
183
+ if (a.tool_name) {
184
+ fields.push({
185
+ key: "tool",
186
+ value: a.tool_name,
187
+ meta: a.tool_use_id ? `[${a.tool_use_id}]` : void 0
188
+ });
189
+ }
190
+ if (a.tool_input !== void 0) {
191
+ fields.push({ key: "input", value: a.tool_input });
192
+ }
193
+ } else if (a.kind === "message") {
194
+ if (a.text !== void 0) {
195
+ fields.push({ key: "message", value: a.text });
196
+ }
197
+ } else if (a.kind === "thinking_only") {
198
+ if (a.text !== void 0) {
199
+ fields.push({ key: "thinking", value: a.text });
200
+ }
201
+ } else if (a.kind === "sub_agent_dispatch") {
202
+ if (a.sub_agent) fields.push({ key: "sub_agent", value: a.sub_agent });
203
+ if (a.text !== void 0) fields.push({ key: "message", value: a.text });
204
+ } else {
205
+ for (const k of Object.keys(a)) {
206
+ if (k === "kind") continue;
207
+ const v = a[k];
208
+ if (v !== void 0) fields.push({ key: k, value: v });
209
+ }
210
+ }
211
+ return paint("action", "section", mode) + renderFields(fields, opts, 0);
212
+ }
213
+ function renderOutcome(o, opts) {
214
+ const mode = resolveMode(opts.mode);
215
+ const fields = [];
216
+ const statusColor = o.status === "error" ? "error" : o.status === "pending" ? "pending" : "ok";
217
+ fields.push({
218
+ key: "status",
219
+ value: { __painted: paint(o.status, statusColor, mode) }
220
+ });
221
+ if (o.summary !== void 0 && o.summary !== null && o.summary !== "") {
222
+ fields.push({ key: "summary", value: o.summary });
223
+ }
224
+ if (o.tool_result_ref) {
225
+ const shortRef = o.tool_result_ref.slice(0, 12);
226
+ if (opts.toolResultText !== void 0) {
227
+ const size = `${(opts.toolResultText.length / 1024).toFixed(1)} kB`;
228
+ const metaText = `blob ${shortRef}\u2026 \xB7 ${size}`;
229
+ const metaHtml = opts.mode === "html" && opts.rawBlobHref ? ` <a href="${escHtml(opts.rawBlobHref)}" class="p-meta">view raw</a>` : "";
230
+ const painted = paint(metaText, "meta", mode) + metaHtml;
231
+ fields.push({ key: "result", value: opts.toolResultText, metaPainted: painted });
232
+ } else {
233
+ const metaText = `blob ${shortRef}\u2026`;
234
+ fields.push({
235
+ key: "result",
236
+ value: { __painted: paint("(blob ref)", "meta", mode) },
237
+ metaPainted: paint(metaText, "meta", mode)
238
+ });
239
+ }
240
+ }
241
+ if (o.is_error) {
242
+ fields.push({ key: "is_error", value: true });
243
+ }
244
+ if (o.state_delta !== void 0 && o.state_delta !== null) {
245
+ fields.push({ key: "state_delta", value: o.state_delta });
246
+ }
247
+ return paint("outcome", "section", mode) + renderFieldsWithPaintEscape(fields, opts, 0);
248
+ }
249
+ function renderFieldsWithPaintEscape(fields, opts, depth) {
250
+ const mode = resolveMode(opts.mode);
251
+ if (fields.length === 0) return emDash(mode);
252
+ const keyWidth = maxKeyWidth(fields.map((f) => f.key));
253
+ const indent = indentStr(depth + 1, opts);
254
+ const lines = [];
255
+ for (const f of fields) {
256
+ let rendered;
257
+ if (isPlainObject(f.value) && "__painted" in f.value && typeof f.value.__painted === "string") {
258
+ rendered = f.value.__painted;
259
+ } else {
260
+ rendered = prettyValue(f.value, opts, depth + 1);
261
+ }
262
+ const paddedKey = paint(f.key.padEnd(keyWidth), "key", mode);
263
+ let meta = "";
264
+ if (f.metaPainted) meta = " " + f.metaPainted;
265
+ else if (f.meta) meta = " " + paint(f.meta, "meta", mode);
266
+ if (rendered.startsWith("\n")) {
267
+ lines.push(`${indent}${paddedKey}${meta}${rendered}`);
268
+ } else if (rendered.includes("\n")) {
269
+ const valueLines = rendered.split("\n");
270
+ const continuation = " ".repeat(indent.length + keyWidth + 2);
271
+ lines.push(`${indent}${paddedKey} ${valueLines[0]}${meta}`);
272
+ for (const l of valueLines.slice(1)) lines.push(continuation + l);
273
+ } else {
274
+ lines.push(`${indent}${paddedKey} ${rendered}${meta}`);
275
+ }
276
+ }
277
+ return "\n" + lines.join("\n");
278
+ }
279
+ function renderCost(c, opts) {
280
+ const mode = resolveMode(opts.mode);
281
+ const fields = [];
282
+ const tokParts = [];
283
+ tokParts.push(`${fmtTokens(c.tokens.input)} in`);
284
+ tokParts.push(`${fmtTokens(c.tokens.output)} out`);
285
+ if (c.tokens.cached_read !== void 0 && c.tokens.cached_read > 0) {
286
+ tokParts.push(`${fmtTokens(c.tokens.cached_read)} cached read`);
287
+ }
288
+ if (c.tokens.cache_creation !== void 0 && c.tokens.cache_creation > 0) {
289
+ tokParts.push(`${fmtTokens(c.tokens.cache_creation)} cache create`);
290
+ }
291
+ fields.push({
292
+ key: "tokens",
293
+ value: { __painted: paint(tokParts.join(" \xB7 "), "num", mode) }
294
+ });
295
+ fields.push({
296
+ key: "latency",
297
+ value: { __painted: paint(`${c.latency_ms} ms`, "num", mode) }
298
+ });
299
+ fields.push({
300
+ key: "cost",
301
+ value: { __painted: paint(fmtCents(c.cost_cents), "num", mode) }
302
+ });
303
+ if (c.tags && c.tags.length > 0) {
304
+ fields.push({ key: "tags", value: c.tags.join(", ") });
305
+ } else {
306
+ fields.push({ key: "tags", value: null });
307
+ }
308
+ return paint("cost", "section", mode) + renderFieldsWithPaintEscape(fields, opts, 0);
309
+ }
310
+ function renderDecision(text, opts) {
311
+ const mode = resolveMode(opts.mode);
312
+ if (text === void 0 || text === null || text === "") {
313
+ return `${paint("decision", "section", mode)} ${emDash(mode)}`;
314
+ }
315
+ try {
316
+ const parsed = JSON.parse(text);
317
+ if (isPlainObject(parsed) || Array.isArray(parsed)) {
318
+ return paint("decision", "section", mode) + prettyValue(parsed, opts, 0);
319
+ }
320
+ return `${paint("decision", "section", mode)} ${prettyValue(parsed, opts, 0)}`;
321
+ } catch {
322
+ const suffix = opts.truncated ? "(truncated \xB7 view raw)" : "(not JSON)";
323
+ const header = sectionHeader("decision", suffix, mode);
324
+ const block = prettyMultilineString(text, opts);
325
+ return `${header}
326
+ ${indentStr(1, opts)}${block.split("\n").join("\n" + indentStr(1, opts))}`;
327
+ }
328
+ }
329
+ function prettyTab(kind, value, opts) {
330
+ const resolved = {
331
+ ...opts,
332
+ mode: resolveMode(opts.mode),
333
+ maxStringLen: opts.maxStringLen ?? DEFAULT_MAX_STR,
334
+ indent: opts.indent ?? DEFAULT_INDENT
335
+ };
336
+ switch (kind) {
337
+ case "action":
338
+ return renderAction(value, resolved);
339
+ case "outcome":
340
+ return renderOutcome(value, resolved);
341
+ case "decision":
342
+ return renderDecision(value, resolved);
343
+ case "cost":
344
+ return renderCost(value, resolved);
345
+ }
346
+ }
347
+ function reformatJsonString(s) {
348
+ try {
349
+ return JSON.stringify(JSON.parse(s), null, 2);
350
+ } catch {
351
+ return s;
352
+ }
353
+ }
354
+
355
+ // src/html.ts
356
+ var STYLES = `
357
+ /* \u2500\u2500\u2500 Cerulean Design System tokens \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
358
+ :root {
359
+ /* Brand */
360
+ --cerulean-50: #EBF7FC;
361
+ --cerulean-100: #CFEAF6;
362
+ --cerulean-200: #A5D9EE;
363
+ --cerulean-300: #6FC1E1;
364
+ --cerulean-400: #38BDF8;
365
+ --cerulean-500: #00A6E0;
366
+ --cerulean-600: #0284C0;
367
+ --cerulean-700: #0369A1;
368
+ --cerulean-800: #075985;
369
+ --cerulean-900: #0C4A6E;
370
+
371
+ /* Surfaces */
372
+ --surface-0: #08090B;
373
+ --surface-1: #0E1014;
374
+ --surface-2: #161A21;
375
+ --surface-3: #1F2630;
376
+ --surface-4: #2A3340;
377
+ --border-subtle: #1A1F2A;
378
+ --border-default: #252D3A;
379
+ --border-strong: #3A4655;
380
+
381
+ /* Text */
382
+ --text-primary: #E8ECEF;
383
+ --text-secondary: #9AA5B5;
384
+ --text-tertiary: #5F6B7C;
385
+ --text-disabled: #3F4856;
386
+ --text-on-accent: #04141E;
387
+
388
+ /* Semantic */
389
+ --amber-400: #FBBF24;
390
+ --amber-bg: rgba(251,191,36,0.08);
391
+ --coral-400: #F87171;
392
+ --coral-bg: rgba(248,113,113,0.08);
393
+ --mint-400: #34D399;
394
+ --mint-bg: rgba(52,211,153,0.08);
395
+ --violet-400:#A78BFA;
396
+ --violet-bg: rgba(167,139,250,0.08);
397
+
398
+ /* Type */
399
+ --font-sans: "Geist", "S\xF6hne", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
400
+ --font-mono: "Geist Mono", "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
401
+
402
+ /* Spacing */
403
+ --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
404
+ --space-5: 20px; --space-6: 24px; --space-8: 32px; --space-10: 40px;
405
+ --space-12: 48px; --space-16: 64px;
406
+
407
+ /* Radius */
408
+ --radius-xs: 2px; --radius-sm: 4px; --radius-md: 6px;
409
+ --radius-lg: 8px; --radius-xl: 12px;
410
+
411
+ /* Elevation */
412
+ --elevation-0: 0 0 0 1px var(--border-default);
413
+ --elevation-1: 0 0 0 1px var(--border-strong),
414
+ inset 0 1px 0 0 rgba(255,255,255,0.03);
415
+ --elevation-2: 0 0 0 1px var(--border-strong),
416
+ 0 20px 60px -20px rgba(0,0,0,0.8),
417
+ inset 0 1px 0 0 rgba(255,255,255,0.04);
418
+ --focus-ring: 0 0 0 2px var(--surface-0),
419
+ 0 0 0 4px var(--cerulean-400);
420
+ --brand-glow: 0 0 80px -20px rgba(56,189,248,0.35);
421
+
422
+ /* Motion */
423
+ --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
424
+ --duration-fast: 120ms;
425
+ --duration-default: 200ms;
426
+ --duration-slow: 400ms;
427
+
428
+ /* \u2500\u2500\u2500 Aliases for legacy class references \u2500\u2500\u2500 */
429
+ --bg: var(--surface-0);
430
+ --bg-2: var(--surface-1);
431
+ --bg-3: var(--surface-2);
432
+ --border: var(--border-default);
433
+ --fg: var(--text-primary);
434
+ --fg-mute: var(--text-secondary);
435
+ --accent: var(--cerulean-400);
436
+ --ok: var(--mint-400);
437
+ --warn: var(--amber-400);
438
+ --err: var(--coral-400);
439
+ --fork: var(--violet-400);
440
+ }
441
+
442
+ /* \u2500\u2500\u2500 Reset + base \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
443
+ * { box-sizing: border-box; }
444
+ ::selection { background: rgba(56,189,248,0.25); color: var(--text-primary); }
445
+ html, body {
446
+ margin: 0; padding: 0;
447
+ background: var(--surface-0); color: var(--text-primary);
448
+ font-family: var(--font-sans);
449
+ font-size: 14px;
450
+ line-height: 1.5;
451
+ -webkit-font-smoothing: antialiased;
452
+ -moz-osx-font-smoothing: grayscale;
453
+ font-feature-settings: "ss01", "cv11";
454
+ }
455
+ code, pre, .mono {
456
+ font-family: var(--font-mono);
457
+ font-size: 12.5px;
458
+ font-feature-settings: normal;
459
+ }
460
+
461
+ /* \u2500\u2500\u2500 Links \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
462
+ a { color: var(--cerulean-400); text-decoration: none; transition: color var(--duration-fast) var(--ease-out); }
463
+ a:hover { color: var(--cerulean-300); text-decoration: underline; text-underline-offset: 2px; }
464
+
465
+ /* \u2500\u2500\u2500 Section labels (Modal-style) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
466
+ .section-label {
467
+ font-family: var(--font-mono);
468
+ font-size: 11px;
469
+ font-weight: 500;
470
+ letter-spacing: 0.1em;
471
+ text-transform: uppercase;
472
+ color: var(--cerulean-400);
473
+ margin-bottom: var(--space-2);
474
+ }
475
+
476
+ /* \u2500\u2500\u2500 Header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
477
+ header {
478
+ position: sticky; top: 0; z-index: 30;
479
+ height: 60px;
480
+ padding: 0 var(--space-6);
481
+ background: rgba(8, 9, 11, 0.78);
482
+ backdrop-filter: blur(14px) saturate(180%);
483
+ -webkit-backdrop-filter: blur(14px) saturate(180%);
484
+ border-bottom: 1px solid var(--border-subtle);
485
+ display: flex; align-items: center; gap: var(--space-6);
486
+ }
487
+ header .brand {
488
+ display: inline-flex; align-items: center; gap: 10px;
489
+ color: var(--text-primary);
490
+ font-weight: 600; font-size: 15px; letter-spacing: -0.01em;
491
+ }
492
+ header .brand:hover { text-decoration: none; color: var(--text-primary); }
493
+ header .brand-mark {
494
+ width: 18px; height: 18px;
495
+ border-radius: var(--radius-sm);
496
+ background: linear-gradient(135deg, var(--cerulean-400) 0%, var(--cerulean-700) 100%);
497
+ box-shadow: var(--brand-glow);
498
+ }
499
+ header .topnav { display: flex; gap: var(--space-5); font-size: 13px; }
500
+ header .topnav a {
501
+ color: var(--text-secondary);
502
+ padding: 4px 0;
503
+ transition: color var(--duration-fast) var(--ease-out);
504
+ }
505
+ header .topnav a:hover { color: var(--text-primary); text-decoration: none; }
506
+ header .crumbs {
507
+ color: var(--text-tertiary); font-size: 13px;
508
+ font-family: var(--font-mono);
509
+ }
510
+ header #flash {
511
+ margin-left: auto; font-size: 12px;
512
+ font-family: var(--font-mono);
513
+ color: var(--text-secondary);
514
+ opacity: 0;
515
+ transition: opacity var(--duration-fast) var(--ease-out);
516
+ }
517
+ header #flash[data-kind="err"] { color: var(--coral-400); }
518
+ header #flash[data-kind="info"] { color: var(--cerulean-400); }
519
+
520
+ main {
521
+ padding: var(--space-8) var(--space-6) var(--space-12);
522
+ max-width: 1500px; margin: 0 auto;
523
+ }
524
+
525
+ h2 { font-size: 24px; font-weight: 600; letter-spacing: -0.02em; margin: 0; }
526
+ h3 { font-size: 14px; font-weight: 600; margin: 0; letter-spacing: -0.005em; }
527
+
528
+ /* \u2500\u2500\u2500 Tables \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
529
+ table {
530
+ width: 100%;
531
+ font-size: 13px;
532
+ border-collapse: collapse;
533
+ }
534
+ th, td { text-align: left; vertical-align: middle; }
535
+ th {
536
+ font-family: var(--font-mono);
537
+ font-size: 11px;
538
+ font-weight: 500;
539
+ text-transform: uppercase;
540
+ letter-spacing: 0.06em;
541
+ color: var(--text-tertiary);
542
+ padding: var(--space-3) var(--space-4);
543
+ border-bottom: 1px solid var(--border-default);
544
+ background: var(--surface-0);
545
+ }
546
+ td {
547
+ padding: var(--space-3) var(--space-4);
548
+ color: var(--text-primary);
549
+ border-bottom: 1px solid var(--border-subtle);
550
+ }
551
+ td.numeric, td.mono {
552
+ font-family: var(--font-mono);
553
+ font-variant-numeric: tabular-nums;
554
+ white-space: nowrap;
555
+ }
556
+ tbody tr { transition: background var(--duration-fast) var(--ease-out); }
557
+
558
+ /* \u2500\u2500\u2500 Scrollable table wrapper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
559
+ /* Wraps wide tables (Runs page) so they scroll horizontally instead
560
+ of squeezing all 8 columns into the viewport. The table sets its
561
+ own min-width to keep columns from collapsing when there's space. */
562
+ .table-scroll {
563
+ overflow-x: auto;
564
+ border: 1px solid var(--border-default);
565
+ border-radius: var(--radius-md);
566
+ background: var(--surface-1);
567
+ }
568
+ .table-scroll::-webkit-scrollbar { height: 10px; }
569
+ .table-scroll::-webkit-scrollbar-track { background: var(--surface-0); }
570
+ .table-scroll::-webkit-scrollbar-thumb {
571
+ background: var(--surface-3);
572
+ border-radius: var(--radius-full);
573
+ border: 2px solid var(--surface-0);
574
+ }
575
+ .table-scroll::-webkit-scrollbar-thumb:hover { background: var(--surface-4); }
576
+ .runs-table { min-width: 1180px; }
577
+ .runs-table td:first-child,
578
+ .runs-table th:first-child {
579
+ /* Title column gets the most breathing room and is allowed to wrap
580
+ to one extra line on long titles \u2014 but stop at 480px so it
581
+ doesn't push the rest of the table off-screen. */
582
+ min-width: 320px; max-width: 480px;
583
+ white-space: normal;
584
+ }
585
+ .runs-table td:last-child,
586
+ .runs-table th:last-child {
587
+ /* Project column \u2014 cap so deep nested paths don't dominate. */
588
+ max-width: 240px;
589
+ overflow: hidden; text-overflow: ellipsis;
590
+ }
591
+ tbody tr:hover { background: var(--surface-1); }
592
+
593
+ /* \u2500\u2500\u2500 Badges (status pills) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
594
+ .badge, .pill {
595
+ display: inline-flex;
596
+ align-items: center;
597
+ gap: 6px;
598
+ font-family: var(--font-mono);
599
+ font-size: 11px;
600
+ font-weight: 500;
601
+ letter-spacing: 0.04em;
602
+ text-transform: uppercase;
603
+ padding: 2px 8px;
604
+ border-radius: var(--radius-xs);
605
+ border: 1px solid var(--border-default);
606
+ background: var(--surface-2);
607
+ color: var(--text-secondary);
608
+ line-height: 1.5;
609
+ white-space: nowrap;
610
+ }
611
+ .badge--info,
612
+ .pill.in_progress, .pill.live-awaiting_input {
613
+ color: var(--cerulean-300);
614
+ background: rgba(56, 189, 248, 0.08);
615
+ border-color: rgba(56, 189, 248, 0.25);
616
+ }
617
+ .badge--success,
618
+ .pill.ok, .pill.live-progressing, .pill.live-completed {
619
+ color: var(--mint-400);
620
+ background: var(--mint-bg);
621
+ border-color: rgba(52, 211, 153, 0.25);
622
+ }
623
+ .badge--warn,
624
+ .pill.live-stalled {
625
+ color: var(--amber-400);
626
+ background: var(--amber-bg);
627
+ border-color: rgba(251, 191, 36, 0.25);
628
+ }
629
+ .badge--error,
630
+ .pill.error, .pill.live-errored, .pill.live-looping {
631
+ color: var(--coral-400);
632
+ background: var(--coral-bg);
633
+ border-color: rgba(248, 113, 113, 0.25);
634
+ }
635
+ .badge--premium,
636
+ .pill.fork {
637
+ color: var(--violet-400);
638
+ background: var(--violet-bg);
639
+ border-color: rgba(167, 139, 250, 0.25);
640
+ }
641
+ .badge--muted,
642
+ .pill.abandoned {
643
+ color: var(--text-tertiary);
644
+ background: var(--surface-2);
645
+ border-color: var(--border-default);
646
+ }
647
+
648
+ /* Status dots */
649
+ .dot {
650
+ width: 6px; height: 6px; border-radius: var(--radius-full);
651
+ display: inline-block;
652
+ background: currentColor;
653
+ }
654
+ .dot--success { color: var(--mint-400); box-shadow: 0 0 6px rgba(52, 211, 153, 0.6); }
655
+ .dot--error { color: var(--coral-400); }
656
+ .dot--warn { color: var(--amber-400); }
657
+ .dot--info { color: var(--cerulean-400); box-shadow: 0 0 6px rgba(56, 189, 248, 0.5); }
658
+ .dot--muted { color: var(--text-tertiary); box-shadow: none; }
659
+
660
+ /* \u2500\u2500\u2500 v0.3 Live toggle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
661
+ Sits in the header alongside the topnav. Two visual states drive
662
+ off the data-live attribute: idle (muted, "GO LIVE") vs live
663
+ (mint dot pulse, "LIVE"). Click hits POST /api/live/start|stop
664
+ and the JS handler flips the attribute without a reload. */
665
+ .live-toggle {
666
+ display: inline-flex;
667
+ align-items: center;
668
+ gap: 6px;
669
+ padding: 4px 10px;
670
+ height: 26px;
671
+ border-radius: var(--radius-sm);
672
+ border: 1px solid var(--border-default);
673
+ background: var(--surface-1);
674
+ color: var(--text-secondary);
675
+ font-family: var(--font-mono);
676
+ font-size: 11px;
677
+ font-weight: 500;
678
+ letter-spacing: 0.06em;
679
+ cursor: pointer;
680
+ transition: background var(--duration-fast) var(--ease-out),
681
+ border-color var(--duration-fast) var(--ease-out),
682
+ color var(--duration-fast) var(--ease-out);
683
+ }
684
+ .live-toggle:hover {
685
+ background: var(--surface-2);
686
+ border-color: var(--border-strong);
687
+ color: var(--text-primary);
688
+ }
689
+ .live-toggle[data-live="1"] {
690
+ border-color: var(--mint-400);
691
+ color: var(--mint-400);
692
+ background: rgba(52, 211, 153, 0.08);
693
+ }
694
+ .live-toggle[data-live="1"]:hover {
695
+ background: rgba(52, 211, 153, 0.14);
696
+ }
697
+ .live-toggle[data-live="1"] .dot {
698
+ animation: live-pulse 1.4s ease-in-out infinite;
699
+ }
700
+ .live-toggle[disabled] { opacity: 0.55; cursor: progress; }
701
+ @keyframes live-pulse {
702
+ 0%, 100% { opacity: 1; box-shadow: 0 0 6px rgba(52, 211, 153, 0.6); }
703
+ 50% { opacity: 0.55; box-shadow: 0 0 2px rgba(52, 211, 153, 0.3); }
704
+ }
705
+
706
+ /* Auto-dot for live-status pills inside fleet cards. Lives in the
707
+ pseudo-element so we don't have to change every render call site. */
708
+ .pill.live-progressing::before,
709
+ .pill.live-stalled::before,
710
+ .pill.live-looping::before,
711
+ .pill.live-awaiting_input::before,
712
+ .pill.live-errored::before,
713
+ .pill.live-completed::before {
714
+ content: ""; width: 6px; height: 6px; border-radius: var(--radius-full);
715
+ background: currentColor; flex-shrink: 0;
716
+ }
717
+ .pill.live-progressing::before { box-shadow: 0 0 6px rgba(52,211,153,0.6); }
718
+ .pill.live-awaiting_input::before { box-shadow: 0 0 6px rgba(56,189,248,0.5); }
719
+ .pill.live-looping::before { box-shadow: 0 0 6px rgba(248,113,113,0.5); }
720
+
721
+ /* \u2500\u2500\u2500 Buttons \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
722
+ button {
723
+ background: transparent;
724
+ color: var(--text-primary);
725
+ border: 1px solid var(--border-default);
726
+ border-radius: var(--radius-sm);
727
+ padding: 8px 14px;
728
+ font: inherit;
729
+ font-size: 13px;
730
+ font-weight: 500;
731
+ cursor: pointer;
732
+ transition: background var(--duration-fast) var(--ease-out),
733
+ border-color var(--duration-fast) var(--ease-out),
734
+ color var(--duration-fast) var(--ease-out);
735
+ }
736
+ button:hover { background: var(--surface-2); border-color: var(--border-strong); }
737
+ button:focus-visible { outline: none; box-shadow: var(--focus-ring); }
738
+ button.primary {
739
+ background: var(--cerulean-400);
740
+ color: var(--text-on-accent);
741
+ border-color: var(--cerulean-400);
742
+ font-weight: 500;
743
+ }
744
+ button.primary:hover {
745
+ background: var(--cerulean-300);
746
+ border-color: var(--cerulean-300);
747
+ }
748
+ button.primary:active {
749
+ background: var(--cerulean-500);
750
+ border-color: var(--cerulean-500);
751
+ }
752
+ button.tertiary {
753
+ background: transparent;
754
+ border: 1px solid transparent;
755
+ color: var(--cerulean-400);
756
+ padding: 6px 8px;
757
+ }
758
+ button.tertiary:hover {
759
+ background: rgba(56, 189, 248, 0.08);
760
+ color: var(--cerulean-300);
761
+ border-color: transparent;
762
+ }
763
+
764
+ /* \u2500\u2500\u2500 Seal-run control (split button: status picker + action) \u2500\u2500\u2500\u2500\u2500\u2500
765
+ Used in the run detail header for in_progress runs. The whole thing
766
+ reads as one rounded segmented control: a status picker on the left,
767
+ a primary action on the right, joined visually by a hairline divider.
768
+ Status color tints the picker so the visual matches what'll be
769
+ written. */
770
+ .seal-control {
771
+ display: inline-flex;
772
+ align-items: stretch;
773
+ height: 30px;
774
+ border: 1px solid var(--border-default);
775
+ border-radius: var(--radius-sm);
776
+ background: var(--surface-1);
777
+ overflow: hidden;
778
+ transition: border-color var(--duration-fast) var(--ease-out),
779
+ box-shadow var(--duration-fast) var(--ease-out);
780
+ }
781
+ .seal-control:hover { border-color: var(--border-strong); }
782
+ .seal-control:focus-within {
783
+ border-color: var(--cerulean-400);
784
+ box-shadow: var(--focus-ring);
785
+ }
786
+ .seal-status-select {
787
+ appearance: none;
788
+ -webkit-appearance: none;
789
+ background: transparent;
790
+ border: 0;
791
+ border-radius: 0;
792
+ padding: 0 24px 0 12px;
793
+ font-family: var(--font-mono);
794
+ font-size: 11.5px;
795
+ font-weight: 500;
796
+ letter-spacing: 0.04em;
797
+ color: var(--text-secondary);
798
+ line-height: 28px;
799
+ cursor: pointer;
800
+ /* Custom chevron \u2014 keeps the control compact without a browser caret. */
801
+ background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
802
+ linear-gradient(135deg, currentColor 50%, transparent 50%);
803
+ background-position: calc(100% - 13px) 13px, calc(100% - 9px) 13px;
804
+ background-size: 4px 4px, 4px 4px;
805
+ background-repeat: no-repeat;
806
+ transition: color var(--duration-fast) var(--ease-out),
807
+ background-color var(--duration-fast) var(--ease-out);
808
+ }
809
+ .seal-status-select:focus { outline: none; box-shadow: none; }
810
+ .seal-status-select:hover { background-color: var(--surface-2); color: var(--text-primary); }
811
+ .seal-status-select[data-status="ok"] { color: var(--mint-400); }
812
+ .seal-status-select[data-status="error"] { color: var(--coral-400); }
813
+ .seal-status-select[data-status="abandoned"] { color: var(--amber-400); }
814
+ .seal-control > button.seal-action {
815
+ border: 0;
816
+ border-left: 1px solid var(--border-default);
817
+ border-radius: 0;
818
+ height: 100%;
819
+ padding: 0 14px;
820
+ background: transparent;
821
+ color: var(--text-primary);
822
+ font-family: var(--font-sans);
823
+ font-size: 12px;
824
+ font-weight: 500;
825
+ letter-spacing: 0.01em;
826
+ line-height: 1;
827
+ display: inline-flex;
828
+ align-items: center;
829
+ gap: 6px;
830
+ cursor: pointer;
831
+ transition: background var(--duration-fast) var(--ease-out),
832
+ color var(--duration-fast) var(--ease-out);
833
+ }
834
+ .seal-control > button.seal-action:hover {
835
+ background: var(--surface-2);
836
+ color: var(--cerulean-300);
837
+ }
838
+ .seal-control > button.seal-action svg { display: block; }
839
+
840
+ /* \u2500\u2500\u2500 v0.3 Files tab + run-level files summary \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
841
+ Per SPEC \xA79 \u2014 reuses existing semantic palette (no new tokens
842
+ except --cerulean-bg added in Turn 5's plan, not needed yet
843
+ because the "selected file row" interaction lives in v0.5's
844
+ working-tree panel). Op badges follow the spec mapping:
845
+ mint A / amber M / coral D / violet R / dim X. */
846
+ .files-summary {
847
+ display: inline-flex;
848
+ align-items: center;
849
+ gap: var(--space-3);
850
+ padding: 4px 0 var(--space-2);
851
+ font-family: var(--font-mono);
852
+ font-size: 12px;
853
+ color: var(--text-secondary);
854
+ }
855
+ .files-stat-add { color: var(--mint-400); }
856
+ .files-stat-rm { color: var(--coral-400); }
857
+ .files-stat-count { color: var(--text-tertiary); }
858
+ .file-list { display: flex; flex-direction: column; gap: 4px; }
859
+ .file-row {
860
+ border: 1px solid var(--border-default);
861
+ border-radius: var(--radius-sm);
862
+ background: var(--surface-1);
863
+ overflow: hidden;
864
+ }
865
+ .file-row-head {
866
+ width: 100%;
867
+ background: transparent;
868
+ border: 0;
869
+ padding: 8px 10px;
870
+ text-align: left;
871
+ display: grid;
872
+ grid-template-columns: 22px 1fr auto auto auto;
873
+ gap: 10px;
874
+ align-items: center;
875
+ font-family: var(--font-mono);
876
+ font-size: 12px;
877
+ color: var(--text-primary);
878
+ cursor: default;
879
+ }
880
+ .file-row-head.expandable { cursor: pointer; }
881
+ .file-row-head.expandable:hover { background: var(--surface-2); }
882
+ .file-row-head .file-row-caret {
883
+ color: var(--text-tertiary);
884
+ transition: transform var(--duration-fast) var(--ease-out);
885
+ }
886
+ .file-row.expanded .file-row-caret { transform: rotate(180deg); }
887
+ .file-op {
888
+ display: inline-block;
889
+ width: 20px; height: 20px; line-height: 20px;
890
+ text-align: center;
891
+ border-radius: var(--radius-sm);
892
+ font-weight: 600;
893
+ font-size: 11px;
894
+ }
895
+ .file-op-create { color: var(--mint-400); background: rgba(52, 211, 153, 0.12); }
896
+ .file-op-modify { color: var(--amber-400); background: rgba(245, 158, 11, 0.12); }
897
+ .file-op-delete { color: var(--coral-400); background: rgba(248, 113, 113, 0.12); }
898
+ .file-op-rename { color: var(--violet-400); background: rgba(167, 139, 250, 0.12); }
899
+ .file-op-chmod { color: var(--text-tertiary); background: var(--surface-2); }
900
+
901
+ /* v0.3 \u2014 Shiki render override (design D10).
902
+ * Shiki ships themes with hardcoded backgrounds; we override to
903
+ * blend with the Cerulean dark surface tokens so /api/blob/:hash/render
904
+ * output looks native inside the files page Final tab and the
905
+ * Step card context viewer. github-dark-dimmed's foreground stays
906
+ * usable on --surface-0, no foreground override needed. */
907
+ .shiki {
908
+ background: var(--surface-0) !important;
909
+ color: var(--text-primary) !important;
910
+ padding: 12px 14px;
911
+ font-family: var(--font-mono);
912
+ font-size: 12.5px;
913
+ line-height: 1.5;
914
+ border-radius: var(--radius-sm);
915
+ overflow-x: auto;
916
+ }
917
+ .shiki code { background: transparent; padding: 0; }
918
+ .shiki .line { display: block; min-height: 1.5em; }
919
+
920
+ .file-path { color: var(--text-primary); overflow-wrap: anywhere; }
921
+ .file-stats { display: inline-flex; gap: 6px; color: var(--text-tertiary); }
922
+ .file-flag {
923
+ display: inline-block;
924
+ padding: 1px 6px;
925
+ border-radius: var(--radius-sm);
926
+ font-size: 10px;
927
+ letter-spacing: 0.04em;
928
+ text-transform: uppercase;
929
+ }
930
+ .flag-partial { color: var(--text-tertiary); background: var(--surface-2); }
931
+ .flag-binary { color: var(--text-secondary); background: var(--surface-2); }
932
+ .flag-redacted { color: var(--coral-400); border: 1px solid var(--coral-400); }
933
+ .file-diff {
934
+ background: var(--surface-0);
935
+ border-top: 1px solid var(--border-subtle);
936
+ margin: 0;
937
+ padding: 10px 14px;
938
+ font-family: var(--font-mono);
939
+ font-size: 12px;
940
+ line-height: 1.5;
941
+ overflow-x: auto;
942
+ white-space: pre;
943
+ }
944
+ .file-diff .diff-add { color: var(--mint-400); }
945
+ .file-diff .diff-del { color: var(--coral-400); }
946
+ .file-diff .diff-hunk { color: var(--cerulean-400); }
947
+ .file-diff-empty {
948
+ padding: 8px 14px;
949
+ border-top: 1px solid var(--border-subtle);
950
+ color: var(--text-tertiary);
951
+ font-size: 12px;
952
+ font-family: var(--font-mono);
953
+ }
954
+ .tab-count {
955
+ display: inline-block;
956
+ margin-left: 6px;
957
+ padding: 1px 6px;
958
+ border-radius: var(--radius-full);
959
+ background: var(--surface-2);
960
+ color: var(--text-tertiary);
961
+ font-size: 10px;
962
+ font-weight: 500;
963
+ }
964
+
965
+ /* Run-level "Files changed in this run" summary, below the timeline. */
966
+ .run-files-summary {
967
+ margin: var(--space-3) 0 var(--space-4);
968
+ padding: var(--space-3);
969
+ background: var(--surface-1);
970
+ border: 1px solid var(--border-default);
971
+ border-radius: var(--radius-md);
972
+ }
973
+ .run-files-summary > summary {
974
+ cursor: pointer;
975
+ list-style: none;
976
+ display: flex;
977
+ align-items: center;
978
+ gap: var(--space-3);
979
+ }
980
+ .run-files-summary > summary::-webkit-details-marker { display: none; }
981
+ .run-files-totals {
982
+ display: inline-flex;
983
+ gap: var(--space-2);
984
+ margin-left: auto;
985
+ font-family: var(--font-mono);
986
+ font-size: 12px;
987
+ }
988
+ .run-files-list {
989
+ margin-top: var(--space-3);
990
+ display: flex;
991
+ flex-direction: column;
992
+ gap: 2px;
993
+ }
994
+ .run-file-row {
995
+ width: 100%;
996
+ background: transparent;
997
+ border: 0;
998
+ padding: 6px 8px;
999
+ text-align: left;
1000
+ display: grid;
1001
+ grid-template-columns: 22px 1fr auto auto auto;
1002
+ gap: 10px;
1003
+ align-items: center;
1004
+ font-family: var(--font-mono);
1005
+ font-size: 12px;
1006
+ color: var(--text-primary);
1007
+ border-radius: var(--radius-sm);
1008
+ cursor: pointer;
1009
+ transition: background var(--duration-fast) var(--ease-out);
1010
+ }
1011
+ .run-file-row:hover { background: var(--surface-2); }
1012
+ .run-file-row-meta { color: var(--text-tertiary); font-size: 11px; }
1013
+
1014
+ /* \u2500\u2500\u2500 Row-level seal (runs list) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1015
+ Ghost button that fades in on row hover. Avoids visual noise on
1016
+ long tables where most rows aren't actionable. */
1017
+ .runs-table tbody tr .row-seal {
1018
+ background: transparent;
1019
+ border: 1px solid transparent;
1020
+ color: var(--text-tertiary);
1021
+ padding: 4px 10px;
1022
+ font-size: 11.5px;
1023
+ font-family: var(--font-mono);
1024
+ letter-spacing: 0.04em;
1025
+ border-radius: var(--radius-sm);
1026
+ opacity: 0.55;
1027
+ transition: opacity var(--duration-fast) var(--ease-out),
1028
+ color var(--duration-fast) var(--ease-out),
1029
+ border-color var(--duration-fast) var(--ease-out),
1030
+ background var(--duration-fast) var(--ease-out);
1031
+ }
1032
+ .runs-table tbody tr:hover .row-seal { opacity: 1; }
1033
+ .runs-table tbody tr .row-seal:hover {
1034
+ color: var(--mint-400);
1035
+ border-color: var(--mint-400);
1036
+ background: rgba(52, 211, 153, 0.08);
1037
+ }
1038
+ .runs-table tbody tr .row-seal::before {
1039
+ content: "\u2713";
1040
+ margin-right: 5px;
1041
+ opacity: 0.7;
1042
+ }
1043
+
1044
+ /* \u2500\u2500\u2500 Inputs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1045
+ input, textarea, select {
1046
+ background: var(--surface-1);
1047
+ color: var(--text-primary);
1048
+ border: 1px solid var(--border-default);
1049
+ border-radius: var(--radius-sm);
1050
+ padding: 8px 12px;
1051
+ font: inherit;
1052
+ font-size: 13px;
1053
+ font-family: var(--font-sans);
1054
+ transition: border-color var(--duration-fast) var(--ease-out),
1055
+ box-shadow var(--duration-fast) var(--ease-out);
1056
+ }
1057
+ input:focus, textarea:focus, select:focus {
1058
+ outline: none;
1059
+ border-color: var(--cerulean-400);
1060
+ box-shadow: var(--focus-ring);
1061
+ }
1062
+ input::placeholder, textarea::placeholder { color: var(--text-tertiary); }
1063
+ textarea { min-height: 88px; resize: vertical; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.55; }
1064
+
1065
+ /* \u2500\u2500\u2500 Filter bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1066
+ .filter-bar {
1067
+ display: flex; align-items: center; gap: var(--space-2);
1068
+ margin: 0 0 var(--space-3) 0; flex-wrap: wrap;
1069
+ }
1070
+ .filter-chip {
1071
+ padding: 4px 12px; border-radius: var(--radius-full);
1072
+ font-family: var(--font-mono); font-size: 11px;
1073
+ letter-spacing: 0.04em; text-transform: uppercase;
1074
+ background: transparent; border: 1px solid var(--border-default);
1075
+ color: var(--text-tertiary);
1076
+ cursor: pointer; user-select: none;
1077
+ transition: color var(--duration-fast) var(--ease-out),
1078
+ border-color var(--duration-fast) var(--ease-out),
1079
+ background var(--duration-fast) var(--ease-out);
1080
+ }
1081
+ .filter-chip:hover { color: var(--text-primary); border-color: var(--border-strong); }
1082
+ .filter-chip.active {
1083
+ color: var(--cerulean-400);
1084
+ border-color: var(--cerulean-400);
1085
+ background: rgba(56, 189, 248, 0.06);
1086
+ }
1087
+ .filter-input {
1088
+ background: var(--surface-1); border: 1px solid var(--border-default);
1089
+ color: var(--text-primary); border-radius: var(--radius-sm);
1090
+ padding: 5px 10px; font-size: 12.5px;
1091
+ min-width: 220px;
1092
+ font-family: var(--font-mono);
1093
+ }
1094
+ .filter-input:focus { outline: none; border-color: var(--cerulean-400); box-shadow: var(--focus-ring); }
1095
+ .filter-input::placeholder { color: var(--text-tertiary); }
1096
+
1097
+ /* \u2500\u2500\u2500 Copy button \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1098
+ .copy-btn {
1099
+ background: transparent; border: 1px solid transparent;
1100
+ color: var(--text-tertiary);
1101
+ border-radius: var(--radius-xs);
1102
+ padding: 1px 6px; font-size: 11px;
1103
+ cursor: pointer; font-family: var(--font-mono);
1104
+ transition: color var(--duration-fast) var(--ease-out),
1105
+ border-color var(--duration-fast) var(--ease-out),
1106
+ background var(--duration-fast) var(--ease-out);
1107
+ }
1108
+ .copy-btn:hover {
1109
+ color: var(--text-primary);
1110
+ border-color: var(--border-default);
1111
+ background: var(--surface-2);
1112
+ }
1113
+ .copy-btn.copied { color: var(--mint-400); border-color: rgba(52,211,153,0.3); }
1114
+
1115
+ /* \u2500\u2500\u2500 Live Probe panel (Turn 8 chunk 5) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1116
+ .probe-timestamps {
1117
+ display: flex; gap: var(--space-4); flex-wrap: wrap;
1118
+ font-size: 11.5px; color: var(--text-tertiary);
1119
+ margin-bottom: var(--space-3);
1120
+ }
1121
+ .probe-timestamps code {
1122
+ font-family: var(--font-mono); color: var(--text-secondary);
1123
+ }
1124
+ .probe-inject-empty, .probe-inject-queued {
1125
+ display: flex; flex-direction: column; gap: var(--space-2);
1126
+ margin-bottom: var(--space-3);
1127
+ }
1128
+ .probe-inject-textarea {
1129
+ width: 100%; box-sizing: border-box;
1130
+ background: var(--surface-2);
1131
+ border: 1px solid var(--border-default);
1132
+ border-radius: var(--radius-sm);
1133
+ padding: var(--space-2) var(--space-3);
1134
+ font-family: var(--font-mono); font-size: 12px;
1135
+ color: var(--text-primary);
1136
+ resize: vertical;
1137
+ }
1138
+ .probe-inject-textarea:focus {
1139
+ outline: none; border-color: var(--cerulean-400);
1140
+ box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.15);
1141
+ }
1142
+ .probe-inject-preview {
1143
+ background: var(--surface-2);
1144
+ border: 1px solid var(--border-default);
1145
+ border-radius: var(--radius-sm);
1146
+ padding: var(--space-2) var(--space-3);
1147
+ font-family: var(--font-mono); font-size: 12px;
1148
+ color: var(--text-secondary);
1149
+ margin: 0; white-space: pre-wrap; word-break: break-word;
1150
+ max-height: 160px; overflow: auto;
1151
+ }
1152
+ .probe-inject-actions, .probe-actions {
1153
+ display: flex; gap: var(--space-2); align-items: center;
1154
+ }
1155
+ .probe-resume-btn { /* visually distinct from Pause */ }
1156
+
1157
+ /* \u2500\u2500\u2500 Keyboard help \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1158
+ .kbd-help {
1159
+ position: fixed; right: 24px; bottom: 24px; z-index: 80;
1160
+ background: var(--surface-2);
1161
+ border: 1px solid var(--border-strong);
1162
+ border-radius: var(--radius-md);
1163
+ padding: 14px 18px;
1164
+ font-size: 12px;
1165
+ display: none;
1166
+ box-shadow: var(--elevation-2);
1167
+ }
1168
+ .kbd-help.open { display: block; }
1169
+ .kbd-help kbd {
1170
+ background: var(--surface-3); border: 1px solid var(--border-strong);
1171
+ border-bottom-width: 2px;
1172
+ padding: 1px 6px; border-radius: var(--radius-xs);
1173
+ font-family: var(--font-mono); font-size: 11px;
1174
+ color: var(--text-primary);
1175
+ }
1176
+ .kbd-help-toggle {
1177
+ position: fixed; right: 24px; bottom: 24px; z-index: 79;
1178
+ width: 32px; height: 32px; border-radius: var(--radius-full);
1179
+ background: var(--surface-2); border: 1px solid var(--border-default);
1180
+ color: var(--text-tertiary);
1181
+ cursor: pointer; padding: 0;
1182
+ font-size: 14px; line-height: 1;
1183
+ transition: color var(--duration-fast) var(--ease-out),
1184
+ border-color var(--duration-fast) var(--ease-out);
1185
+ }
1186
+ .kbd-help-toggle:hover { color: var(--cerulean-400); border-color: var(--cerulean-400); }
1187
+
1188
+ /* \u2500\u2500\u2500 Timeline \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1189
+ .timeline {
1190
+ display: flex; flex-wrap: wrap; gap: 4px;
1191
+ background: var(--surface-1); padding: var(--space-3);
1192
+ border: 1px solid var(--border-default);
1193
+ border-radius: var(--radius-md); margin-bottom: var(--space-3);
1194
+ position: sticky; top: 60px; z-index: 20;
1195
+ max-height: 144px; overflow-y: auto;
1196
+ }
1197
+ .timeline .blk {
1198
+ min-width: 18px; height: 22px; padding: 2px 7px;
1199
+ border-radius: var(--radius-xs); background: var(--surface-2);
1200
+ font-family: var(--font-mono); font-size: 11px;
1201
+ color: var(--text-tertiary);
1202
+ border: 1px solid var(--border-default);
1203
+ cursor: pointer; user-select: none;
1204
+ transition: border-color var(--duration-fast) var(--ease-out),
1205
+ color var(--duration-fast) var(--ease-out),
1206
+ background var(--duration-fast) var(--ease-out);
1207
+ line-height: 18px;
1208
+ }
1209
+ .timeline .blk:hover {
1210
+ color: var(--text-primary);
1211
+ border-color: var(--border-strong);
1212
+ }
1213
+ .timeline .blk.ok { color: var(--text-secondary); border-color: rgba(52,211,153,0.3); }
1214
+ .timeline .blk.error {
1215
+ background: var(--coral-bg); border-color: rgba(248,113,113,0.4); color: var(--coral-400);
1216
+ }
1217
+ .timeline .blk.in_progress { color: var(--cerulean-300); border-color: rgba(56,189,248,0.3); }
1218
+ .timeline .blk.active {
1219
+ color: var(--text-on-accent);
1220
+ background: var(--cerulean-400);
1221
+ border-color: var(--cerulean-400);
1222
+ box-shadow: 0 0 0 2px rgba(56,189,248,0.2);
1223
+ }
1224
+
1225
+ /* \u2500\u2500\u2500 Step cards \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1226
+ .step-card {
1227
+ background: var(--surface-1);
1228
+ border: 1px solid var(--border-default);
1229
+ border-radius: var(--radius-md);
1230
+ padding: var(--space-5) var(--space-6);
1231
+ margin-bottom: var(--space-3);
1232
+ scroll-margin-top: 80px;
1233
+ transition: border-color var(--duration-default) var(--ease-out),
1234
+ transform var(--duration-default) var(--ease-out),
1235
+ box-shadow var(--duration-default) var(--ease-out);
1236
+ }
1237
+ .step-card:hover {
1238
+ border-color: var(--border-strong);
1239
+ }
1240
+ .step-card.active {
1241
+ border-color: var(--cerulean-400);
1242
+ box-shadow: 0 0 0 1px var(--cerulean-400) inset,
1243
+ 0 4px 32px -8px rgba(56, 189, 248, 0.16);
1244
+ }
1245
+ .step-card h3 { margin: 0 0 var(--space-3) 0; font-size: 13px; }
1246
+ .step-card .row-actions {
1247
+ display: flex; gap: var(--space-2); align-items: center;
1248
+ }
1249
+ .step-card .row-actions button {
1250
+ background: transparent; border: 1px solid var(--border-default); color: var(--text-secondary);
1251
+ border-radius: var(--radius-sm); padding: 3px 10px; font-size: 11px;
1252
+ }
1253
+ .step-card .row-actions button:hover {
1254
+ color: var(--text-primary); border-color: var(--border-strong); background: var(--surface-2);
1255
+ }
1256
+ .step-card .row-actions button.pretty-toggle[aria-pressed="true"] {
1257
+ color: var(--cerulean-300); border-color: var(--cerulean-400); background: rgba(56,189,248,0.08);
1258
+ }
1259
+
1260
+ /* \u2500\u2500\u2500 Pretty-print color classes (matches CLI ANSI palette) \u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1261
+ pre.body.pretty .p-section { color: var(--text-primary); font-weight: 600; }
1262
+ pre.body.pretty .p-key { color: var(--text-tertiary); }
1263
+ pre.body.pretty .p-val { color: var(--text-primary); }
1264
+ pre.body.pretty .p-str { color: var(--violet-400); }
1265
+ pre.body.pretty .p-num { color: var(--cerulean-400); }
1266
+ pre.body.pretty .p-bool { color: var(--cerulean-400); }
1267
+ pre.body.pretty .p-null { color: var(--text-tertiary); }
1268
+ pre.body.pretty .p-meta { color: var(--text-tertiary); }
1269
+ pre.body.pretty .p-block { color: var(--text-tertiary); }
1270
+ pre.body.pretty .p-ok { color: #22C55E; }
1271
+ pre.body.pretty .p-error { color: var(--coral-400, #F87171); }
1272
+ pre.body.pretty .p-pending { color: #F59E0B; }
1273
+ pre.body.pretty .p-meta a { color: var(--cerulean-400); }
1274
+
1275
+ /* \u2500\u2500\u2500 Code blocks (decision/action/outcome bodies) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1276
+ pre.body {
1277
+ background: var(--surface-0);
1278
+ border: 1px solid var(--border-default);
1279
+ padding: var(--space-3) var(--space-4);
1280
+ border-radius: var(--radius-sm);
1281
+ max-height: 360px;
1282
+ overflow: auto;
1283
+ white-space: pre-wrap;
1284
+ word-break: break-word;
1285
+ font-family: var(--font-mono);
1286
+ font-size: 12.5px;
1287
+ line-height: 1.6;
1288
+ color: var(--text-primary);
1289
+ }
1290
+
1291
+ /* \u2500\u2500\u2500 Tab bar (per-step Decision/Action/etc.) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1292
+ .tab-bar { display: flex; gap: var(--space-1); margin-bottom: var(--space-3); }
1293
+ .tab-bar button {
1294
+ background: transparent; color: var(--text-tertiary);
1295
+ border: 1px solid transparent; border-radius: var(--radius-sm);
1296
+ padding: 5px 12px; cursor: pointer;
1297
+ font-family: var(--font-mono); font-size: 11px;
1298
+ text-transform: uppercase; letter-spacing: 0.06em;
1299
+ transition: color var(--duration-fast) var(--ease-out),
1300
+ background var(--duration-fast) var(--ease-out);
1301
+ }
1302
+ .tab-bar button:hover { color: var(--text-primary); }
1303
+ .tab-bar button.active {
1304
+ color: var(--cerulean-400);
1305
+ background: var(--surface-2);
1306
+ border-color: var(--border-default);
1307
+ }
1308
+
1309
+ /* \u2500\u2500\u2500 Meta row (run header) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1310
+ .meta-row {
1311
+ display: flex; gap: var(--space-5); flex-wrap: wrap;
1312
+ font-size: 12.5px; color: var(--text-tertiary);
1313
+ margin-bottom: var(--space-4);
1314
+ font-family: var(--font-mono);
1315
+ }
1316
+ .meta-row .kv { display: inline-flex; gap: 6px; align-items: baseline; }
1317
+ .meta-row .kv strong {
1318
+ color: var(--text-tertiary); font-weight: 500;
1319
+ text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em;
1320
+ }
1321
+ .meta-row .kv .val,
1322
+ .meta-row .kv > span:not(.copy-btn):not(.badge):not(.pill) {
1323
+ color: var(--text-primary);
1324
+ font-variant-numeric: tabular-nums;
1325
+ }
1326
+
1327
+ /* \u2500\u2500\u2500 Annotations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1328
+ .annotation {
1329
+ background: rgba(56, 189, 248, 0.05);
1330
+ border-left: 2px solid var(--cerulean-400);
1331
+ padding: var(--space-2) var(--space-3);
1332
+ margin: var(--space-2) 0;
1333
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
1334
+ font-size: 12.5px;
1335
+ }
1336
+ .annotation strong { color: var(--text-primary); }
1337
+
1338
+ /* \u2500\u2500\u2500 Diff rows \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1339
+ .diff-row td { padding: var(--space-3) var(--space-4); }
1340
+ .diff-row.shared { opacity: 0.55; }
1341
+ .diff-row.context_diff td:nth-child(1) { border-left: 2px solid var(--amber-400); }
1342
+ .diff-row.decision_diff td:nth-child(1) { border-left: 2px solid var(--cerulean-400); }
1343
+ .diff-row.action_diff td:nth-child(1) { border-left: 2px solid var(--coral-400); }
1344
+ .diff-row.outcome_diff td:nth-child(1) { border-left: 2px solid var(--violet-400); }
1345
+ .diff-row.only_a td:nth-child(1) { border-left: 2px solid var(--coral-400); }
1346
+ .diff-row.only_b td:nth-child(1) { border-left: 2px solid var(--mint-400); }
1347
+ .diff-row.diverged td:nth-child(1) { border-left: 2px solid var(--violet-400); }
1348
+
1349
+ .empty {
1350
+ color: var(--text-tertiary); font-style: italic;
1351
+ padding: var(--space-12) var(--space-6);
1352
+ text-align: center;
1353
+ border: 1px dashed var(--border-default);
1354
+ border-radius: var(--radius-md);
1355
+ background: var(--surface-1);
1356
+ }
1357
+
1358
+ /* \u2500\u2500\u2500 Fleet grid \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1359
+ .fleet {
1360
+ display: grid;
1361
+ grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
1362
+ gap: var(--space-4);
1363
+ }
1364
+ .card {
1365
+ background: var(--surface-1);
1366
+ border: 1px solid var(--border-default);
1367
+ border-radius: var(--radius-md);
1368
+ padding: var(--space-5);
1369
+ position: relative;
1370
+ transition: border-color var(--duration-default) var(--ease-out),
1371
+ transform var(--duration-default) var(--ease-out),
1372
+ box-shadow var(--duration-default) var(--ease-out);
1373
+ }
1374
+ .card:hover {
1375
+ border-color: var(--border-strong);
1376
+ transform: translateY(-1px);
1377
+ box-shadow: var(--elevation-1);
1378
+ }
1379
+ .card .title-row {
1380
+ display: flex; justify-content: space-between; align-items: center;
1381
+ margin-bottom: var(--space-3); gap: var(--space-3);
1382
+ }
1383
+ .card .title-row .title {
1384
+ font-weight: 600; font-size: 14px; letter-spacing: -0.005em;
1385
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1386
+ color: var(--text-primary);
1387
+ }
1388
+ .card .title-row .title:hover { color: var(--cerulean-400); text-decoration: none; }
1389
+ .card .meta {
1390
+ font-family: var(--font-mono);
1391
+ font-size: 11px; color: var(--text-tertiary);
1392
+ display: flex; gap: var(--space-3); flex-wrap: wrap;
1393
+ margin-top: var(--space-3);
1394
+ letter-spacing: 0.02em;
1395
+ }
1396
+ .card .meta .kv { color: var(--text-secondary); }
1397
+ .card .meta .age { font-variant-numeric: tabular-nums; color: var(--text-tertiary); }
1398
+
1399
+ /* Context utilization bar */
1400
+ .ctx-bar {
1401
+ height: 3px; background: var(--surface-3);
1402
+ border-radius: var(--radius-full);
1403
+ margin: var(--space-3) 0;
1404
+ position: relative; overflow: hidden;
1405
+ }
1406
+ .ctx-bar .fill {
1407
+ height: 100%;
1408
+ background: var(--cerulean-400);
1409
+ transition: width var(--duration-slow) var(--ease-out);
1410
+ border-radius: var(--radius-full);
1411
+ }
1412
+ .ctx-bar.warn .fill { background: var(--amber-400); }
1413
+ .ctx-bar.danger .fill { background: var(--coral-400); }
1414
+
1415
+ .recent-tools {
1416
+ font-family: var(--font-mono); font-size: 11px;
1417
+ color: var(--text-secondary);
1418
+ display: flex; gap: 4px; flex-wrap: wrap; align-items: center;
1419
+ }
1420
+ /* Small mono-uppercase label rendered before the tool chips. Same
1421
+ idiom as section-label so the fleet card reads consistently with
1422
+ the rest of the system. */
1423
+ .recent-tools-label {
1424
+ font-family: var(--font-mono);
1425
+ font-size: 10px;
1426
+ font-weight: 500;
1427
+ letter-spacing: 0.08em;
1428
+ text-transform: uppercase;
1429
+ color: var(--text-tertiary);
1430
+ margin-right: 4px;
1431
+ }
1432
+ .recent-tools code {
1433
+ background: var(--surface-2);
1434
+ border: 1px solid var(--border-subtle);
1435
+ padding: 1px 6px; border-radius: var(--radius-xs);
1436
+ color: var(--cerulean-300);
1437
+ }
1438
+ .alert-strip {
1439
+ margin-top: var(--space-2);
1440
+ padding: var(--space-2) var(--space-3);
1441
+ border-radius: var(--radius-sm);
1442
+ font-size: 11.5px;
1443
+ font-family: var(--font-mono);
1444
+ background: var(--coral-bg); color: var(--coral-400);
1445
+ border: 1px solid rgba(248,113,113,0.25);
1446
+ }
1447
+ .alert-strip.warn {
1448
+ background: var(--amber-bg); color: var(--amber-400);
1449
+ border-color: rgba(251,191,36,0.25);
1450
+ }
1451
+
1452
+ .static-banner {
1453
+ background: var(--surface-1);
1454
+ border: 1px solid var(--border-default);
1455
+ border-left: 2px solid var(--cerulean-400);
1456
+ border-radius: var(--radius-sm);
1457
+ padding: var(--space-3) var(--space-4);
1458
+ font-size: 12.5px; color: var(--text-secondary);
1459
+ margin-bottom: var(--space-4);
1460
+ }
1461
+ .static-banner strong { color: var(--text-primary); }
1462
+ .static-banner code {
1463
+ background: var(--surface-2);
1464
+ border: 1px solid var(--border-default);
1465
+ padding: 1px 6px; border-radius: var(--radius-xs);
1466
+ color: var(--cerulean-300);
1467
+ font-family: var(--font-mono);
1468
+ }
1469
+
1470
+ /* \u2500\u2500\u2500 Modals \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1471
+ .modal-bg {
1472
+ position: fixed; inset: 0;
1473
+ background: rgba(8, 9, 11, 0.7);
1474
+ backdrop-filter: blur(4px);
1475
+ display: none; align-items: center; justify-content: center; z-index: 100;
1476
+ }
1477
+ .modal-bg.open { display: flex; }
1478
+ .modal {
1479
+ background: var(--surface-2);
1480
+ border: 1px solid var(--border-strong);
1481
+ border-radius: var(--radius-lg);
1482
+ padding: var(--space-6);
1483
+ width: 540px; max-width: 92vw;
1484
+ max-height: 80vh; overflow: auto;
1485
+ box-shadow: var(--elevation-2);
1486
+ }
1487
+ .modal h3 { margin: 0 0 var(--space-4) 0; font-size: 16px; font-weight: 600; }
1488
+ .modal label {
1489
+ display: block;
1490
+ font-family: var(--font-mono);
1491
+ font-size: 11px;
1492
+ text-transform: uppercase; letter-spacing: 0.06em;
1493
+ color: var(--text-tertiary);
1494
+ margin-bottom: var(--space-1);
1495
+ margin-top: var(--space-3);
1496
+ }
1497
+ .modal input, .modal textarea, .modal select { width: 100%; }
1498
+ .modal .actions {
1499
+ margin-top: var(--space-5); display: flex;
1500
+ gap: var(--space-2); justify-content: flex-end;
1501
+ }
1502
+
1503
+ /* \u2500\u2500\u2500 Tests page \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1504
+ .tests-grid {
1505
+ display: grid; grid-template-columns: 280px 1fr;
1506
+ gap: var(--space-4); min-height: 400px;
1507
+ }
1508
+ .test-list {
1509
+ background: var(--surface-1);
1510
+ border: 1px solid var(--border-default);
1511
+ border-radius: var(--radius-md);
1512
+ padding: var(--space-2);
1513
+ overflow: auto; max-height: 75vh;
1514
+ }
1515
+ .test-list .item {
1516
+ display: block; padding: var(--space-2) var(--space-3);
1517
+ border-radius: var(--radius-sm);
1518
+ color: var(--text-primary); cursor: pointer; font-size: 13px;
1519
+ transition: background var(--duration-fast) var(--ease-out);
1520
+ }
1521
+ .test-list .item:hover { background: var(--surface-2); text-decoration: none; }
1522
+ .test-list .item.active {
1523
+ background: var(--surface-2);
1524
+ color: var(--cerulean-400);
1525
+ border-left: 2px solid var(--cerulean-400);
1526
+ }
1527
+ .test-list .item .meta {
1528
+ font-family: var(--font-mono);
1529
+ font-size: 11px; color: var(--text-tertiary);
1530
+ margin-top: 2px;
1531
+ }
1532
+ .test-detail {
1533
+ background: var(--surface-1);
1534
+ border: 1px solid var(--border-default);
1535
+ border-radius: var(--radius-md);
1536
+ padding: var(--space-5) var(--space-6);
1537
+ min-height: 200px;
1538
+ }
1539
+ .assertion-row {
1540
+ display: grid;
1541
+ grid-template-columns: 180px 1fr 90px 32px;
1542
+ gap: var(--space-2); align-items: center;
1543
+ padding: var(--space-2) 0;
1544
+ border-bottom: 1px dashed var(--border-subtle);
1545
+ }
1546
+ .assertion-row select, .assertion-row input { padding: 5px 8px; font-size: 12.5px; }
1547
+ .assertion-row .rm {
1548
+ background: transparent; color: var(--coral-400); border: none; cursor: pointer;
1549
+ font-size: 16px; line-height: 1;
1550
+ }
1551
+ .assertion-row .rm:hover { color: var(--coral-500); background: transparent; border: none; }
1552
+ .results-list {
1553
+ margin-top: var(--space-4);
1554
+ border-top: 1px solid var(--border-subtle); padding-top: var(--space-3);
1555
+ }
1556
+ .results-list .row {
1557
+ font-family: var(--font-mono); font-size: 12px;
1558
+ padding: 3px 0;
1559
+ letter-spacing: 0.02em;
1560
+ }
1561
+ .results-list .pass { color: var(--mint-400); }
1562
+ .results-list .fail { color: var(--coral-400); }
1563
+
1564
+ /* \u2500\u2500\u2500 Reduced motion \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1565
+ @media (prefers-reduced-motion: reduce) {
1566
+ *, *::before, *::after {
1567
+ animation-duration: 0.001ms !important;
1568
+ animation-iteration-count: 1 !important;
1569
+ transition-duration: 0.001ms !important;
1570
+ scroll-behavior: auto !important;
1571
+ }
1572
+ }
1573
+
1574
+ /* \u2500\u2500\u2500 API-metered cost disclosure \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1575
+ /* Tooltip-style marker rendered inline next to every $cost figure.
1576
+ Subscription users (Claude Pro / Max) don't pay these dollars \u2014
1577
+ Meterbility's number is the API-equivalent rate, and the API rate itself
1578
+ reflects VC-subsidized 2026 pricing. Tooltip on the chip spells
1579
+ this out; the cost-footnote block at the page bottom expands. */
1580
+ .cost-mark {
1581
+ display: inline-flex; align-items: center;
1582
+ margin-left: var(--space-1);
1583
+ font-family: var(--font-mono);
1584
+ font-size: 9px;
1585
+ font-weight: 500;
1586
+ letter-spacing: 0.06em;
1587
+ color: var(--text-tertiary);
1588
+ border: 1px solid var(--border-default);
1589
+ background: var(--surface-2);
1590
+ padding: 1px 4px;
1591
+ border-radius: var(--radius-xs);
1592
+ cursor: help;
1593
+ text-transform: uppercase;
1594
+ vertical-align: middle;
1595
+ transition: color var(--duration-fast) var(--ease-out),
1596
+ border-color var(--duration-fast) var(--ease-out);
1597
+ }
1598
+ .cost-mark:hover {
1599
+ color: var(--cerulean-300);
1600
+ border-color: var(--cerulean-400);
1601
+ }
1602
+
1603
+ .cost-footnote {
1604
+ margin-top: var(--space-8);
1605
+ padding: var(--space-3) var(--space-4);
1606
+ font-size: 12px;
1607
+ line-height: 1.6;
1608
+ color: var(--text-secondary);
1609
+ background: var(--surface-1);
1610
+ border: 1px solid var(--border-default);
1611
+ border-left: 2px solid var(--cerulean-400);
1612
+ border-radius: var(--radius-sm);
1613
+ }
1614
+ .cost-footnote strong { color: var(--text-primary); }
1615
+ .cost-footnote em { color: var(--cerulean-300); font-style: normal; }
1616
+ .cost-footnote .label {
1617
+ color: var(--cerulean-400);
1618
+ text-transform: uppercase;
1619
+ font-size: 10px;
1620
+ letter-spacing: 0.1em;
1621
+ font-family: var(--font-mono);
1622
+ margin-right: var(--space-2);
1623
+ }
1624
+ `;
1625
+ var SCRIPT = `
1626
+ function fmtAge(iso) {
1627
+ if (!iso) return '\u2014';
1628
+ const ms = Date.now() - new Date(iso).getTime();
1629
+ if (ms < 0) return 'just now';
1630
+ const s = Math.round(ms / 1000);
1631
+ if (s < 60) return s + 's ago';
1632
+ if (s < 3600) return Math.round(s / 60) + 'm ago';
1633
+ return Math.round(s / 3600) + 'h ago';
1634
+ }
1635
+ function costStr(cents) {
1636
+ const dollars = cents / 100;
1637
+ if (dollars === 0) return '$0.00';
1638
+ if (Math.abs(dollars) >= 0.005) return '$' + dollars.toFixed(2);
1639
+ return '$' + dollars.toFixed(4);
1640
+ }
1641
+ const COST_MARK = '<span class="cost-mark" title="API-equivalent rate. (1) Subscription users (Pro/Max) pay a flat fee, not this. (2) Reflects VC-subsidized 2026 pricing \u2014 training and cluster CapEx aren\\'t in the per-token bill. Use for relative comparison only.">api\xB7metered</span>';
1642
+ function ctxBarClass(pct) {
1643
+ if (pct >= 90) return 'ctx-bar danger';
1644
+ if (pct >= 70) return 'ctx-bar warn';
1645
+ return 'ctx-bar';
1646
+ }
1647
+ function escapeHtml(s) {
1648
+ if (s == null) return '';
1649
+ return String(s).replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
1650
+ }
1651
+ function renderFleetEntry(e) {
1652
+ const r = e.run;
1653
+ const tools = (e.recent_tools || []).map(t => '<code>' + escapeHtml(t) + '</code>').join('');
1654
+ const alerts = (e.alerts || []).map(a => '<div class="alert-strip ' + (a.kind === 'stall' ? 'warn' : '') + '">' + escapeHtml(a.message) + '</div>').join('');
1655
+ return '<div class="card" data-run="' + escapeHtml(r.run_id) + '">'
1656
+ + '<div class="title-row">'
1657
+ + '<a class="title" href="/runs/' + escapeHtml(r.run_id) + '">' + escapeHtml(r.title || r.run_id) + '</a>'
1658
+ + '<span class="pill live-' + escapeHtml(e.status) + '">' + escapeHtml(e.status) + '</span>'
1659
+ + '</div>'
1660
+ + '<div class="meta">'
1661
+ + '<span class="kv">' + r.step_count + ' steps</span>'
1662
+ + '<span class="kv">' + costStr(r.cost_cents) + COST_MARK + '</span>'
1663
+ + '<span class="kv">' + escapeHtml(r.git_branch || '') + '</span>'
1664
+ + '<span class="age" data-age="' + escapeHtml(e.last_step_at || '') + '">' + fmtAge(e.last_step_at) + '</span>'
1665
+ + '</div>'
1666
+ + '<div class="' + ctxBarClass(e.context_pct) + '" title="context util ' + e.context_pct + '%"><div class="fill" style="width:' + e.context_pct + '%"></div></div>'
1667
+ + '<div class="recent-tools"><span class="recent-tools-label">Recent Tools Used</span>' + (tools || '<span style="opacity:0.5">no tools yet</span>') + '</div>'
1668
+ + alerts
1669
+ + '</div>';
1670
+ }
1671
+ function tickAges() {
1672
+ document.querySelectorAll('[data-age]').forEach(el => {
1673
+ el.textContent = fmtAge(el.dataset.age);
1674
+ });
1675
+ }
1676
+ function isLiveMode() {
1677
+ const meta = document.querySelector('meta[name="meter-live-mode"]');
1678
+ return meta && meta.getAttribute('content') === '1';
1679
+ }
1680
+ function startLive() {
1681
+ // The "tick ages every second" loop is useful in both modes; the
1682
+ // SSE EventSource only makes sense when --live is on.
1683
+ if (document.getElementById('fleet-grid')) {
1684
+ setInterval(tickAges, 1000);
1685
+ tickAges();
1686
+ }
1687
+ if (!isLiveMode() || typeof EventSource === 'undefined') return;
1688
+ const root = document.getElementById('fleet-grid');
1689
+ if (!root) return;
1690
+ const src = new EventSource('/api/live');
1691
+ src.addEventListener('fleet:snapshot', (ev) => {
1692
+ const data = JSON.parse(ev.data);
1693
+ root.innerHTML = data.entries.map(renderFleetEntry).join('') || '<div class="empty">No active runs.</div>';
1694
+ });
1695
+ src.addEventListener('alert', (ev) => {
1696
+ const data = JSON.parse(ev.data);
1697
+ const banner = document.getElementById('alert-banner');
1698
+ if (!banner) return;
1699
+ const div = document.createElement('div');
1700
+ div.className = 'alert-strip';
1701
+ div.innerHTML = '<strong>' + escapeHtml(data.kind) + '</strong> \xB7 ' + escapeHtml(data.message) + ' \xB7 <a href="/runs/' + escapeHtml(data.run_id) + '">open</a>';
1702
+ banner.appendChild(div);
1703
+ setTimeout(() => div.remove(), 12000);
1704
+ });
1705
+ }
1706
+ window.addEventListener('DOMContentLoaded', startLive);
1707
+
1708
+ function showTab(stepId, tab) {
1709
+ const tabs = document.querySelectorAll('[data-step="' + stepId + '"] .tab');
1710
+ tabs.forEach(t => t.style.display = 'none');
1711
+ const target = document.querySelector('[data-step="' + stepId + '"] .tab.tab-' + tab);
1712
+ if (target) target.style.display = '';
1713
+ document.querySelectorAll('[data-step="' + stepId + '"] .tab-btn').forEach(b => b.classList.remove('active'));
1714
+ const btn = document.querySelector('[data-step="' + stepId + '"] .tab-btn[data-tab="' + tab + '"]');
1715
+ if (btn) btn.classList.add('active');
1716
+ }
1717
+ /* \u2500\u2500\u2500 Per-step pretty toggle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1718
+ * One button per step card flips all four tab bodies between raw
1719
+ * and pretty. State persists in localStorage under
1720
+ * meter:pretty:<run_id>:<step_id>. Live-appended step cards check
1721
+ * this key when they mount so reload + SSE both restore correctly.
1722
+ */
1723
+ function togglePretty(btn) {
1724
+ const card = btn.closest('.step-card');
1725
+ if (!card) return;
1726
+ const runId = btn.dataset.runId;
1727
+ const stepId = btn.dataset.stepId;
1728
+ const key = 'meter:pretty:' + runId + ':' + stepId;
1729
+ const turningOn = btn.getAttribute('aria-pressed') !== 'true';
1730
+ applyPrettyState(card, turningOn);
1731
+ try {
1732
+ if (turningOn) localStorage.setItem(key, '1');
1733
+ else localStorage.removeItem(key);
1734
+ } catch (e) { /* private browsing \u2014 toggle still works, state lost on reload */ }
1735
+ }
1736
+ function applyPrettyState(card, pretty) {
1737
+ const btn = card.querySelector('.pretty-toggle');
1738
+ if (btn) btn.setAttribute('aria-pressed', pretty ? 'true' : 'false');
1739
+ card.querySelectorAll('.tab .raw').forEach(el => {
1740
+ if (pretty) el.style.display = 'none';
1741
+ else el.style.removeProperty('display');
1742
+ });
1743
+ card.querySelectorAll('.tab .pretty').forEach(el => {
1744
+ if (pretty) el.style.removeProperty('display');
1745
+ else el.style.display = 'none';
1746
+ });
1747
+ }
1748
+ function restorePrettyForCard(card) {
1749
+ const btn = card.querySelector('.pretty-toggle');
1750
+ if (!btn) return;
1751
+ const runId = btn.dataset.runId;
1752
+ const stepId = btn.dataset.stepId;
1753
+ const key = 'meter:pretty:' + runId + ':' + stepId;
1754
+ try {
1755
+ if (localStorage.getItem(key) === '1') applyPrettyState(card, true);
1756
+ } catch (e) { /* localStorage unavailable \u2014 leave default raw */ }
1757
+ }
1758
+ document.addEventListener('DOMContentLoaded', function() {
1759
+ document.querySelectorAll('.step-card[data-step]').forEach(restorePrettyForCard);
1760
+ });
1761
+ function jumpToStep(seq, opts) {
1762
+ // Scroll the step CARD into view, not the timeline block (which shares
1763
+ // the data-seq attribute). Sticky header offset is handled in CSS via
1764
+ // scroll-margin-top on .step-card.
1765
+ const card = document.getElementById('step-' + seq);
1766
+ if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
1767
+ setActiveStep(seq);
1768
+ if (!(opts && opts.skipHash)) {
1769
+ history.replaceState(null, '', '#step-' + seq);
1770
+ }
1771
+ }
1772
+ function setActiveStep(seq) {
1773
+ document.querySelectorAll('.blk').forEach(b => b.classList.remove('active'));
1774
+ const blk = document.querySelector('.blk[data-seq="' + seq + '"]');
1775
+ if (blk) {
1776
+ blk.classList.add('active');
1777
+ // Keep the active block in view as user scrolls past it.
1778
+ blk.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
1779
+ }
1780
+ document.querySelectorAll('.step-card.active').forEach(c => c.classList.remove('active'));
1781
+ const card = document.getElementById('step-' + seq);
1782
+ if (card) card.classList.add('active');
1783
+ }
1784
+
1785
+ /* --- IntersectionObserver: highlight whichever step is in view --- */
1786
+ function initStepObserver() {
1787
+ const cards = Array.from(document.querySelectorAll('.step-card[data-step-seq]'));
1788
+ if (cards.length === 0) return;
1789
+ const visible = new Map();
1790
+ const io = new IntersectionObserver((entries) => {
1791
+ for (const e of entries) {
1792
+ if (e.isIntersecting) visible.set(e.target, e.intersectionRatio);
1793
+ else visible.delete(e.target);
1794
+ }
1795
+ // Pick the most-visible card; on ties, the one earliest in the DOM.
1796
+ let best = null; let bestRatio = -1;
1797
+ for (const [el, ratio] of visible) {
1798
+ if (ratio > bestRatio) { best = el; bestRatio = ratio; }
1799
+ }
1800
+ if (best) {
1801
+ const seq = best.getAttribute('data-step-seq');
1802
+ if (seq !== null) setActiveStep(Number(seq));
1803
+ }
1804
+ }, { threshold: [0, 0.2, 0.5, 1], rootMargin: '-80px 0px -50% 0px' });
1805
+ cards.forEach(c => io.observe(c));
1806
+ }
1807
+
1808
+ /* --- Keyboard navigation --- */
1809
+ function initKeyboardNav() {
1810
+ const cards = () => Array.from(document.querySelectorAll('.step-card[data-step-seq]'));
1811
+ const currentSeq = () => {
1812
+ const active = document.querySelector('.step-card.active');
1813
+ return active ? Number(active.getAttribute('data-step-seq')) : -1;
1814
+ };
1815
+ document.addEventListener('keydown', (e) => {
1816
+ // Don't intercept while typing in inputs/textareas.
1817
+ const t = e.target;
1818
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
1819
+ const all = cards();
1820
+ if (all.length === 0) return;
1821
+ const cur = currentSeq();
1822
+ let next = null;
1823
+ if (e.key === 'j' || e.key === 'ArrowDown') {
1824
+ next = all.find(c => Number(c.getAttribute('data-step-seq')) > cur);
1825
+ if (!next) next = all[all.length - 1];
1826
+ } else if (e.key === 'k' || e.key === 'ArrowUp') {
1827
+ const before = all.filter(c => Number(c.getAttribute('data-step-seq')) < cur);
1828
+ next = before[before.length - 1] || all[0];
1829
+ } else if (e.key === 'g') {
1830
+ next = all[0];
1831
+ } else if (e.key === 'G') {
1832
+ next = all[all.length - 1];
1833
+ } else if (e.key === '/') {
1834
+ const f = document.getElementById('step-filter');
1835
+ if (f) { e.preventDefault(); f.focus(); }
1836
+ return;
1837
+ } else if (e.key === '?') {
1838
+ const help = document.getElementById('kbd-help');
1839
+ if (help) { e.preventDefault(); help.classList.toggle('open'); }
1840
+ return;
1841
+ } else {
1842
+ return;
1843
+ }
1844
+ if (next) {
1845
+ e.preventDefault();
1846
+ jumpToStep(Number(next.getAttribute('data-step-seq')));
1847
+ }
1848
+ });
1849
+ }
1850
+
1851
+ /* --- URL hash on load --- */
1852
+ function restoreFromHash() {
1853
+ const m = (location.hash || '').match(/^#step-(\\d+)$/);
1854
+ if (!m) return;
1855
+ // wait a tick so layout is final
1856
+ setTimeout(() => jumpToStep(Number(m[1]), { skipHash: true }), 30);
1857
+ }
1858
+
1859
+ /* --- Filter chips --- */
1860
+ function applyFilter(kind) {
1861
+ document.querySelectorAll('.filter-chip').forEach(c => c.classList.toggle('active', c.dataset.kind === kind));
1862
+ const cards = document.querySelectorAll('.step-card[data-step-seq]');
1863
+ cards.forEach(c => {
1864
+ const action = c.getAttribute('data-action-kind') || '';
1865
+ const status = c.getAttribute('data-step-status') || '';
1866
+ let show = true;
1867
+ if (kind === 'tools') show = action === 'tool_call';
1868
+ else if (kind === 'messages') show = action === 'message';
1869
+ else if (kind === 'errors') show = status === 'error';
1870
+ c.style.display = show ? '' : 'none';
1871
+ });
1872
+ const query = (document.getElementById('step-filter') || {}).value || '';
1873
+ if (query) applyTextFilter(query);
1874
+ }
1875
+ function applyTextFilter(q) {
1876
+ const ql = q.toLowerCase();
1877
+ document.querySelectorAll('.step-card[data-step-seq]').forEach(c => {
1878
+ if (c.style.display === 'none') return; // already filtered out by chip
1879
+ const text = (c.innerText || '').toLowerCase();
1880
+ c.style.display = !ql || text.includes(ql) ? '' : 'none';
1881
+ });
1882
+ }
1883
+
1884
+ /* --- v0.3 Files tab \u2014 per-row diff toggle --- */
1885
+ function toggleFileDiff(rowId) {
1886
+ const container = document.getElementById(rowId);
1887
+ if (!container) return;
1888
+ const pre = container.querySelector('.file-diff');
1889
+ if (!pre) return;
1890
+ const showing = pre.style.display !== 'none';
1891
+ pre.style.display = showing ? 'none' : '';
1892
+ const row = container.closest('.file-row');
1893
+ if (row) row.classList.toggle('expanded', !showing);
1894
+ }
1895
+
1896
+ /* --- Copy to clipboard --- */
1897
+ function copyText(text, btn) {
1898
+ navigator.clipboard.writeText(text).then(() => {
1899
+ if (!btn) return;
1900
+ const orig = btn.innerHTML;
1901
+ btn.innerHTML = '\u2713 copied';
1902
+ btn.classList.add('copied');
1903
+ setTimeout(() => { btn.innerHTML = orig; btn.classList.remove('copied'); }, 1200);
1904
+ });
1905
+ }
1906
+
1907
+ window.addEventListener('DOMContentLoaded', () => {
1908
+ initStepObserver();
1909
+ initKeyboardNav();
1910
+ restoreFromHash();
1911
+ initLiveToggle();
1912
+ initLiveRunUpdates();
1913
+ });
1914
+
1915
+ /* --- v0.3 Live toggle ---
1916
+ * Click handler for the header button. Hits POST /api/live/start or
1917
+ * /api/live/stop, then mirrors the response into the data-live
1918
+ * attribute so the CSS picks up the new state. On success we also
1919
+ * flip the meta tag so other components polling meter-live-mode
1920
+ * see the change.
1921
+ *
1922
+ * The button is disabled during the request to prevent double-fire,
1923
+ * and the failure path surfaces a flash message instead of silently
1924
+ * leaving the user staring at a button that did nothing. */
1925
+ async function toggleLive(event) {
1926
+ const btn = (event && event.currentTarget) || document.getElementById('live-toggle');
1927
+ if (!btn) return;
1928
+ const goingLive = btn.dataset.live !== '1';
1929
+ btn.setAttribute('disabled', '1');
1930
+ try {
1931
+ const res = await fetch('/api/live/' + (goingLive ? 'start' : 'stop'), {
1932
+ method: 'POST',
1933
+ headers: { 'Content-Type': 'application/json' },
1934
+ body: '{}',
1935
+ });
1936
+ if (!res.ok) throw new Error('HTTP ' + res.status);
1937
+ const body = await res.json();
1938
+ applyLiveState(body && body.live);
1939
+ } catch (err) {
1940
+ flash('live toggle failed: ' + (err.message || err), 'err');
1941
+ } finally {
1942
+ btn.removeAttribute('disabled');
1943
+ }
1944
+ }
1945
+
1946
+ function applyLiveState(isLive) {
1947
+ const btn = document.getElementById('live-toggle');
1948
+ if (btn) {
1949
+ btn.dataset.live = isLive ? '1' : '0';
1950
+ const dot = btn.querySelector('.dot');
1951
+ if (dot) {
1952
+ dot.classList.toggle('dot--success', !!isLive);
1953
+ dot.classList.toggle('dot--muted', !isLive);
1954
+ }
1955
+ const label = btn.querySelector('.live-toggle-label');
1956
+ if (label) label.textContent = isLive ? 'LIVE' : 'GO LIVE';
1957
+ btn.title = isLive
1958
+ ? 'Click to stop live capture'
1959
+ : 'Click to start watching ~/.claude/projects for live agent activity';
1960
+ }
1961
+ const meta = document.querySelector('meta[name="meter-live-mode"]');
1962
+ if (meta) meta.setAttribute('content', isLive ? '1' : '0');
1963
+ // Tell any subscribed page features to react to the state change.
1964
+ document.dispatchEvent(
1965
+ new CustomEvent('meter:live-state', { detail: { live: !!isLive } }),
1966
+ );
1967
+ }
1968
+
1969
+ function initLiveToggle() {
1970
+ // Sync the button with /api/live/status on load \u2014 covers the case
1971
+ // where someone hit start in another tab between renders.
1972
+ fetch('/api/live/status')
1973
+ .then((r) => (r.ok ? r.json() : null))
1974
+ .then((b) => {
1975
+ if (b && typeof b.live === 'boolean') applyLiveState(b.live);
1976
+ })
1977
+ .catch(() => { /* silent \u2014 toggle still works via click */ });
1978
+ }
1979
+
1980
+ function flash(msg, kind) {
1981
+ const el = document.getElementById('flash');
1982
+ if (!el) return;
1983
+ el.textContent = msg;
1984
+ el.dataset.kind = kind || 'info';
1985
+ el.style.opacity = '1';
1986
+ setTimeout(() => { el.style.opacity = '0'; }, 3000);
1987
+ }
1988
+
1989
+ /* --- v0.3 Live run updates ---
1990
+ * On a /runs/:id page, open an EventSource and append new step cards
1991
+ * as they arrive \u2014 no more hard refresh to see the agent's progress.
1992
+ *
1993
+ * Strategy: we know the current run's id from a data attribute on
1994
+ * the main element (set by renderRun). On run:updated events for
1995
+ * this run, fetch the missing step cards via the new
1996
+ * GET /api/runs/:id/step-card/:seq fragment endpoint and append.
1997
+ * The timeline gets a new block in lockstep so the scrubber stays
1998
+ * in sync.
1999
+ *
2000
+ * Why fetch HTML fragments rather than JSON: the step card has a lot
2001
+ * of structural decisions (tabs, copy buttons, monospace path padding)
2002
+ * that the server already does well. Rebuilding all of that in JS
2003
+ * would duplicate render logic \u2014 fragments keep one source of truth. */
2004
+ function initLiveRunUpdates() {
2005
+ const main = document.querySelector('main');
2006
+ const runId = main && main.dataset && main.dataset.runId;
2007
+ if (!runId) return;
2008
+ let es;
2009
+ let knownStepCount = parseInt(main.dataset.stepCount || '0', 10) || 0;
2010
+ function ensureSubscribed() {
2011
+ if (es && es.readyState !== 2) return;
2012
+ try { es = new EventSource('/api/live'); } catch { return; }
2013
+ es.addEventListener('run:updated', (ev) => {
2014
+ try {
2015
+ const data = JSON.parse(ev.data);
2016
+ if (!data || !data.run || data.run.run_id !== runId) return;
2017
+ // The server tells us how many steps the run has now; we
2018
+ // fetch any sequences past our last-known count and append.
2019
+ appendStepsUpTo(data.run.step_count || 0);
2020
+ } catch (err) { /* ignore parse errors */ }
2021
+ });
2022
+ es.addEventListener('run:completed', (ev) => {
2023
+ try {
2024
+ const data = JSON.parse(ev.data);
2025
+ if (data && data.run && data.run.run_id === runId) {
2026
+ flash('run completed \u2014 refresh for the final timeline view', 'info');
2027
+ }
2028
+ } catch { /* ignore */ }
2029
+ });
2030
+ es.onerror = () => {
2031
+ // Browser will auto-reconnect; nothing to do.
2032
+ };
2033
+ }
2034
+ async function appendStepsUpTo(target) {
2035
+ while (knownStepCount < target) {
2036
+ const seq = knownStepCount;
2037
+ try {
2038
+ const res = await fetch(
2039
+ '/api/runs/' + encodeURIComponent(runId) + '/step-card/' + seq,
2040
+ );
2041
+ if (!res.ok) break; // server doesn't have the row yet \u2014 retry next event
2042
+ const html = await res.text();
2043
+ const wrap = document.createElement('div');
2044
+ wrap.innerHTML = html;
2045
+ // The fragment endpoint wraps the card + timeline block in a
2046
+ // single <div data-step-fragment="1">. We move each piece to
2047
+ // its real home, then drop the wrapper.
2048
+ const fragment = wrap.querySelector('[data-step-fragment]');
2049
+ if (!fragment) break;
2050
+ const card = fragment.querySelector('.step-card');
2051
+ const tlBlock = fragment.querySelector('[data-timeline-blk]');
2052
+ if (card) {
2053
+ const stepsAnchor = document.getElementById('steps-anchor');
2054
+ if (stepsAnchor) {
2055
+ // Replace the placeholder "no steps" empty state on the
2056
+ // first append.
2057
+ const placeholder = stepsAnchor.querySelector('.empty');
2058
+ if (placeholder) placeholder.remove();
2059
+ stepsAnchor.appendChild(card);
2060
+ } else {
2061
+ main.appendChild(card);
2062
+ }
2063
+ // The freshly-inserted card needs to honor any per-step
2064
+ // pretty toggle the user set on prior cards' siblings via
2065
+ // localStorage. Restore before any user interaction is
2066
+ // possible. (See togglePretty / restorePrettyForCard.)
2067
+ restorePrettyForCard(card);
2068
+ }
2069
+ if (tlBlock) {
2070
+ const tl = document.querySelector('.timeline');
2071
+ if (tl) tl.appendChild(tlBlock);
2072
+ }
2073
+ knownStepCount = seq + 1;
2074
+ main.dataset.stepCount = String(knownStepCount);
2075
+ flash('step #' + seq + ' captured live', 'info');
2076
+ } catch {
2077
+ break;
2078
+ }
2079
+ }
2080
+ }
2081
+ // Only subscribe when live is on; otherwise wait for the toggle.
2082
+ function maybeSubscribe(isLive) {
2083
+ if (isLive) ensureSubscribed();
2084
+ }
2085
+ const meta = document.querySelector('meta[name="meter-live-mode"]');
2086
+ maybeSubscribe(meta && meta.getAttribute('content') === '1');
2087
+ document.addEventListener('meter:live-state', (e) => {
2088
+ maybeSubscribe(e.detail && e.detail.live);
2089
+ });
2090
+ }
2091
+
2092
+ /* --- Modal helpers --- */
2093
+ function openModal(id) {
2094
+ const el = document.getElementById(id);
2095
+ if (el) el.classList.add('open');
2096
+ }
2097
+ function closeModal(id) {
2098
+ const el = document.getElementById(id);
2099
+ if (el) el.classList.remove('open');
2100
+ }
2101
+
2102
+ /* --- Fork modal --- */
2103
+ function openForkModal(runId, sequence, defaultText) {
2104
+ document.getElementById('fork-run-id').value = runId;
2105
+ document.getElementById('fork-seq').value = sequence;
2106
+ document.getElementById('fork-payload').value = defaultText || '';
2107
+ document.getElementById('fork-status').textContent = '';
2108
+ openModal('fork-modal');
2109
+ }
2110
+ async function submitFork() {
2111
+ const status = document.getElementById('fork-status');
2112
+ status.textContent = 'forking\u2026';
2113
+ status.style.color = 'var(--fg-mute)';
2114
+ const continueMode = document.getElementById('fork-continue').value;
2115
+ const allowToolsRaw = (document.getElementById('fork-allow-tools') || {}).value || '';
2116
+ const allowTools = allowToolsRaw.split(',').map(s => s.trim()).filter(Boolean);
2117
+ const body = {
2118
+ origin_run_id: document.getElementById('fork-run-id').value,
2119
+ at: parseInt(document.getElementById('fork-seq').value, 10),
2120
+ edit_type: document.getElementById('fork-edit-type').value,
2121
+ edit_payload: { text: document.getElementById('fork-payload').value },
2122
+ fake: document.getElementById('fork-fake').value || undefined,
2123
+ live: document.getElementById('fork-live').checked,
2124
+ continue: continueMode === 'none' ? undefined : continueMode,
2125
+ max_iterations: parseInt(document.getElementById('fork-max-iter').value || '25', 10),
2126
+ model: document.getElementById('fork-model').value || undefined,
2127
+ allow_tools: allowTools.length ? allowTools : undefined,
2128
+ };
2129
+ if (continueMode !== 'none') status.textContent = 'forking + continuing (' + continueMode + ')\u2026';
2130
+ try {
2131
+ const res = await fetch('/api/fork', {
2132
+ method: 'POST',
2133
+ headers: { 'Content-Type': 'application/json' },
2134
+ body: JSON.stringify(body),
2135
+ });
2136
+ const data = await res.json();
2137
+ if (!res.ok) throw new Error(data.error || 'fork failed');
2138
+ let summary = 'fork created \u2192 <a href="/runs/' + data.fork_run_id + '">open</a> \xB7 <a href="/diff?a=' + body.origin_run_id + '&b=' + data.fork_run_id + '">diff</a>';
2139
+ if (data.continuation) {
2140
+ const c = data.continuation;
2141
+ const colorFor = (r) => r === 'model_completed' ? 'var(--ok)' : r === 'tool_error' || r === 'model_error' ? 'var(--err)' : 'var(--warn)';
2142
+ summary += '<br><span style="color:var(--fg-mute);font-size:11px">continued \xB7 ' + c.iterations + ' iterations \xB7 ' + c.steps_added + ' steps added \xB7 </span>';
2143
+ summary += '<span style="color:' + colorFor(c.terminal_reason) + ';font-size:11px">' + c.terminal_reason + '</span>';
2144
+ }
2145
+ status.innerHTML = summary;
2146
+ status.style.color = 'var(--fg)';
2147
+ } catch (err) {
2148
+ status.textContent = 'error: ' + err.message;
2149
+ status.style.color = 'var(--err)';
2150
+ }
2151
+ }
2152
+
2153
+ /* --- Annotation --- */
2154
+ function openAnnotateModal(targetKind, targetId) {
2155
+ document.getElementById('ann-kind').value = targetKind;
2156
+ document.getElementById('ann-id').value = targetId;
2157
+ document.getElementById('ann-note').value = '';
2158
+ document.getElementById('ann-verdict').value = '';
2159
+ document.getElementById('ann-status').textContent = '';
2160
+ openModal('annotate-modal');
2161
+ }
2162
+ async function submitAnnotation() {
2163
+ const status = document.getElementById('ann-status');
2164
+ status.textContent = 'saving\u2026';
2165
+ const body = {
2166
+ target_kind: document.getElementById('ann-kind').value,
2167
+ target_id: document.getElementById('ann-id').value,
2168
+ verdict: document.getElementById('ann-verdict').value || undefined,
2169
+ note: document.getElementById('ann-note').value,
2170
+ author: 'web-ui',
2171
+ };
2172
+ try {
2173
+ const res = await fetch('/api/annotate', {
2174
+ method: 'POST',
2175
+ headers: { 'Content-Type': 'application/json' },
2176
+ body: JSON.stringify(body),
2177
+ });
2178
+ if (!res.ok) throw new Error('annotate failed');
2179
+ status.textContent = 'saved';
2180
+ setTimeout(() => { closeModal('annotate-modal'); location.reload(); }, 600);
2181
+ } catch (err) {
2182
+ status.textContent = 'error: ' + err.message;
2183
+ }
2184
+ }
2185
+
2186
+ /* --- Run close (manual seal for in_progress runs) --- */
2187
+ async function closeRun(runId, status) {
2188
+ const finalStatus = status || 'ok';
2189
+ const label = runId.slice(0, 12);
2190
+ if (!confirm('Close run ' + label + ' as "' + finalStatus + '"?\\n\\nProxy-captured runs stay in_progress until you close them \u2014 this is the manual seal.')) return;
2191
+ try {
2192
+ const res = await fetch('/api/runs/' + encodeURIComponent(runId) + '/close', {
2193
+ method: 'POST',
2194
+ headers: { 'Content-Type': 'application/json' },
2195
+ body: JSON.stringify({ status: finalStatus }),
2196
+ });
2197
+ if (!res.ok) {
2198
+ const err = await res.json().catch(() => ({}));
2199
+ throw new Error(err.error || ('HTTP ' + res.status));
2200
+ }
2201
+ location.reload();
2202
+ } catch (err) {
2203
+ alert('close failed: ' + err.message);
2204
+ }
2205
+ }
2206
+
2207
+ async function closeStaleRuns() {
2208
+ const minStr = prompt('Close every in_progress run older than how many minutes?\\n\\n(Empty = 60. Use 0 to close all.)', '60');
2209
+ if (minStr === null) return;
2210
+ const minutes = minStr.trim() === '' ? 60 : parseInt(minStr, 10);
2211
+ if (!Number.isFinite(minutes) || minutes < 0) { alert('not a number'); return; }
2212
+ try {
2213
+ const res = await fetch('/api/runs/close-stale', {
2214
+ method: 'POST',
2215
+ headers: { 'Content-Type': 'application/json' },
2216
+ body: JSON.stringify({ older_than_minutes: minutes, status: 'ok' }),
2217
+ });
2218
+ if (!res.ok) throw new Error('HTTP ' + res.status);
2219
+ const out = await res.json();
2220
+ alert('closed ' + out.closed + ' run(s)');
2221
+ location.reload();
2222
+ } catch (err) {
2223
+ alert('bulk close failed: ' + err.message);
2224
+ }
2225
+ }
2226
+
2227
+ /* --- Live Probe panel (Turn 8 chunk 5) ---
2228
+ * The panel is server-rendered (via renderProbePanel) and lives at
2229
+ * #probe-panel-<run-id>. These handlers fire the corresponding REST
2230
+ * call then refresh the panel HTML in place. A polling loop also runs
2231
+ * every 1.5s while the panel is in the DOM to pick up SDK-side state
2232
+ * changes (e.g. confirmPaused() happened in the agent process).
2233
+ */
2234
+ async function probePause(runId) {
2235
+ await probeCall(runId, 'pause', 'POST');
2236
+ }
2237
+ async function probeResume(runId) {
2238
+ await probeCall(runId, 'resume', 'POST');
2239
+ }
2240
+ async function probeClear(runId) {
2241
+ if (!confirm('Remove the probe file for ' + runId.slice(0, 12) + '?\\n\\nUse this to recover from a stale paused state after the SDK has exited.')) return;
2242
+ await probeCall(runId, 'clear', 'POST');
2243
+ }
2244
+ async function probeClearInject(runId) {
2245
+ if (!confirm('Discard the pending inject?')) return;
2246
+ // Empty message + force = clear. The route accepts an empty string
2247
+ // only as part of an explicit clear request.
2248
+ await probeCall(runId, 'inject', 'POST', { clear: true });
2249
+ }
2250
+ async function probeOverwriteInject(runId) {
2251
+ // Reset the queued inject to empty so the textarea is shown, then
2252
+ // let the user type the replacement. The discard call refreshes the
2253
+ // panel; the empty-textarea state is now visible.
2254
+ if (!confirm('Replace the pending inject with a new message?')) return;
2255
+ await probeCall(runId, 'inject', 'POST', { clear: true });
2256
+ // After the panel refreshes, focus the textarea.
2257
+ setTimeout(function () {
2258
+ const ta = document.getElementById('probe-inject-' + runId);
2259
+ if (ta) ta.focus();
2260
+ }, 100);
2261
+ }
2262
+ async function probeSendInject(runId) {
2263
+ const ta = document.getElementById('probe-inject-' + runId);
2264
+ if (!ta) return;
2265
+ const msg = (ta.value || '').trim();
2266
+ if (!msg) { alert('inject message is empty'); return; }
2267
+ await probeCall(runId, 'inject', 'POST', { message: msg });
2268
+ }
2269
+ async function probeCall(runId, action, method, body) {
2270
+ try {
2271
+ const opts = { method: method };
2272
+ if (body !== undefined) {
2273
+ opts.headers = { 'Content-Type': 'application/json' };
2274
+ opts.body = JSON.stringify(body);
2275
+ }
2276
+ const res = await fetch('/api/runs/' + encodeURIComponent(runId) + '/probe/' + action, opts);
2277
+ if (!res.ok) {
2278
+ const err = await res.json().catch(() => ({}));
2279
+ throw new Error(err.error || ('HTTP ' + res.status));
2280
+ }
2281
+ await refreshProbePanel(runId);
2282
+ } catch (err) {
2283
+ alert('probe ' + action + ' failed: ' + err.message);
2284
+ }
2285
+ }
2286
+ async function refreshProbePanel(runId) {
2287
+ try {
2288
+ const res = await fetch('/api/runs/' + encodeURIComponent(runId) + '/probe/panel');
2289
+ if (!res.ok) return;
2290
+ const html = await res.text();
2291
+ const existing = document.getElementById('probe-panel-' + runId);
2292
+ if (!existing) return;
2293
+ // Cheap diff: only swap if the markup changed, so the panel
2294
+ // doesn't visually flicker on every poll tick when state is steady.
2295
+ if (existing.outerHTML === html) return;
2296
+ existing.outerHTML = html;
2297
+ } catch (_err) {
2298
+ // Polling failures are silent \u2014 the next tick will retry.
2299
+ }
2300
+ }
2301
+ function startProbePolling() {
2302
+ const panels = document.querySelectorAll('[data-probe-panel]');
2303
+ if (panels.length === 0) return;
2304
+ // One interval per page; the handler iterates whatever panels are
2305
+ // currently in the DOM (panel refresh re-creates the node so we have
2306
+ // to re-query each tick).
2307
+ setInterval(function () {
2308
+ const cur = document.querySelectorAll('[data-probe-panel]');
2309
+ cur.forEach(function (p) { refreshProbePanel(p.dataset.runId); });
2310
+ }, 1500);
2311
+ }
2312
+ if (document.readyState === 'loading') {
2313
+ document.addEventListener('DOMContentLoaded', startProbePolling);
2314
+ } else {
2315
+ startProbePolling();
2316
+ }
2317
+
2318
+ /* --- Test editor --- */
2319
+ let currentTest = null;
2320
+
2321
+ async function selectTest(name) {
2322
+ document.querySelectorAll('.test-list .item').forEach(el =>
2323
+ el.classList.toggle('active', el.dataset.name === name)
2324
+ );
2325
+ const res = await fetch('/api/tests/' + encodeURIComponent(name));
2326
+ if (!res.ok) return;
2327
+ currentTest = await res.json();
2328
+ renderTestDetail(currentTest);
2329
+ loadResults(name);
2330
+ }
2331
+ function renderTestDetail(t) {
2332
+ const root = document.getElementById('test-detail');
2333
+ if (!t) { root.innerHTML = '<div class="empty">Select a test from the left.</div>'; return; }
2334
+ const rows = (t.assertions || []).map((a, i) => assertionRowHtml(a, i)).join('');
2335
+ root.innerHTML =
2336
+ '<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap">' +
2337
+ '<h3 style="margin:0">' + escapeHtml(t.name) + '</h3>' +
2338
+ '<div style="display:flex;gap:6px;flex-wrap:wrap">' +
2339
+ '<button onclick="addAssertionRow()">+ assertion</button>' +
2340
+ ' <button class="primary" onclick="saveAssertions()">Save</button>' +
2341
+ ' <button onclick="runTestAgainstAll()">Run on all runs</button>' +
2342
+ ' <button onclick="deleteCurrentTest()" style="color:var(--err);border-color:var(--err)">Delete test</button>' +
2343
+ '</div>' +
2344
+ '</div>' +
2345
+ (t.description ? '<p style="color:var(--fg-mute);font-size:12.5px">' + escapeHtml(t.description) + '</p>' : '') +
2346
+ '<div id="assertions-area">' + rows + '</div>' +
2347
+ '<div style="margin-top:14px;display:flex;gap:6px;align-items:center;flex-wrap:wrap">' +
2348
+ '<input id="single-run-id" placeholder="run_\u2026 (full or 12-char prefix)"' +
2349
+ ' style="font-family:ui-monospace,Menlo,monospace;min-width:240px">' +
2350
+ ' <button onclick="runTestAgainstOne()">Run on this run</button>' +
2351
+ ' <span id="single-run-status" style="font-size:12px;color:var(--fg-mute);font-family:ui-monospace,Menlo,monospace"></span>' +
2352
+ '</div>' +
2353
+ '<div id="test-results" class="results-list"></div>';
2354
+ }
2355
+ function assertionRowHtml(a, i) {
2356
+ const kinds = ['includes_tool_call','excludes_tool_call','tool_call_count','output_contains','output_does_not_contain','min_steps','max_steps','final_status','max_cost_cents','no_error_step'];
2357
+ const opts = kinds.map(k => '<option value="' + k + '"' + (k === a.kind ? ' selected' : '') + '>' + k + '</option>').join('');
2358
+ return '<div class="assertion-row" data-idx="' + i + '">' +
2359
+ '<select onchange="updateAssertion(' + i + ', \\'kind\\', this.value)">' + opts + '</select>' +
2360
+ '<input value="' + escapeHtml(String(a.value || '')) + '" onchange="updateAssertion(' + i + ', \\'value\\', this.value)">' +
2361
+ '<input placeholder="label" value="' + escapeHtml(a.label || '') + '" onchange="updateAssertion(' + i + ', \\'label\\', this.value)">' +
2362
+ '<button class="rm" onclick="removeAssertion(' + i + ')" title="remove">\xD7</button>' +
2363
+ '</div>';
2364
+ }
2365
+ function updateAssertion(idx, key, val) {
2366
+ if (!currentTest) return;
2367
+ if (key === 'value' && /^[\\d.]+$/.test(val) && currentTest.assertions[idx].kind !== 'final_status') val = Number(val);
2368
+ currentTest.assertions[idx][key] = val;
2369
+ }
2370
+ function addAssertionRow() {
2371
+ if (!currentTest) return;
2372
+ currentTest.assertions = currentTest.assertions || [];
2373
+ currentTest.assertions.push({ kind: 'includes_tool_call', value: '' });
2374
+ renderTestDetail(currentTest);
2375
+ }
2376
+ function removeAssertion(idx) {
2377
+ if (!currentTest) return;
2378
+ currentTest.assertions.splice(idx, 1);
2379
+ renderTestDetail(currentTest);
2380
+ }
2381
+ async function saveAssertions() {
2382
+ if (!currentTest) return;
2383
+ const res = await fetch('/api/tests/' + encodeURIComponent(currentTest.name) + '/assertions', {
2384
+ method: 'PUT',
2385
+ headers: { 'Content-Type': 'application/json' },
2386
+ body: JSON.stringify({ assertions: currentTest.assertions }),
2387
+ });
2388
+ if (res.ok) {
2389
+ currentTest = await res.json();
2390
+ renderTestDetail(currentTest);
2391
+ flash('saved');
2392
+ } else {
2393
+ flash('save failed', true);
2394
+ }
2395
+ }
2396
+ async function deleteCurrentTest() {
2397
+ if (!currentTest) return;
2398
+ if (!confirm('Delete test "' + currentTest.name + '"? This is permanent.')) return;
2399
+ const res = await fetch('/api/tests/' + encodeURIComponent(currentTest.name), {
2400
+ method: 'DELETE',
2401
+ });
2402
+ if (res.ok) {
2403
+ flash('deleted ' + currentTest.name);
2404
+ setTimeout(() => location.reload(), 600);
2405
+ } else {
2406
+ flash('delete failed', true);
2407
+ }
2408
+ }
2409
+ async function runTestAgainstOne() {
2410
+ if (!currentTest) return;
2411
+ const id = (document.getElementById('single-run-id') || {}).value || '';
2412
+ if (!id) { setStatus('single-run-status', 'enter a run id', true); return; }
2413
+ setStatus('single-run-status', 'running\u2026');
2414
+ try {
2415
+ const res = await fetch('/api/tests/' + encodeURIComponent(currentTest.name) + '/run', {
2416
+ method: 'POST',
2417
+ headers: { 'Content-Type': 'application/json' },
2418
+ body: JSON.stringify({ run_id: id }),
2419
+ });
2420
+ const data = await res.json();
2421
+ if (!res.ok) throw new Error(data.error || 'run failed');
2422
+ const r = data[0];
2423
+ if (!r) { setStatus('single-run-status', 'no result returned', true); return; }
2424
+ const passed = r.assertions.filter(a => a.passed).length;
2425
+ setStatus('single-run-status',
2426
+ (r.passed ? '\u2713 PASS' : '\u2717 FAIL') + ' ' + passed + '/' + r.assertions.length + ' ' + r.run_id.slice(0,12),
2427
+ !r.passed);
2428
+ loadResults(currentTest.name);
2429
+ } catch (err) {
2430
+ setStatus('single-run-status', 'error: ' + err.message, true);
2431
+ }
2432
+ }
2433
+ async function runTestAgainstAll() {
2434
+ if (!currentTest) return;
2435
+ flash('running\u2026');
2436
+ const res = await fetch('/api/tests/' + encodeURIComponent(currentTest.name) + '/run', {
2437
+ method: 'POST',
2438
+ headers: { 'Content-Type': 'application/json' },
2439
+ body: JSON.stringify({ limit: 50 }),
2440
+ });
2441
+ const results = await res.json();
2442
+ const pass = results.filter(r => r.passed).length;
2443
+ const fail = results.length - pass;
2444
+ flash(pass + ' pass \xB7 ' + fail + ' fail', fail > 0);
2445
+ loadResults(currentTest.name);
2446
+ }
2447
+ async function loadResults(name) {
2448
+ const res = await fetch('/api/tests/' + encodeURIComponent(name) + '/results');
2449
+ const data = await res.json();
2450
+ const root = document.getElementById('test-results');
2451
+ if (!root) return;
2452
+ if (!data.length) { root.innerHTML = '<p style="color:var(--fg-mute);font-size:12px">No results yet.</p>'; return; }
2453
+ root.innerHTML = '<h4 style="margin:8px 0;font-size:12px;color:var(--fg-mute)">Recent results</h4>' +
2454
+ data.map(r =>
2455
+ '<div class="row ' + (r.passed ? 'pass' : 'fail') + '">' +
2456
+ (r.passed ? 'PASS' : 'FAIL') + ' ' +
2457
+ escapeHtml(r.run_id.slice(0,12)) + ' ' +
2458
+ r.assertions.filter(a => a.passed).length + '/' + r.assertions.length + ' ' +
2459
+ escapeHtml(r.created_at) +
2460
+ '</div>'
2461
+ ).join('');
2462
+ }
2463
+ async function createNewTest() {
2464
+ const name = prompt('New test name:');
2465
+ if (!name) return;
2466
+ const res = await fetch('/api/tests', {
2467
+ method: 'POST',
2468
+ headers: { 'Content-Type': 'application/json' },
2469
+ body: JSON.stringify({ name }),
2470
+ });
2471
+ if (res.ok) location.reload();
2472
+ }
2473
+ function flash(msg, isError) {
2474
+ const el = document.getElementById('flash');
2475
+ if (!el) return;
2476
+ el.textContent = msg;
2477
+ el.style.color = isError ? 'var(--err)' : 'var(--ok)';
2478
+ setTimeout(() => { el.textContent = ''; }, 2000);
2479
+ }
2480
+
2481
+ /* \u2500\u2500\u2500 Settings page \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2482
+ function setStatus(id, msg, isError) {
2483
+ const el = document.getElementById(id);
2484
+ if (!el) return;
2485
+ el.textContent = msg;
2486
+ el.style.color = isError ? 'var(--err)' : 'var(--ok)';
2487
+ }
2488
+ async function saveSetting(key, value, statusId) {
2489
+ setStatus(statusId, 'saving\u2026');
2490
+ try {
2491
+ const res = await fetch('/api/settings', {
2492
+ method: 'POST',
2493
+ headers: { 'Content-Type': 'application/json' },
2494
+ body: JSON.stringify({ key, value }),
2495
+ });
2496
+ if (!res.ok) throw new Error((await res.json()).error || 'save failed');
2497
+ setStatus(statusId, 'saved');
2498
+ } catch (err) {
2499
+ setStatus(statusId, 'error: ' + err.message, true);
2500
+ }
2501
+ }
2502
+ async function saveDefaults() {
2503
+ const tools = (document.getElementById('watch-tools') || {}).value || '';
2504
+ const stall = (document.getElementById('stall-seconds') || {}).value || '120';
2505
+ const model = (document.getElementById('default-model') || {}).value || 'claude-opus-4-7';
2506
+ const iter = (document.getElementById('default-max-iter') || {}).value || '25';
2507
+ await saveSetting('live.watch_tools', tools, 'defaults-status');
2508
+ await saveSetting('live.stall_seconds', stall, 'defaults-status');
2509
+ await saveSetting('fork.default_model', model, 'defaults-status');
2510
+ await saveSetting('fork.default_max_iterations', iter, 'defaults-status');
2511
+ setStatus('defaults-status', 'all defaults saved');
2512
+ }
2513
+ async function runIngest(runtime) {
2514
+ const limit = parseInt((document.getElementById('ingest-limit') || {}).value || '5', 10);
2515
+ setStatus('ingest-status', 'ingesting ' + runtime + '\u2026');
2516
+ try {
2517
+ const res = await fetch('/api/ingest', {
2518
+ method: 'POST',
2519
+ headers: { 'Content-Type': 'application/json' },
2520
+ body: JSON.stringify({ runtime, limit }),
2521
+ });
2522
+ const data = await res.json();
2523
+ if (!res.ok) throw new Error(data.error || 'ingest failed');
2524
+ const summary = runtime === 'cursor'
2525
+ ? data.composers + ' composer(s) \xB7 ' + data.steps + ' step(s)'
2526
+ : data.runs + ' run(s) \xB7 ' + data.steps + ' step(s) \xB7 ' + (data.bytes / 1024).toFixed(1) + 'KB';
2527
+ setStatus('ingest-status', '\u2713 ' + runtime + ': ' + summary);
2528
+ } catch (err) {
2529
+ setStatus('ingest-status', 'error: ' + err.message, true);
2530
+ }
2531
+ }
2532
+ async function runDoctor() {
2533
+ const root = document.getElementById('doctor-results');
2534
+ if (!root) return;
2535
+ root.innerHTML = '<span style="color:var(--fg-mute)">running\u2026</span>';
2536
+ try {
2537
+ const res = await fetch('/api/doctor');
2538
+ const data = await res.json();
2539
+ const colorFor = (s) => s === 'ok' ? 'var(--ok)' : s === 'warn' ? 'var(--warn)' : 'var(--err)';
2540
+ const iconFor = (s) => s === 'ok' ? '\u2714' : s === 'warn' ? '\u26A0' : '\u2716';
2541
+ root.innerHTML = data.checks.map(c =>
2542
+ '<div style="padding:3px 0"><span style="color:' + colorFor(c.status) + ';margin-right:8px">' + iconFor(c.status) + '</span>' +
2543
+ '<strong style="color:var(--fg)">' + escapeHtml(c.name) + '</strong> ' +
2544
+ '<span style="color:var(--fg-mute);margin-left:8px">' + escapeHtml(c.detail) + '</span></div>'
2545
+ ).join('');
2546
+ } catch (err) {
2547
+ root.innerHTML = '<span style="color:var(--err)">' + escapeHtml(err.message) + '</span>';
2548
+ }
2549
+ }
2550
+ async function testSlack() {
2551
+ const webhook = (document.getElementById('slack-webhook') || {}).value;
2552
+ setStatus('slack-status', 'sending\u2026');
2553
+ try {
2554
+ const res = await fetch('/api/slack/test', {
2555
+ method: 'POST',
2556
+ headers: { 'Content-Type': 'application/json' },
2557
+ body: JSON.stringify({ webhook: webhook && !webhook.startsWith('(') ? webhook : undefined }),
2558
+ });
2559
+ const data = await res.json();
2560
+ if (!res.ok) throw new Error(data.error || 'send failed');
2561
+ setStatus('slack-status', '\u2713 test message sent');
2562
+ } catch (err) {
2563
+ setStatus('slack-status', 'error: ' + err.message, true);
2564
+ }
2565
+ }
2566
+ async function postgresInit() {
2567
+ const url = (document.getElementById('pg-url') || {}).value;
2568
+ setStatus('pg-status', 'connecting\u2026');
2569
+ try {
2570
+ const res = await fetch('/api/db/postgres-init', {
2571
+ method: 'POST',
2572
+ headers: { 'Content-Type': 'application/json' },
2573
+ body: JSON.stringify({ url: url && !url.startsWith('(') ? url : undefined }),
2574
+ });
2575
+ const data = await res.json();
2576
+ if (!res.ok) throw new Error(data.error || 'connect failed');
2577
+ setStatus('pg-status', '\u2713 schema_version=' + data.schema_version);
2578
+ } catch (err) {
2579
+ setStatus('pg-status', 'error: ' + err.message, true);
2580
+ }
2581
+ }
2582
+ async function postgresSync() {
2583
+ const url = (document.getElementById('pg-url') || {}).value;
2584
+ setStatus('pg-status', 'syncing\u2026');
2585
+ try {
2586
+ const res = await fetch('/api/db/postgres-sync', {
2587
+ method: 'POST',
2588
+ headers: { 'Content-Type': 'application/json' },
2589
+ body: JSON.stringify({ url: url && !url.startsWith('(') ? url : undefined }),
2590
+ });
2591
+ const data = await res.json();
2592
+ if (!res.ok) throw new Error(data.error || 'sync failed');
2593
+ setStatus('pg-status', '\u2713 ' + data.runs + ' runs \xB7 ' + data.steps + ' steps \xB7 ' + data.blobs + ' blobs (' + (data.bytes/1024).toFixed(1) + 'KB)');
2594
+ } catch (err) {
2595
+ setStatus('pg-status', 'error: ' + err.message, true);
2596
+ }
2597
+ }
2598
+ `;
2599
+ function renderShell(title, body, opts = {}) {
2600
+ const liveToggle = `<button id="live-toggle" class="live-toggle"
2601
+ data-live="${opts.liveMode ? "1" : "0"}"
2602
+ title="${opts.liveMode ? "Click to stop live capture" : "Click to start watching ~/.claude/projects for live agent activity"}"
2603
+ onclick="toggleLive(event)"><span class="dot dot--${opts.liveMode ? "success" : "muted"}"></span><span class="live-toggle-label">${opts.liveMode ? "LIVE" : "GO LIVE"}</span></button>`;
2604
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
2605
+ <title>${esc(title)} \xB7 Meterbility</title>
2606
+ <meta name="meter-live-mode" content="${opts.liveMode ? "1" : "0"}">
2607
+ <link rel="preconnect" href="https://fonts.googleapis.com">
2608
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
2609
+ <link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
2610
+ <style>${STYLES}</style>
2611
+ </head><body>
2612
+ <header>
2613
+ <a class="brand" href="/">
2614
+ <span class="brand-mark"></span>
2615
+ <span class="brand-name">Meterbility</span>
2616
+ </a>
2617
+ <nav class="topnav">
2618
+ <a href="/">Fleet</a>
2619
+ <a href="/runs">Runs</a>
2620
+ <a href="/tests">Tests</a>
2621
+ <a href="/settings">Settings</a>
2622
+ </nav>
2623
+ ${liveToggle}
2624
+ <span class="crumbs">${esc(title)}</span>
2625
+ <span id="flash"></span>
2626
+ </header>
2627
+ <main>${body}</main>
2628
+
2629
+ <!-- Fork modal -->
2630
+ <div id="fork-modal" class="modal-bg" onclick="if(event.target===this)closeModal('fork-modal')">
2631
+ <div class="modal">
2632
+ <h3>Fork from this step</h3>
2633
+ <input type="hidden" id="fork-run-id">
2634
+ <input type="hidden" id="fork-seq">
2635
+ <label>Edit type</label>
2636
+ <select id="fork-edit-type">
2637
+ <option value="replace_user_message">replace_user_message</option>
2638
+ <option value="inject_message">inject_message</option>
2639
+ <option value="replace_system_prompt">replace_system_prompt</option>
2640
+ <option value="modify_tool_description">modify_tool_description</option>
2641
+ <option value="add_context">add_context</option>
2642
+ <option value="remove_tool">remove_tool</option>
2643
+ <option value="change_model">change_model</option>
2644
+ </select>
2645
+ <label>Payload (becomes <code>{ text: ... }</code>)</label>
2646
+ <textarea id="fork-payload" placeholder="The new message text\u2026"></textarea>
2647
+ <label>Fake suffix response (optional \u2014 leave blank for live mode)</label>
2648
+ <input id="fork-fake" placeholder="Acknowledged.">
2649
+ <label style="display:flex;gap:8px;align-items:center;margin-top:6px">
2650
+ <input type="checkbox" id="fork-live" style="width:auto"> Use live Anthropic call (requires ANTHROPIC_API_KEY)
2651
+ </label>
2652
+
2653
+ <hr style="border:0;border-top:1px solid var(--border);margin:16px 0">
2654
+ <label>Continuation (multi-step replay)</label>
2655
+ <select id="fork-continue">
2656
+ <option value="none">None \u2014 one suffix step only</option>
2657
+ <option value="simulate">Simulate \u2014 replay model live, use original tool results</option>
2658
+ <option value="live">Live \u2014 replay model + execute tools (Bash safe-list)</option>
2659
+ </select>
2660
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px">
2661
+ <label style="font-size:12px;color:var(--fg-mute)">Max iterations
2662
+ <input id="fork-max-iter" type="number" min="1" max="100" value="25" style="margin-top:4px">
2663
+ </label>
2664
+ <label style="font-size:12px;color:var(--fg-mute)">Live model (continuation)
2665
+ <input id="fork-model" value="claude-opus-4-7" style="margin-top:4px;font-family:ui-monospace,Menlo,monospace">
2666
+ </label>
2667
+ </div>
2668
+ <label style="margin-top:8px">Allowed tools for live continuation (comma-separated)</label>
2669
+ <input id="fork-allow-tools" placeholder="Bash" style="font-family:ui-monospace,Menlo,monospace">
2670
+
2671
+ <div class="actions">
2672
+ <button onclick="closeModal('fork-modal')">Cancel</button>
2673
+ <button class="primary" onclick="submitFork()">Fork</button>
2674
+ </div>
2675
+ <p id="fork-status" style="font-size:12px;color:var(--fg-mute);margin-top:10px"></p>
2676
+ </div>
2677
+ </div>
2678
+
2679
+ <!-- Annotate modal -->
2680
+ <div id="annotate-modal" class="modal-bg" onclick="if(event.target===this)closeModal('annotate-modal')">
2681
+ <div class="modal">
2682
+ <h3>Annotate</h3>
2683
+ <input type="hidden" id="ann-kind">
2684
+ <input type="hidden" id="ann-id">
2685
+ <label>Verdict</label>
2686
+ <select id="ann-verdict">
2687
+ <option value="">(none)</option>
2688
+ <option value="correct">correct</option>
2689
+ <option value="incorrect">incorrect</option>
2690
+ <option value="unclear">unclear</option>
2691
+ <option value="good_decision">good_decision</option>
2692
+ <option value="bad_decision">bad_decision</option>
2693
+ </select>
2694
+ <label>Note</label>
2695
+ <textarea id="ann-note" placeholder="What surprised you about this step?"></textarea>
2696
+ <div class="actions">
2697
+ <button onclick="closeModal('annotate-modal')">Cancel</button>
2698
+ <button class="primary" onclick="submitAnnotation()">Save</button>
2699
+ </div>
2700
+ <p id="ann-status" style="font-size:12px;color:var(--fg-mute);margin-top:10px"></p>
2701
+ </div>
2702
+ </div>
2703
+
2704
+ <!-- Keyboard help -->
2705
+ <div id="kbd-help" class="kbd-help">
2706
+ <div style="font-weight:600;margin-bottom:6px">Keyboard shortcuts</div>
2707
+ <div><kbd>j</kbd> / <kbd>\u2193</kbd> next step</div>
2708
+ <div><kbd>k</kbd> / <kbd>\u2191</kbd> previous step</div>
2709
+ <div><kbd>g</kbd> first step \xB7 <kbd>G</kbd> last step</div>
2710
+ <div><kbd>/</kbd> focus text filter</div>
2711
+ <div><kbd>?</kbd> toggle this help</div>
2712
+ </div>
2713
+ <button class="kbd-help-toggle" title="keyboard shortcuts (?)" onclick="document.getElementById('kbd-help').classList.toggle('open')">?</button>
2714
+
2715
+ <script>${SCRIPT}</script>
2716
+ </body></html>`;
2717
+ }
2718
+ function renderFleet(entries, opts = {}) {
2719
+ const initial = entries.map((e) => fleetEntryHtml(e)).join("");
2720
+ const banner = opts.liveMode ? "" : `<div class="static-banner">
2721
+ <strong>Static snapshot.</strong> Status, context %, and recent tools are computed from captured data \u2014 no auto-updates.
2722
+ For live monitoring + alerts (Slack, loop / stall / context-threshold), restart with <code>meter web --live</code>.
2723
+ </div>`;
2724
+ const subtitle = opts.liveMode ? "live \xB7 auto-updating via SSE" : "static snapshot of last 50 runs \xB7 click any card to drill in";
2725
+ const empty = opts.liveMode ? `<div class="empty">No active runs yet. Open a Claude Code session and Meterbility will pick it up within a couple of seconds.</div>` : `<div class="empty">No runs captured. Run <code>meter ingest claude-code --limit 5</code>.</div>`;
2726
+ return `<div id="alert-banner" style="margin-bottom:var(--space-3)"></div>
2727
+ <div style="display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:var(--space-5)">
2728
+ <div>
2729
+ <div class="section-label">${opts.liveMode ? "Live \xB7 SSE" : "Snapshot"}</div>
2730
+ <h2 style="margin:0">Fleet</h2>
2731
+ <div style="font-size:12.5px;color:var(--text-tertiary);margin-top:var(--space-1)">${esc(subtitle)}</div>
2732
+ </div>
2733
+ <div class="meta-row" style="margin:0">
2734
+ <div class="kv"><strong>Runs</strong> <span class="val">${entries.length}</span></div>
2735
+ <div class="kv"><a href="/runs">All runs \u2192</a></div>
2736
+ </div>
2737
+ </div>
2738
+ ${banner}
2739
+ <div id="fleet-grid" class="fleet">${initial || empty}</div>
2740
+ ${COST_FOOTNOTE_HTML}`;
2741
+ }
2742
+ function fleetEntryHtml(e) {
2743
+ const r = e.run;
2744
+ const tools = (e.recent_tools ?? []).map((t) => `<code>${esc(t)}</code>`).join("");
2745
+ const alerts = (e.alerts ?? []).map(
2746
+ (a) => `<div class="alert-strip ${a.kind === "stall" ? "warn" : ""}">${esc(a.message)}</div>`
2747
+ ).join("");
2748
+ const barClass = e.context_pct >= 90 ? "ctx-bar danger" : e.context_pct >= 70 ? "ctx-bar warn" : "ctx-bar";
2749
+ return `<div class="card" data-run="${esc(r.run_id)}">
2750
+ <div class="title-row">
2751
+ <a class="title" href="/runs/${esc(r.run_id)}">${esc(r.title ?? r.run_id)}</a>
2752
+ <span class="pill live-${esc(e.status)}">${esc(e.status)}</span>
2753
+ </div>
2754
+ <div class="meta">
2755
+ <span class="kv">${r.step_count} steps</span>
2756
+ <span class="kv">${costEl(r.cost_cents)}</span>
2757
+ <span class="kv">${esc(r.git_branch ?? "")}</span>
2758
+ <span class="age" data-age="${esc(e.last_step_at ?? "")}"></span>
2759
+ </div>
2760
+ <div class="${barClass}" title="context util ${e.context_pct}%"><div class="fill" style="width:${e.context_pct}%"></div></div>
2761
+ <div class="recent-tools"><span class="recent-tools-label">Recent Tools used</span>${tools || '<span style="opacity:0.5">no tools yet</span>'}</div>
2762
+ ${alerts}
2763
+ </div>`;
2764
+ }
2765
+ function renderRunList(runs, opts = {}) {
2766
+ const filters = opts.filters ?? {};
2767
+ const total = opts.totalAvailable ?? runs.length;
2768
+ const isFiltered = !!filters.status || !!filters.tool || !!filters.project;
2769
+ const hasInProgress = runs.some((r) => r.status === "in_progress");
2770
+ const bulkSeal = hasInProgress ? `<button type="button" class="tertiary"
2771
+ style="margin-left:8px;font-size:11.5px;font-family:ui-monospace,Menlo,monospace"
2772
+ title="Seal every in_progress run older than N minutes"
2773
+ onclick="closeStaleRuns()">Seal stale\u2026</button>` : "";
2774
+ const filterBar = `<form id="runs-filter" method="get" action="/runs"
2775
+ style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px">
2776
+ <select name="status" onchange="this.form.submit()" style="font-family:ui-monospace,Menlo,monospace">
2777
+ <option value="">All statuses</option>
2778
+ ${["ok", "error", "in_progress", "abandoned"].map(
2779
+ (s) => `<option value="${s}"${filters.status === s ? " selected" : ""}>${s}</option>`
2780
+ ).join("")}
2781
+ </select>
2782
+ <input name="tool" value="${esc(filters.tool ?? "")}"
2783
+ placeholder="filter by tool name (e.g. Bash)"
2784
+ style="font-family:ui-monospace,Menlo,monospace;min-width:200px">
2785
+ <input name="project" value="${esc(filters.project ?? "")}"
2786
+ placeholder="filter by project path substring"
2787
+ style="font-family:ui-monospace,Menlo,monospace;min-width:200px">
2788
+ <button type="submit" class="primary">Apply</button>
2789
+ ${isFiltered ? `<a href="/runs" style="font-size:12px">clear</a>` : ""}
2790
+ <span style="margin-left:auto;display:inline-flex;align-items:center;font-size:12px;color:var(--fg-mute);font-family:ui-monospace,Menlo,monospace">
2791
+ ${runs.length} of ${total} run(s)${bulkSeal}
2792
+ </span>
2793
+ </form>`;
2794
+ const empty = isFiltered ? `<div class="empty">No runs match the current filter. <a href="/runs">Clear filter</a>.</div>` : `<div class="empty">No runs captured yet. Run <code>meter ingest claude-code</code> to import sessions, or open the <a href="/settings">Settings page</a>.</div>`;
2795
+ const rows = runs.map((r) => {
2796
+ const status = `<span class="pill ${esc(r.status)}">${esc(r.status)}</span>`;
2797
+ const fork = r.fork_origin_run_id ? ` <span class="badge badge--premium">fork</span>` : "";
2798
+ const cost = costEl(r.cost_cents);
2799
+ const title = r.title ?? r.run_id;
2800
+ const project = projectLabel(r.cwd);
2801
+ const projectFull = r.cwd ?? "";
2802
+ const actions = r.status === "in_progress" ? `<button class="row-seal" type="button"
2803
+ title="Seal this run as ok (mostly for proxy-captured runs)"
2804
+ onclick="closeRun('${esc(r.run_id)}', 'ok')">Seal</button>` : "";
2805
+ return `<tr>
2806
+ <td>
2807
+ <a href="/runs/${esc(r.run_id)}">${esc(title)}</a>${fork}
2808
+ </td>
2809
+ <td>${status}</td>
2810
+ <td class="mono">${esc(r.run_id.slice(0, 12))}</td>
2811
+ <td class="numeric">${r.step_count}</td>
2812
+ <td class="numeric">${cost}</td>
2813
+ <td class="mono">${esc(r.started_at)}</td>
2814
+ <td class="mono">${esc(r.git_branch ?? "")}</td>
2815
+ <td class="mono" title="${esc(projectFull)}">${esc(project)}</td>
2816
+ <td>${actions}</td>
2817
+ </tr>`;
2818
+ }).join("");
2819
+ return `<div style="margin-bottom:var(--space-5)">
2820
+ <div class="section-label">All runs</div>
2821
+ <h2 style="margin:0">${total} captured</h2>
2822
+ </div>
2823
+ ${filterBar}
2824
+ ${runs.length === 0 ? empty : `<div class="table-scroll">
2825
+ <table class="runs-table">
2826
+ <thead><tr>
2827
+ <th>Title</th>
2828
+ <th>Status</th>
2829
+ <th>Run</th>
2830
+ <th>Steps</th>
2831
+ <th>Cost</th>
2832
+ <th>Started</th>
2833
+ <th>Branch</th>
2834
+ <th>Project</th>
2835
+ <th></th>
2836
+ </tr></thead>
2837
+ <tbody>${rows}</tbody>
2838
+ </table>
2839
+ </div>`}
2840
+ ${COST_FOOTNOTE_HTML}`;
2841
+ }
2842
+ function projectLabel(cwd) {
2843
+ if (!cwd) return "\u2014";
2844
+ const trimmed = cwd.replace(/\/+$/, "");
2845
+ if (trimmed === "(unknown)") return "\u2014";
2846
+ if (trimmed.startsWith("(") && trimmed.endsWith(")")) {
2847
+ return trimmed.slice(1, -1);
2848
+ }
2849
+ const parts = trimmed.split("/").filter(Boolean);
2850
+ if (parts.length === 0) return trimmed;
2851
+ if (parts.length === 1) return parts[0];
2852
+ return parts.slice(-2).join("/");
2853
+ }
2854
+ function renderRun(run, steps, annotations, forks, stepDecisions, fileChangesByStep = /* @__PURE__ */ new Map(), probePanelHtml = "") {
2855
+ const meta = `<div class="meta-row">
2856
+ <div class="kv"><strong>Status</strong> <span class="pill ${esc(run.status)}">${esc(run.status)}</span></div>
2857
+ <div class="kv"><strong>Steps</strong> <span class="val">${run.step_count}</span></div>
2858
+ <div class="kv"><strong>Cost</strong> <span class="val">${costEl(run.cost_cents)}</span></div>
2859
+ <div class="kv"><strong>Input</strong> <span class="val">${run.tokens_total_input.toLocaleString()}</span></div>
2860
+ <div class="kv"><strong>Output</strong> <span class="val">${run.tokens_total_output.toLocaleString()}</span></div>
2861
+ <div class="kv"><strong>Cached</strong> <span class="val">${run.tokens_total_cached.toLocaleString()}</span></div>
2862
+ <div class="kv"><strong>Branch</strong> <span class="val">${esc(run.git_branch ?? "\u2014")}</span></div>
2863
+ ${run.cwd ? `<div class="kv"><strong>Project</strong> <span class="val mono" title="${esc(run.cwd)}">${esc(projectLabel(run.cwd))}</span>
2864
+ <button class="copy-btn" title="copy full project path" onclick="copyText('${esc(run.cwd)}', this)">copy</button>
2865
+ </div>` : ""}
2866
+ <div class="kv"><strong>Run ID</strong> <span class="val mono">${esc(run.run_id.slice(0, 16))}\u2026</span>
2867
+ <button class="copy-btn" title="copy full run id" onclick="copyText('${esc(run.run_id)}', this)">copy</button>
2868
+ </div>
2869
+ <div class="kv"><strong>Export</strong>
2870
+ <a class="val mono" href="/api/runs/${esc(run.run_id)}/export" download="${esc(run.run_id)}.meter.json"
2871
+ title="Download as Meterbility Trace Format v0.2 JSON (with inlined blobs)">trace.json</a>
2872
+ <a class="copy-btn" href="/api/runs/${esc(run.run_id)}/export?blobs=0" download="${esc(run.run_id)}.thin.json"
2873
+ title="Trace without inlined blobs (smaller)">thin</a>
2874
+ </div>
2875
+ ${run.fork_origin_run_id ? `<div class="kv"><strong>Forked from</strong> <a href="/runs/${esc(run.fork_origin_run_id)}">${esc(run.fork_origin_run_id.slice(0, 12))}</a></div>` : ""}
2876
+ </div>`;
2877
+ const timeline = `<div class="timeline">${steps.map((s) => {
2878
+ const label = s.action.kind === "tool_call" ? esc(s.action.tool_name ?? "tool") : s.action.kind === "message" ? "msg" : s.action.kind === "thinking_only" ? "\u2022" : esc(s.action.kind);
2879
+ return `<div class="blk ${esc(s.status)}" data-seq="${s.sequence}" title="step ${s.sequence}: ${esc(s.action.kind)}${s.action.tool_name ? " " + esc(s.action.tool_name) : ""}" onclick="jumpToStep(${s.sequence})">${s.sequence}. ${label}</div>`;
2880
+ }).join("")}</div>`;
2881
+ const errorCount = steps.filter((s) => s.status === "error").length;
2882
+ const toolCount = steps.filter((s) => s.action.kind === "tool_call").length;
2883
+ const msgCount = steps.filter((s) => s.action.kind === "message").length;
2884
+ const filterBar = `<div class="filter-bar">
2885
+ <span class="filter-chip active" data-kind="all" onclick="applyFilter('all')">All \xB7 ${steps.length}</span>
2886
+ <span class="filter-chip" data-kind="tools" onclick="applyFilter('tools')">Tool calls \xB7 ${toolCount}</span>
2887
+ <span class="filter-chip" data-kind="messages" onclick="applyFilter('messages')">Messages \xB7 ${msgCount}</span>
2888
+ <span class="filter-chip" data-kind="errors" onclick="applyFilter('errors')">Errors \xB7 ${errorCount}</span>
2889
+ <input class="filter-input" id="step-filter" type="text"
2890
+ placeholder="filter by text (press / to focus)"
2891
+ oninput="applyTextFilter(this.value)">
2892
+ <span style="margin-left:auto;font-size:11px;color:var(--fg-mute)">
2893
+ <kbd>j</kbd>/<kbd>k</kbd> next/prev \xB7 <kbd>g</kbd>/<kbd>G</kbd> top/bottom \xB7 <kbd>?</kbd> help
2894
+ </span>
2895
+ </div>`;
2896
+ const runAnnotations = `<div class="step-card">
2897
+ <div class="section-label">Annotations</div>
2898
+ <h3 style="display:flex;align-items:center;gap:8px">
2899
+ <span>Run notes</span>
2900
+ <span class="row-actions" style="margin-left:auto">
2901
+ <button onclick="openAnnotateModal('run', '${esc(run.run_id)}')">+ Annotate</button>
2902
+ </span>
2903
+ </h3>
2904
+ ${annotations.length ? annotations.map(
2905
+ (a) => `<div class="annotation"><strong>${esc(a.author)}</strong> \xB7 <em>${esc(a.verdict ?? "note")}</em> \xB7 ${esc(a.note ?? "")}</div>`
2906
+ ).join("") : '<p style="color:var(--text-tertiary);font-size:12.5px;margin:0">No annotations yet.</p>'}
2907
+ </div>`;
2908
+ const forksBlock = forks.length ? `<div class="step-card">
2909
+ <div class="section-label">Forks</div>
2910
+ <h3>Derived runs</h3>
2911
+ ${forks.map(
2912
+ (f) => `<div class="annotation"><span class="badge badge--premium">${esc(f.edit_type)}</span> from step <code>${esc(f.origin_step_id.slice(0, 12))}</code> \u2192 <a href="/runs/${esc(f.fork_run_id)}">${esc(f.fork_run_id.slice(0, 12))}</a> \xB7 <a href="/diff?a=${esc(run.run_id)}&b=${esc(f.fork_run_id)}">diff</a></div>`
2913
+ ).join("")}
2914
+ </div>` : "";
2915
+ const stepCards = steps.map(
2916
+ (s) => renderStepCard(
2917
+ s,
2918
+ stepDecisions.get(s.step_id) ?? "",
2919
+ fileChangesByStep.get(s.step_id) ?? []
2920
+ )
2921
+ ).join("");
2922
+ const allFileChanges = [];
2923
+ for (const list of fileChangesByStep.values()) {
2924
+ for (const fc of list) allFileChanges.push(fc);
2925
+ }
2926
+ const filesSection = renderRunFilesSummary(allFileChanges, steps);
2927
+ const runtimeLabel = run.source_runtime === "fork" ? "Fork" : run.source_runtime;
2928
+ const closeButton = run.status === "in_progress" ? `<div class="seal-control" style="margin-left:auto"
2929
+ title="Seal this run \u2014 sets ended_at + final status. Useful for proxy-captured runs that have no upstream end signal.">
2930
+ <select class="seal-status-select"
2931
+ id="close-status-${esc(run.run_id)}"
2932
+ data-status="ok"
2933
+ onchange="this.dataset.status=this.value"
2934
+ aria-label="final status">
2935
+ <option value="ok">ok</option>
2936
+ <option value="error">error</option>
2937
+ <option value="abandoned">abandoned</option>
2938
+ </select>
2939
+ <button class="seal-action" type="button"
2940
+ onclick="closeRun('${esc(run.run_id)}', document.getElementById('close-status-${esc(run.run_id)}').value)">
2941
+ <svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden="true">
2942
+ <path d="M2.5 6.5l2.5 2.5 4.5-5.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
2943
+ </svg>
2944
+ Seal run
2945
+ </button>
2946
+ </div>` : "";
2947
+ const liveStamp = `<script>
2948
+ (function () {
2949
+ var m = document.querySelector('main');
2950
+ if (!m) return;
2951
+ m.dataset.runId = ${JSON.stringify(run.run_id)};
2952
+ m.dataset.stepCount = ${JSON.stringify(String(steps.length))};
2953
+ })();
2954
+ </script>`;
2955
+ return `<div style="margin-bottom:var(--space-6)">
2956
+ <div class="section-label">${esc(runtimeLabel)} \xB7 Run</div>
2957
+ <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
2958
+ <h2 style="margin:0">${esc(run.title ?? run.run_id)}</h2>
2959
+ ${closeButton}
2960
+ </div>
2961
+ </div>
2962
+ ${meta}
2963
+ ${timeline}
2964
+ ${filesSection}
2965
+ ${probePanelHtml}
2966
+ ${filterBar}
2967
+ ${runAnnotations}
2968
+ ${forksBlock}
2969
+ <div id="steps-anchor">${stepCards.length ? stepCards : `<div class="empty">No steps in this run.</div>`}</div>
2970
+ ${COST_FOOTNOTE_HTML}
2971
+ ${liveStamp}`;
2972
+ }
2973
+ function renderProbePanel(runId, r) {
2974
+ const stateClass = r.state === "running" ? "ok" : r.state === "pause_requested" ? "in_progress" : "error";
2975
+ const stateLabel = r.state === "running" ? "running" : r.state === "pause_requested" ? "pause requested" : "paused";
2976
+ const fmt = (ms) => ms === null ? "\u2014" : new Date(ms).toISOString().slice(11, 19);
2977
+ const injectQueued = r.inject !== null && r.inject !== void 0;
2978
+ const injectBlock = injectQueued ? `<div class="probe-inject-queued">
2979
+ <div class="section-label">Pending inject</div>
2980
+ <pre class="probe-inject-preview">${esc(r.inject ?? "")}</pre>
2981
+ <div class="probe-inject-actions">
2982
+ <button type="button" class="seal-action"
2983
+ onclick="probeOverwriteInject('${esc(runId)}')">Replace\u2026</button>
2984
+ <button type="button" class="copy-btn"
2985
+ onclick="probeClearInject('${esc(runId)}')">Discard</button>
2986
+ </div>
2987
+ </div>` : `<div class="probe-inject-empty">
2988
+ <label for="probe-inject-${esc(runId)}" class="section-label">Inject a message (visible to model on next call)</label>
2989
+ <textarea id="probe-inject-${esc(runId)}" class="probe-inject-textarea"
2990
+ rows="2" placeholder="e.g. the failing test is a stale fixture \u2014 reset with npm run reset:fixtures"></textarea>
2991
+ <button type="button" class="seal-action"
2992
+ onclick="probeSendInject('${esc(runId)}')">Queue inject</button>
2993
+ </div>`;
2994
+ const actionButtons = `<div class="probe-actions">
2995
+ ${r.state === "running" ? `<button type="button" class="seal-action"
2996
+ onclick="probePause('${esc(runId)}')">Pause</button>` : `<button type="button" class="seal-action probe-resume-btn"
2997
+ onclick="probeResume('${esc(runId)}')">Resume</button>`}
2998
+ <button type="button" class="copy-btn"
2999
+ onclick="probeClear('${esc(runId)}')"
3000
+ title="Remove the probe file. Use this to recover from a stale paused state after the SDK has exited.">Clear</button>
3001
+ </div>`;
3002
+ return `<div class="step-card" id="probe-panel-${esc(runId)}" data-probe-panel="1" data-run-id="${esc(runId)}">
3003
+ <div class="section-label">Live Probe</div>
3004
+ <h3 style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
3005
+ <span>Pause \xB7 Inject \xB7 Resume</span>
3006
+ <span class="pill ${stateClass}" data-probe-state>${stateLabel}</span>
3007
+ <span class="row-actions" style="margin-left:auto;font-size:11px;color:var(--text-tertiary)">
3008
+ SDK must run with <code>probeEnabled: true</code>
3009
+ </span>
3010
+ </h3>
3011
+ <div class="probe-timestamps">
3012
+ <span><strong>requested</strong> <code>${fmt(r.requested_at_ms)}</code></span>
3013
+ <span><strong>paused</strong> <code>${fmt(r.paused_at_ms)}</code></span>
3014
+ <span><strong>resumed</strong> <code>${fmt(r.resumed_at_ms)}</code></span>
3015
+ </div>
3016
+ ${injectBlock}
3017
+ ${actionButtons}
3018
+ </div>`;
3019
+ }
3020
+ function renderStepCardFragment(s, decision, fileChanges = []) {
3021
+ const card = renderStepCard(s, decision, fileChanges);
3022
+ const tlColor = s.action.kind === "tool_call" ? esc(s.action.tool_name ?? "tool") : s.action.kind === "message" ? "msg" : s.action.kind === "thinking_only" ? "\u2022" : esc(s.action.kind);
3023
+ const timelineBlk = `<div class="blk ${esc(s.status)}" data-seq="${s.sequence}" data-timeline-blk="1" title="step ${s.sequence}: ${esc(s.action.kind)}${s.action.tool_name ? " " + esc(s.action.tool_name) : ""}" onclick="jumpToStep(${s.sequence})">${s.sequence}. ${tlColor}</div>`;
3024
+ return `<div data-step-fragment="1">${card}${timelineBlk}</div>`;
3025
+ }
3026
+ function renderStepCard(s, decision, fileChanges = []) {
3027
+ const status = `<span class="pill ${esc(s.status)}">${esc(s.status)}</span>`;
3028
+ const defaultText = s.action.kind === "message" ? (s.action.text ?? "").slice(0, 200) : "";
3029
+ const stepHeader = `<h3 style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
3030
+ <a href="#step-${s.sequence}" onclick="jumpToStep(${s.sequence}); event.preventDefault();"
3031
+ style="color:var(--fg);text-decoration:none">#${s.sequence}</a>
3032
+ <span>\xB7 ${esc(s.action.kind)}${s.action.tool_name ? ` \xB7 <code>${esc(s.action.tool_name)}</code>` : ""}</span>
3033
+ ${status}
3034
+ <span class="pill">${esc(s.model)}</span>
3035
+ <button class="copy-btn" title="copy step id" onclick="copyText('${esc(s.step_id)}', this)">${esc(s.step_id.slice(0, 12))}</button>
3036
+ <span class="row-actions" style="margin-left:auto">
3037
+ <button onclick="openForkModal('${esc(s.run_id)}', ${s.sequence}, ${esc(JSON.stringify(defaultText))})">Fork from here</button>
3038
+ <button onclick="openAnnotateModal('step', '${esc(s.step_id)}')">Annotate</button>
3039
+ <button class="pretty-toggle" data-step-id="${esc(s.step_id)}" data-run-id="${esc(s.run_id)}" aria-pressed="false" onclick="togglePretty(this)">Pretty (all tabs)</button>
3040
+ </span>
3041
+ </h3>`;
3042
+ const hasFiles = fileChanges.length > 0;
3043
+ const filesTabBtn = hasFiles ? `<button class="tab-btn" data-tab="files" onclick="showTab('${esc(s.step_id)}','files')">Files <span class="tab-count">${fileChanges.length}</span></button>` : "";
3044
+ const tabBar = `<div class="tab-bar">
3045
+ <button class="tab-btn active" data-tab="decision" onclick="showTab('${esc(s.step_id)}','decision')">Decision</button>
3046
+ <button class="tab-btn" data-tab="action" onclick="showTab('${esc(s.step_id)}','action')">Action</button>
3047
+ <button class="tab-btn" data-tab="outcome" onclick="showTab('${esc(s.step_id)}','outcome')">Outcome</button>
3048
+ <button class="tab-btn" data-tab="cost" onclick="showTab('${esc(s.step_id)}','cost')">Cost</button>
3049
+ <button class="tab-btn" data-tab="context" onclick="showTab('${esc(s.step_id)}','context')">Context</button>
3050
+ ${filesTabBtn}
3051
+ </div>`;
3052
+ const prettyOpts = { mode: "html" };
3053
+ const prettyDecision = prettyTab("decision", decision, {
3054
+ ...prettyOpts,
3055
+ truncated: decision.length >= DECISION_PREVIEW_LIMIT
3056
+ });
3057
+ const prettyAction = prettyTab("action", s.action, prettyOpts);
3058
+ const prettyOutcome = prettyTab("outcome", s.outcome, {
3059
+ ...prettyOpts,
3060
+ rawBlobHref: s.outcome.tool_result_ref ? `/api/blob/${s.outcome.tool_result_ref}` : void 0
3061
+ });
3062
+ const prettyCost = prettyTab(
3063
+ "cost",
3064
+ {
3065
+ tokens: s.tokens,
3066
+ latency_ms: s.latency_ms,
3067
+ cost_cents: s.cost_cents,
3068
+ tags: s.tags
3069
+ },
3070
+ prettyOpts
3071
+ );
3072
+ const decisionTab = `<div class="tab tab-decision"><pre class="body raw">${esc(reformatJsonString(decision))}</pre><pre class="body pretty" style="display:none">${prettyDecision}</pre></div>`;
3073
+ const actionTab = `<div class="tab tab-action" style="display:none"><pre class="body raw">${esc(JSON.stringify(s.action, null, 2))}</pre><pre class="body pretty" style="display:none">${prettyAction}</pre></div>`;
3074
+ const outcomeTab = `<div class="tab tab-outcome" style="display:none"><pre class="body raw">${esc(JSON.stringify(s.outcome, null, 2))}</pre>${s.outcome.tool_result_ref ? `<p class="raw"><a href="/api/blob/${esc(s.outcome.tool_result_ref)}" target="_blank">view tool result (${esc(s.outcome.tool_result_ref.slice(0, 12))})</a></p>` : ""}<pre class="body pretty" style="display:none">${prettyOutcome}</pre></div>`;
3075
+ const costTab = `<div class="tab tab-cost" style="display:none"><pre class="body raw">${esc(
3076
+ JSON.stringify(
3077
+ {
3078
+ model: s.model,
3079
+ tokens: s.tokens,
3080
+ latency_ms: s.latency_ms,
3081
+ cost_cents: s.cost_cents,
3082
+ tags: s.tags
3083
+ },
3084
+ null,
3085
+ 2
3086
+ )
3087
+ )}</pre><pre class="body pretty" style="display:none">${prettyCost}</pre></div>`;
3088
+ const contextTab = `<div class="tab tab-context" style="display:none">
3089
+ <p>
3090
+ <a href="/contexts/${esc(s.context_snapshot_id)}?run=${esc(s.run_id)}&step=${esc(s.step_id)}&seq=${s.sequence}" target="_blank">
3091
+ view full context
3092
+ </a>
3093
+ &nbsp;\xB7&nbsp;
3094
+ <a href="/api/blob/${esc(s.context_snapshot_id)}" target="_blank" style="color:var(--text-tertiary);font-size:11px">
3095
+ raw manifest (${esc(s.context_snapshot_id.slice(0, 12))})
3096
+ </a>
3097
+ </p>
3098
+ </div>`;
3099
+ const filesTab = hasFiles ? `<div class="tab tab-files" style="display:none">${renderStepFilesPanel(fileChanges)}</div>` : "";
3100
+ return `<div class="step-card" id="step-${s.sequence}" data-step="${esc(s.step_id)}" data-step-seq="${s.sequence}" data-action-kind="${esc(s.action.kind)}" data-step-status="${esc(s.status)}">${stepHeader}${tabBar}${decisionTab}${actionTab}${outcomeTab}${costTab}${contextTab}${filesTab}</div>`;
3101
+ }
3102
+ function renderStepFilesPanel(fcs) {
3103
+ const stats = fcs.reduce(
3104
+ (acc, fc) => ({
3105
+ added: acc.added + fc.lines_added,
3106
+ removed: acc.removed + fc.lines_removed
3107
+ }),
3108
+ { added: 0, removed: 0 }
3109
+ );
3110
+ const header = `<div class="files-summary">
3111
+ <span class="files-stat-add">+${stats.added}</span>
3112
+ <span class="files-stat-rm">\u2212${stats.removed}</span>
3113
+ <span class="files-stat-count">${fcs.length} file${fcs.length === 1 ? "" : "s"}</span>
3114
+ </div>`;
3115
+ const rows = fcs.map((fc, idx) => {
3116
+ const renderedPath = fc.op === "rename" && fc.old_path ? `${esc(fc.old_path)} \u2192 ${esc(fc.path)}` : esc(fc.path);
3117
+ const flags = [];
3118
+ if (fc.partial_diff) flags.push(`<span class="file-flag flag-partial">partial</span>`);
3119
+ if (fc.patch_format === "binary") flags.push(`<span class="file-flag flag-binary">binary</span>`);
3120
+ if (fc.redacted) flags.push(`<span class="file-flag flag-redacted">redacted</span>`);
3121
+ const expandable = !fc.partial_diff && !!fc.patch_text;
3122
+ const body = expandable ? `<pre class="file-diff" style="display:none">${renderColorizedPatch(fc.patch_text)}</pre>` : fc.partial_diff ? `<div class="file-diff-empty">partial \u2014 this change ran outside captured tools (e.g. Bash). Enable <code>meter watch --files</code> in v0.4 for full fidelity.</div>` : fc.patch_format === "binary" ? `<div class="file-diff-empty">binary file \u2014 ${esc(fc.path)} (${fc.size_before ?? "?"} \u2192 ${fc.size_after ?? "?"} bytes). <a href="/api/blob/${esc(fc.after_blob_ref ?? "")}" target="_blank">raw bytes</a></div>` : "";
3123
+ const rowId = `fc-${esc(fc.file_change_id)}-${idx}`;
3124
+ return `<div class="file-row" data-op="${esc(fc.op)}">
3125
+ <button class="file-row-head${expandable ? " expandable" : ""}"
3126
+ ${expandable ? `onclick="toggleFileDiff('${rowId}')"` : ""}>
3127
+ <span class="file-op file-op-${esc(fc.op)}">${opLetter(fc.op)}</span>
3128
+ <span class="file-path mono">${renderedPath}</span>
3129
+ <span class="file-stats"><span class="files-stat-add">+${fc.lines_added}</span> <span class="files-stat-rm">\u2212${fc.lines_removed}</span></span>
3130
+ ${flags.join(" ")}
3131
+ ${expandable ? `<span class="file-row-caret">\u25BE</span>` : ""}
3132
+ </button>
3133
+ <div id="${rowId}">${body}</div>
3134
+ </div>`;
3135
+ }).join("");
3136
+ return `${header}<div class="file-list">${rows}</div>`;
3137
+ }
3138
+ function renderColorizedPatch(patch) {
3139
+ return patch.split("\n").map((line) => {
3140
+ if (line.startsWith("@@"))
3141
+ return `<span class="diff-hunk">${esc(line)}</span>`;
3142
+ if (line.startsWith("+") && !line.startsWith("+++"))
3143
+ return `<span class="diff-add">${esc(line)}</span>`;
3144
+ if (line.startsWith("-") && !line.startsWith("---"))
3145
+ return `<span class="diff-del">${esc(line)}</span>`;
3146
+ return esc(line);
3147
+ }).join("\n");
3148
+ }
3149
+ function opLetter(op) {
3150
+ switch (op) {
3151
+ case "create":
3152
+ return "A";
3153
+ case "modify":
3154
+ return "M";
3155
+ case "delete":
3156
+ return "D";
3157
+ case "rename":
3158
+ return "R";
3159
+ case "chmod":
3160
+ return "X";
3161
+ }
3162
+ }
3163
+ function renderRunFilesSummary(fcs, steps) {
3164
+ if (fcs.length === 0) return "";
3165
+ const byPath = /* @__PURE__ */ new Map();
3166
+ for (const fc of fcs) {
3167
+ const cur = byPath.get(fc.path);
3168
+ if (cur) {
3169
+ cur.terminalOp = fc.op;
3170
+ cur.lines_added += fc.lines_added;
3171
+ cur.lines_removed += fc.lines_removed;
3172
+ cur.any_partial = cur.any_partial || fc.partial_diff;
3173
+ cur.any_binary = cur.any_binary || fc.patch_format === "binary";
3174
+ cur.step_ids.add(fc.step_id);
3175
+ if (fc.op === "rename" && fc.old_path) cur.rename_from = fc.old_path;
3176
+ } else {
3177
+ byPath.set(fc.path, {
3178
+ path: fc.path,
3179
+ terminalOp: fc.op,
3180
+ lines_added: fc.lines_added,
3181
+ lines_removed: fc.lines_removed,
3182
+ rename_from: fc.op === "rename" ? fc.old_path : void 0,
3183
+ any_partial: fc.partial_diff,
3184
+ any_binary: fc.patch_format === "binary",
3185
+ step_ids: /* @__PURE__ */ new Set([fc.step_id])
3186
+ });
3187
+ }
3188
+ }
3189
+ const rows = [...byPath.values()].sort(
3190
+ (a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0
3191
+ );
3192
+ const totalAdded = rows.reduce((n, r) => n + r.lines_added, 0);
3193
+ const totalRemoved = rows.reduce((n, r) => n + r.lines_removed, 0);
3194
+ const seqByStepId = /* @__PURE__ */ new Map();
3195
+ for (const s of steps) seqByStepId.set(s.step_id, s.sequence);
3196
+ return `<details class="run-files-summary" open>
3197
+ <summary>
3198
+ <span class="section-label">Files changed in this run</span>
3199
+ <span class="run-files-totals">
3200
+ <span class="files-stat-add">+${totalAdded}</span>
3201
+ <span class="files-stat-rm">\u2212${totalRemoved}</span>
3202
+ <span class="files-stat-count">${rows.length} file${rows.length === 1 ? "" : "s"}</span>
3203
+ </span>
3204
+ </summary>
3205
+ <div class="run-files-list">
3206
+ ${rows.map((r) => {
3207
+ const lastSeq = Math.max(
3208
+ 0,
3209
+ ...[...r.step_ids].map((id) => seqByStepId.get(id) ?? 0)
3210
+ );
3211
+ const renderedPath = r.terminalOp === "rename" && r.rename_from ? `${esc(r.rename_from)} \u2192 ${esc(r.path)}` : esc(r.path);
3212
+ const flags = [];
3213
+ if (r.any_partial) flags.push(`<span class="file-flag flag-partial">partial</span>`);
3214
+ if (r.any_binary) flags.push(`<span class="file-flag flag-binary">binary</span>`);
3215
+ return `<button class="run-file-row" data-op="${esc(r.terminalOp)}"
3216
+ onclick="jumpToStep(${lastSeq})"
3217
+ title="jump to step #${lastSeq} (last touch of ${esc(r.path)})">
3218
+ <span class="file-op file-op-${esc(r.terminalOp)}">${opLetter(r.terminalOp)}</span>
3219
+ <span class="file-path mono">${renderedPath}</span>
3220
+ <span class="file-stats">
3221
+ <span class="files-stat-add">+${r.lines_added}</span>
3222
+ <span class="files-stat-rm">\u2212${r.lines_removed}</span>
3223
+ </span>
3224
+ <span class="run-file-row-meta">${r.step_ids.size} step${r.step_ids.size === 1 ? "" : "s"}</span>
3225
+ ${flags.join(" ")}
3226
+ </button>`;
3227
+ }).join("")}
3228
+ </div>
3229
+ </details>`;
3230
+ }
3231
+ function renderDiff(a, b, d, opts = {}) {
3232
+ const showShared = opts.showShared === true;
3233
+ const sharedHidden = d.rows.filter((r) => r.kind === "shared").length;
3234
+ const header = `<div style="margin-bottom:var(--space-5)">
3235
+ <div class="section-label">Trajectory diff</div>
3236
+ <h2 style="margin:0">${esc(a.title ?? a.run_id.slice(0, 12))} <span style="color:var(--text-tertiary);font-weight:400">vs</span> ${esc(b.title ?? b.run_id.slice(0, 12))}</h2>
3237
+ </div>
3238
+ <div class="meta-row">
3239
+ <div class="kv"><strong>Shared prefix</strong> <span class="val">${d.shared_prefix_length} steps</span></div>
3240
+ <div class="kv"><strong>First divergence</strong> <span class="val">${d.first_divergence_sequence ?? "\u2014"}</span></div>
3241
+ <div class="kv"><strong>Total A / B</strong> <span class="val">${d.total_steps_a} / ${d.total_steps_b}</span></div>
3242
+ </div>
3243
+ <div style="display:flex;gap:8px;align-items:center;margin-bottom:14px;flex-wrap:wrap">
3244
+ <a href="/diff?a=${esc(a.run_id)}&b=${esc(b.run_id)}${showShared ? "" : "&shared=1"}"
3245
+ class="${showShared ? "primary" : ""}"
3246
+ style="padding:6px 12px;border:1px solid var(--border);border-radius:4px;text-decoration:none;font-size:12px;${showShared ? "background:var(--accent);color:#0e1116;border-color:var(--accent)" : "color:var(--fg)"}">
3247
+ ${showShared ? "\u2713 Showing shared rows" : `Show shared rows (${sharedHidden} hidden)`}
3248
+ </a>
3249
+ <a href="/api/diff?a=${esc(a.run_id)}&b=${esc(b.run_id)}"
3250
+ download="diff-${esc(a.run_id.slice(0, 8))}-${esc(b.run_id.slice(0, 8))}.json"
3251
+ style="padding:6px 12px;border:1px solid var(--border);border-radius:4px;text-decoration:none;font-size:12px;color:var(--fg);font-family:ui-monospace,Menlo,monospace">
3252
+ Download JSON
3253
+ </a>
3254
+ </div>`;
3255
+ const rows = d.rows.filter((r) => showShared || r.kind !== "shared").map((row) => {
3256
+ const a2 = row.a ? `${row.a.action_kind}${row.a.tool_name ? "(" + esc(row.a.tool_name) + ")" : ""} \xB7 <span class="pill ${esc(row.a.outcome_status)}">${esc(row.a.outcome_status)}</span>` : "\u2014";
3257
+ const b2 = row.b ? `${row.b.action_kind}${row.b.tool_name ? "(" + esc(row.b.tool_name) + ")" : ""} \xB7 <span class="pill ${esc(row.b.outcome_status)}">${esc(row.b.outcome_status)}</span>` : "\u2014";
3258
+ return `<tr class="diff-row ${esc(row.kind)}">
3259
+ <td>${row.sequence}</td>
3260
+ <td><span class="pill">${esc(row.kind)}</span></td>
3261
+ <td>${a2}</td>
3262
+ <td>${b2}</td>
3263
+ </tr>`;
3264
+ }).join("");
3265
+ return `${header}
3266
+ <table>
3267
+ <thead><tr><th>Seq</th><th>Kind</th><th>A: ${esc(a.run_id.slice(0, 12))}</th><th>B: ${esc(b.run_id.slice(0, 12))}</th></tr></thead>
3268
+ <tbody>${rows}</tbody>
3269
+ </table>`;
3270
+ }
3271
+ function renderTests(tests, recent) {
3272
+ const items = tests.length ? tests.map(
3273
+ (t) => `<a class="item" data-name="${esc(t.name)}" onclick="selectTest('${esc(t.name)}')">
3274
+ <div>${esc(t.name)}</div>
3275
+ <div class="meta">${t.assertions.length} assertions${t.canonical_run_id ? ` \xB7 canon ${esc(t.canonical_run_id.slice(0, 12))}` : ""}</div>
3276
+ </a>`
3277
+ ).join("") : `<p style="color:var(--fg-mute);font-size:12.5px;padding:8px">No tests yet.</p>`;
3278
+ const recentRows = recent.length ? recent.map(
3279
+ (r) => `<div class="row ${r.passed ? "pass" : "fail"}">
3280
+ ${r.passed ? "PASS" : "FAIL"} ${esc(r.test_name.padEnd(20))} ${esc(r.run_id.slice(0, 12))} ${esc(r.created_at)}
3281
+ </div>`
3282
+ ).join("") : `<p style="color:var(--fg-mute);font-size:12px">No results yet \u2014 pick a test and click "Run on all runs."</p>`;
3283
+ return `<div style="display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:var(--space-5)">
3284
+ <div>
3285
+ <div class="section-label">Regression suite</div>
3286
+ <h2 style="margin:0">Tests</h2>
3287
+ </div>
3288
+ <button class="primary" onclick="createNewTest()">+ New test</button>
3289
+ </div>
3290
+ <div class="tests-grid">
3291
+ <div class="test-list">${items}</div>
3292
+ <div id="test-detail" class="test-detail">
3293
+ <div class="empty">Select a test from the left to edit, or create a new one.</div>
3294
+ </div>
3295
+ </div>
3296
+ <div style="margin-top:var(--space-6)">
3297
+ <div class="section-label">Recent results \xB7 all tests</div>
3298
+ <div class="results-list">${recentRows}</div>
3299
+ </div>`;
3300
+ }
3301
+ function costEl(cents) {
3302
+ return `${costStr(cents)}<span class="cost-mark" title="Meterbility computes cost from token counts \xD7 Anthropic public per-token API rates. Two caveats: (1) Claude Pro/Max users pay a flat subscription \u2014 this is API-equivalent, NOT money out of your account. (2) Current API rates reflect VC-subsidized 2026 frontier-model economics: they cover inference with margin, but training runs (>$1B per Opus-class model) and cluster CapEx are funded by equity, not per-token revenue. If labs ever have to be cash-flow positive on a fully-loaded basis, expect these numbers to rise. Use for relative comparison between runs.">api\xB7metered</span>`;
3303
+ }
3304
+ function costStr(cents) {
3305
+ const dollars = cents / 100;
3306
+ if (dollars === 0) return "$0.00";
3307
+ if (Math.abs(dollars) >= 5e-3) return `$${dollars.toFixed(2)}`;
3308
+ return `$${dollars.toFixed(4)}`;
3309
+ }
3310
+ var COST_FOOTNOTE_HTML = `<div class="cost-footnote">
3311
+ <span class="label">Pricing</span>
3312
+ Costs are token counts \xD7 Anthropic's public per-token API rates
3313
+ (Opus 4.x: $15/$75 input/output, $1.50 cached read, $18.75 5m / $30 1h cache write per million).
3314
+ <br><br>
3315
+ <strong>Two things this number is not.</strong>
3316
+ (1) If you're on Claude Pro or Max, you pay a flat subscription \u2014
3317
+ this is the API-equivalent rate, <em>not</em> money out of your account.
3318
+ (2) The API rate itself reflects 2026 frontier-model economics: it covers
3319
+ inference with positive gross margin, but training runs (>$1B per Opus-class model),
3320
+ R&amp;D, and cluster CapEx are funded by VC equity rounds, not per-token revenue.
3321
+ If labs ever have to be cash-flow positive on a fully-loaded basis,
3322
+ expect these numbers to rise \u2014 possibly 2\u20134\xD7.
3323
+ <br><br>
3324
+ Useful for <em>relative</em> comparison between runs (this prompt vs. that prompt,
3325
+ this iteration vs. the canonical), not as a forecast of long-run cost.
3326
+ </div>`;
3327
+ function renderContext(snapshotId, ctx, meta = {}) {
3328
+ const header = `<div style="margin-bottom:var(--space-6)">
3329
+ <div class="section-label">Context snapshot</div>
3330
+ <h2 style="margin:0">What the model saw${meta.sequence !== void 0 ? ` at step #${meta.sequence}` : ""}</h2>
3331
+ <div class="meta-row" style="margin-top:var(--space-3)">
3332
+ <div class="kv"><strong>Snapshot</strong> <span class="val mono">${esc(snapshotId.slice(0, 16))}\u2026</span>
3333
+ <button class="copy-btn" onclick="copyText('${esc(snapshotId)}', this)">copy</button>
3334
+ </div>
3335
+ ${meta.runId ? `<div class="kv"><strong>Run</strong> <a class="val mono" href="/runs/${esc(meta.runId)}">${esc(meta.runId.slice(0, 16))}</a></div>` : ""}
3336
+ ${meta.stepId ? `<div class="kv"><strong>Step</strong> <span class="val mono">${esc(meta.stepId.slice(0, 16))}\u2026</span></div>` : ""}
3337
+ <div class="kv"><strong>Components</strong> <span class="val">${ctx.components.length}</span></div>
3338
+ <div class="kv"><strong>Total chars</strong> <span class="val">${ctx.totalChars.toLocaleString()}</span></div>
3339
+ </div>
3340
+ </div>`;
3341
+ if (ctx.components.length === 0) {
3342
+ return header + `<div class="empty">This snapshot has no captured components.<br>
3343
+ Note: Claude Code captures don't include the system prompt or tool definitions \u2014
3344
+ only what's visible in <code>~/.claude/projects/&lt;cwd&gt;/&lt;session&gt;.jsonl</code>.</div>`;
3345
+ }
3346
+ const blocks = ctx.components.map((c) => renderContextComponent(c)).join("");
3347
+ const fidelityNote = ctx.runtime === "claude-code" ? `<div class="static-banner" style="margin-top:var(--space-6)">
3348
+ <strong>Note on fidelity.</strong> Meterbility captures what Claude Code writes
3349
+ to its session log: conversation history. The system prompt, tool definitions,
3350
+ and any RAG context Anthropic injects server-side aren't in the log and
3351
+ aren't shown here. SDK-captured runs (<code>@meterbility/agent</code>) carry the
3352
+ full context.
3353
+ </div>` : "";
3354
+ return header + blocks + fidelityNote;
3355
+ }
3356
+ function renderContextComponent(c) {
3357
+ switch (c.type) {
3358
+ case "system_prompt":
3359
+ return `<div class="step-card">
3360
+ <div class="section-label">System prompt</div>
3361
+ <h3 style="display:flex;align-items:baseline;gap:8px">
3362
+ <span>${c.text.length.toLocaleString()} chars</span>
3363
+ <button class="copy-btn" onclick="copyText('${esc(c.ref)}', this)">${esc(c.ref.slice(0, 12))}</button>
3364
+ </h3>
3365
+ <pre class="body">${esc(c.text)}</pre>
3366
+ </div>`;
3367
+ case "tool_definitions":
3368
+ return `<div class="step-card">
3369
+ <div class="section-label">Tool definitions</div>
3370
+ <h3>${c.text.length.toLocaleString()} chars</h3>
3371
+ <pre class="body">${esc(reformatJsonString(c.text))}</pre>
3372
+ </div>`;
3373
+ case "conversation_history":
3374
+ return `<div class="step-card">
3375
+ <div class="section-label">Conversation history \xB7 ${c.messages.length} turn${c.messages.length === 1 ? "" : "s"}</div>
3376
+ <h3>Messages</h3>
3377
+ ${c.messages.map(
3378
+ (m, i) => `<div class="annotation" style="border-left-color:${roleColor(m.role)}">
3379
+ <div style="display:flex;align-items:baseline;gap:8px;margin-bottom:4px">
3380
+ <span class="badge ${roleBadgeClass(m.role)}">${esc(m.role.toUpperCase())}</span>
3381
+ <span style="color:var(--text-tertiary);font-size:11px;font-family:var(--font-mono)">#${i} \xB7 ${m.text.length.toLocaleString()} chars</span>
3382
+ <button class="copy-btn" style="margin-left:auto" onclick="copyText('${esc(m.ref)}', this)">${esc(m.ref.slice(0, 12))}</button>
3383
+ </div>
3384
+ <pre class="body" style="margin:0">${esc(m.text)}</pre>
3385
+ </div>`
3386
+ ).join("")}
3387
+ </div>`;
3388
+ case "retrieved_documents":
3389
+ return `<div class="step-card">
3390
+ <div class="section-label">Retrieved documents \xB7 ${c.docs.length}</div>
3391
+ <h3>RAG context</h3>
3392
+ ${c.docs.map(
3393
+ (d) => `<div class="annotation">
3394
+ <div style="display:flex;align-items:baseline;gap:8px;margin-bottom:4px">
3395
+ <strong>${esc(d.source)}</strong>
3396
+ <span style="color:var(--text-tertiary);font-size:11px">${d.text.length.toLocaleString()} chars</span>
3397
+ </div>
3398
+ <pre class="body" style="margin:0">${esc(d.text)}</pre>
3399
+ </div>`
3400
+ ).join("")}
3401
+ </div>`;
3402
+ case "compaction_summary":
3403
+ return `<div class="step-card">
3404
+ <div class="section-label">Compaction summary</div>
3405
+ <h3>Replaces ${c.replaces_steps.length} step${c.replaces_steps.length === 1 ? "" : "s"}</h3>
3406
+ <pre class="body">${esc(c.text)}</pre>
3407
+ </div>`;
3408
+ }
3409
+ }
3410
+ function roleColor(role) {
3411
+ switch (role) {
3412
+ case "user":
3413
+ return "var(--cerulean-400)";
3414
+ case "assistant":
3415
+ return "var(--mint-400)";
3416
+ case "tool":
3417
+ return "var(--amber-400)";
3418
+ default:
3419
+ return "var(--border-default)";
3420
+ }
3421
+ }
3422
+ function roleBadgeClass(role) {
3423
+ switch (role) {
3424
+ case "user":
3425
+ return "badge--info";
3426
+ case "assistant":
3427
+ return "badge--success";
3428
+ case "tool":
3429
+ return "badge--warn";
3430
+ default:
3431
+ return "badge--muted";
3432
+ }
3433
+ }
3434
+ function renderSettings(data) {
3435
+ const slackVal = data.slackWebhookFromEnv ? "(from $METERBILITY_SLACK_WEBHOOK)" : data.slackWebhook ?? "";
3436
+ const apiKeyDisplay = data.apiKeyFromEnv ? "(from $ANTHROPIC_API_KEY)" : data.apiKey ? "\u2022".repeat(48) : "";
3437
+ const pgVal = data.postgresUrlFromEnv ? "(from $METERBILITY_DB_URL)" : data.postgresUrl ?? "";
3438
+ return `<div style="margin-bottom:24px">
3439
+ <div class="section-label">Configuration</div>
3440
+ <h2 style="margin:0">Settings</h2>
3441
+ <div style="font-size:12.5px;color:var(--fg-mute);margin-top:6px">
3442
+ Stored in <code>$METERBILITY_HOME/meterbility.db</code>. Environment variables override stored values.
3443
+ </div>
3444
+ </div>
3445
+
3446
+ ${/* ─── Ingest ──────────────────────────── */
3447
+ ""}
3448
+ <div class="step-card">
3449
+ <div class="section-label">Ingest</div>
3450
+ <h3 style="margin-bottom:8px">Capture sessions from disk</h3>
3451
+ <p style="color:var(--fg-mute);font-size:12.5px;margin:0 0 12px">
3452
+ Replaces <code>meter ingest claude-code/codex-cli/cursor</code> from the terminal.
3453
+ </p>
3454
+ <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
3455
+ <label style="display:flex;gap:6px;align-items:center;font-size:12px;color:var(--fg-mute)">
3456
+ Limit
3457
+ <input type="number" id="ingest-limit" value="5" min="1" max="500" style="width:70px">
3458
+ </label>
3459
+ <button class="primary" onclick="runIngest('claude-code')">Ingest Claude Code</button>
3460
+ <button onclick="runIngest('codex-cli')">Ingest Codex</button>
3461
+ <button onclick="runIngest('cursor')">Ingest Cursor</button>
3462
+ <span id="ingest-status" style="margin-left:8px;font-size:12px;color:var(--fg-mute);font-family:ui-monospace,Menlo,monospace"></span>
3463
+ </div>
3464
+ </div>
3465
+
3466
+ ${/* ─── Doctor ──────────────────────────── */
3467
+ ""}
3468
+ <div class="step-card">
3469
+ <div class="section-label">Health</div>
3470
+ <h3 style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
3471
+ <span>Doctor</span>
3472
+ <span class="row-actions" style="margin-left:auto">
3473
+ <button onclick="runDoctor()">Run checks</button>
3474
+ </span>
3475
+ </h3>
3476
+ <p style="color:var(--fg-mute);font-size:12.5px;margin:0 0 12px">
3477
+ Equivalent to <code>meter doctor</code>: Node version, capture surface, store integrity.
3478
+ </p>
3479
+ <div id="doctor-results" style="font-family:ui-monospace,Menlo,monospace;font-size:12px"></div>
3480
+ </div>
3481
+
3482
+ ${/* ─── Slack ──────────────────────────── */
3483
+ ""}
3484
+ <div class="step-card">
3485
+ <div class="section-label">Notifications</div>
3486
+ <h3 style="margin-bottom:8px">Slack webhook</h3>
3487
+ <p style="color:var(--fg-mute);font-size:12.5px;margin:0 0 12px">
3488
+ Routes live alerts (loop / stall / context-threshold / tool-watched) to Slack.
3489
+ Take an Incoming Webhook URL from your Slack workspace.
3490
+ </p>
3491
+ <div style="display:flex;gap:8px;align-items:center">
3492
+ <input id="slack-webhook" type="password"
3493
+ placeholder="https://hooks.slack.com/services/..."
3494
+ value="${esc(slackVal)}"
3495
+ ${data.slackWebhookFromEnv ? "disabled" : ""}
3496
+ style="flex:1;font-family:ui-monospace,Menlo,monospace">
3497
+ ${data.slackWebhookFromEnv ? `<span class="badge" style="opacity:0.8">env</span>` : `<button class="primary" onclick="saveSetting('slack.webhook', document.getElementById('slack-webhook').value, 'slack-status')">Save</button>
3498
+ <button onclick="testSlack()">Test</button>`}
3499
+ </div>
3500
+ <p id="slack-status" style="font-size:12px;color:var(--fg-mute);margin-top:8px;font-family:ui-monospace,Menlo,monospace"></p>
3501
+ </div>
3502
+
3503
+ ${/* ─── Anthropic API key ──────────────── */
3504
+ ""}
3505
+ <div class="step-card">
3506
+ <div class="section-label">Anthropic</div>
3507
+ <h3 style="margin-bottom:8px">API key (for live fork suffix)</h3>
3508
+ <p style="color:var(--fg-mute);font-size:12.5px;margin:0 0 12px">
3509
+ Required when using <strong>Live</strong> mode in the Fork modal or <strong>Continue: live</strong> for multi-step replay.
3510
+ Stored unencrypted in the local SQLite store.
3511
+ </p>
3512
+ <div style="display:flex;gap:8px;align-items:center">
3513
+ <input id="anthropic-key" type="password"
3514
+ placeholder="sk-ant-..."
3515
+ value="${esc(apiKeyDisplay)}"
3516
+ ${data.apiKeyFromEnv ? "disabled" : ""}
3517
+ style="flex:1;font-family:ui-monospace,Menlo,monospace">
3518
+ ${data.apiKeyFromEnv ? `<span class="badge" style="opacity:0.8">env</span>` : `<button class="primary" onclick="saveSetting('anthropic.api_key', document.getElementById('anthropic-key').value, 'apikey-status')">Save</button>`}
3519
+ </div>
3520
+ <p id="apikey-status" style="font-size:12px;color:var(--fg-mute);margin-top:8px;font-family:ui-monospace,Menlo,monospace"></p>
3521
+ </div>
3522
+
3523
+ ${/* ─── Postgres ──────────────────────── */
3524
+ ""}
3525
+ <div class="step-card">
3526
+ <div class="section-label">Hosted backend (optional)</div>
3527
+ <h3 style="margin-bottom:8px">Postgres</h3>
3528
+ <p style="color:var(--fg-mute);font-size:12.5px;margin:0 0 12px">
3529
+ Replicate captured runs to Postgres for the team tier (SPEC \xA715.3). One-way sync; local SQLite remains primary.
3530
+ </p>
3531
+ <div style="display:flex;gap:8px;align-items:center">
3532
+ <input id="pg-url" type="password"
3533
+ placeholder="postgres://user:pass@host:5432/meter"
3534
+ value="${esc(pgVal)}"
3535
+ ${data.postgresUrlFromEnv ? "disabled" : ""}
3536
+ style="flex:1;font-family:ui-monospace,Menlo,monospace">
3537
+ ${data.postgresUrlFromEnv ? `<span class="badge" style="opacity:0.8">env</span>` : `<button class="primary" onclick="saveSetting('postgres.url', document.getElementById('pg-url').value, 'pg-status')">Save</button>`}
3538
+ </div>
3539
+ <div style="display:flex;gap:8px;margin-top:10px">
3540
+ <button onclick="postgresInit()">Init schema</button>
3541
+ <button onclick="postgresSync()">Sync now</button>
3542
+ </div>
3543
+ <p id="pg-status" style="font-size:12px;color:var(--fg-mute);margin-top:8px;font-family:ui-monospace,Menlo,monospace"></p>
3544
+ </div>
3545
+
3546
+ ${/* ─── Capture defaults ──────────────── */
3547
+ ""}
3548
+ <div class="step-card">
3549
+ <div class="section-label">Capture defaults</div>
3550
+ <h3 style="margin-bottom:12px">Live + fork preferences</h3>
3551
+ <div class="grid-2" style="gap:16px">
3552
+ <label style="font-size:12px;color:var(--fg-mute);display:flex;flex-direction:column;gap:4px">
3553
+ Watched tools (comma-separated, fires alerts when called)
3554
+ <input id="watch-tools" value="${esc(data.watchedTools)}" placeholder="Bash, Write" style="font-family:ui-monospace,Menlo,monospace">
3555
+ </label>
3556
+ <label style="font-size:12px;color:var(--fg-mute);display:flex;flex-direction:column;gap:4px">
3557
+ Stall threshold (seconds)
3558
+ <input id="stall-seconds" type="number" min="10" max="3600" value="${data.stallSeconds}">
3559
+ </label>
3560
+ <label style="font-size:12px;color:var(--fg-mute);display:flex;flex-direction:column;gap:4px">
3561
+ Default fork model
3562
+ <input id="default-model" value="${esc(data.defaultModel)}" style="font-family:ui-monospace,Menlo,monospace">
3563
+ </label>
3564
+ <label style="font-size:12px;color:var(--fg-mute);display:flex;flex-direction:column;gap:4px">
3565
+ Default max iterations (multi-step continuation)
3566
+ <input id="default-max-iter" type="number" min="1" max="100" value="${data.defaultMaxIterations}">
3567
+ </label>
3568
+ </div>
3569
+ <div style="margin-top:14px;display:flex;gap:8px">
3570
+ <button class="primary" onclick="saveDefaults()">Save defaults</button>
3571
+ </div>
3572
+ <p id="defaults-status" style="font-size:12px;color:var(--fg-mute);margin-top:8px;font-family:ui-monospace,Menlo,monospace"></p>
3573
+ </div>`;
3574
+ }
3575
+ function esc(s) {
3576
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
3577
+ }
3578
+
3579
+ export {
3580
+ DECISION_PREVIEW_LIMIT,
3581
+ prettyMultilineString,
3582
+ prettyValue,
3583
+ prettyTab,
3584
+ reformatJsonString,
3585
+ renderShell,
3586
+ renderFleet,
3587
+ renderRunList,
3588
+ renderRun,
3589
+ renderProbePanel,
3590
+ renderStepCardFragment,
3591
+ renderDiff,
3592
+ renderTests,
3593
+ renderContext,
3594
+ renderSettings
3595
+ };