@xenosystem/blocks 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3757 @@
1
+ import {
2
+ XENO_TERMINAL_THEME_KEYS,
3
+ isLive,
4
+ measuredSize,
5
+ readTerminalTheme,
6
+ statusLabel,
7
+ terminalThemeVar
8
+ } from "../chunk-WE6A2DTY.js";
9
+
10
+ // src/ops/console/panel.ts
11
+ import { createElement } from "react";
12
+ import { createRoot } from "react-dom/client";
13
+ import {
14
+ isRecord,
15
+ bindConfig
16
+ } from "@xenosystem/panel-sdk";
17
+
18
+ // src/ops/console/controller.ts
19
+ import { redactForLog } from "@xenosystem/panel-sdk";
20
+
21
+ // src/ops/console/types.ts
22
+ var LEVEL_ORDER = Object.freeze([
23
+ "trace",
24
+ "debug",
25
+ "info",
26
+ "warn",
27
+ "error"
28
+ ]);
29
+ function meetsLevel(level, min) {
30
+ if (!min) return true;
31
+ return LEVEL_ORDER.indexOf(level) >= LEVEL_ORDER.indexOf(min);
32
+ }
33
+ function matchesSearch(record, term) {
34
+ if (!term) return true;
35
+ const needle = term.toLowerCase();
36
+ return record.message.toLowerCase().includes(needle) || (record.source ?? "").toLowerCase().includes(needle) || (record.scope ?? "").toLowerCase().includes(needle);
37
+ }
38
+ function collapseAdjacent(records) {
39
+ const out = [];
40
+ for (const record of records) {
41
+ const last = out[out.length - 1];
42
+ if (last && last.level === record.level && last.message === record.message && last.source === record.source && last.scope === record.scope) {
43
+ out[out.length - 1] = { ...last, ts: record.ts, count: (last.count ?? 1) + 1 };
44
+ continue;
45
+ }
46
+ out.push({ ...record, count: 1 });
47
+ }
48
+ return out;
49
+ }
50
+ var REDACTION_CARRY = 1024;
51
+ var MESSAGE_ELISION = "\u2026";
52
+ function spliceRedactedAppend(existing, addition, redact, carry = REDACTION_CARRY) {
53
+ const window = Math.max(0, carry);
54
+ const cut = Math.max(0, existing.length - window);
55
+ const head = existing.slice(0, cut);
56
+ const tail = existing.slice(cut);
57
+ return head + redact(tail + addition);
58
+ }
59
+ function truncateLogMessage(text, max) {
60
+ if (max <= 0 || text.length <= max) return text;
61
+ if (max <= MESSAGE_ELISION.length) return text.slice(text.length - max);
62
+ return MESSAGE_ELISION + text.slice(text.length - (max - MESSAGE_ELISION.length));
63
+ }
64
+ function formatRecord(record) {
65
+ const time = new Date(record.ts).toISOString();
66
+ const where = [record.source, record.scope].filter(Boolean).join("/");
67
+ const head = `${time} ${record.level.toUpperCase()}${where ? ` [${where}]` : ""} ${record.message}`;
68
+ if (record.data === void 0) return head;
69
+ return `${head}
70
+ ${safeJson(record.data)}`;
71
+ }
72
+ function safeJson(value) {
73
+ const seen = /* @__PURE__ */ new WeakSet();
74
+ try {
75
+ return JSON.stringify(
76
+ value,
77
+ (_k, v) => {
78
+ if (v && typeof v === "object") {
79
+ if (seen.has(v)) return "[circular]";
80
+ seen.add(v);
81
+ }
82
+ return v;
83
+ },
84
+ 2
85
+ ) ?? String(value);
86
+ } catch {
87
+ return String(value);
88
+ }
89
+ }
90
+
91
+ // src/ops/console/controller.ts
92
+ var ConsoleController = class {
93
+ host;
94
+ listeners = /* @__PURE__ */ new Set();
95
+ maxRecords;
96
+ maxMessageChars;
97
+ records = [];
98
+ /**
99
+ * Record id → ABSOLUTE position, where absolute = array index + {@link origin}.
100
+ *
101
+ * Patching by id has to be O(1) or streaming is worse than the thing it replaces: a linear scan
102
+ * of a 5 000-record buffer per fragment, at a thousand fragments a second, is five million
103
+ * comparisons a second to update one line. (`runs/src/controller.ts` keeps the same index for the
104
+ * same reason.)
105
+ *
106
+ * The absolute/origin split is what makes the ring buffer cheap. The buffer trims from the FRONT,
107
+ * so storing raw array indices would mean decrementing every entry on every trim — O(n) per
108
+ * append once the buffer is full. An offset moves the whole map in one addition instead.
109
+ *
110
+ * ⚠️ Records with no id, or an empty one, are not indexed and therefore cannot be patched. That
111
+ * is the honest outcome: `id` is what "addressed" means here, and there is nothing else to
112
+ * address them by.
113
+ */
114
+ index = /* @__PURE__ */ new Map();
115
+ /** Absolute position of `records[0]`. */
116
+ origin = 0;
117
+ filter;
118
+ follow;
119
+ collapse;
120
+ rev = 0;
121
+ dropped = 0;
122
+ droppedPatches = 0;
123
+ snapshot = null;
124
+ constructor(options) {
125
+ this.host = options.host;
126
+ this.maxRecords = Math.max(1, options.maxRecords ?? 5e3);
127
+ this.maxMessageChars = Math.max(REDACTION_CARRY * 2, options.maxMessageChars ?? 65536);
128
+ this.filter = options.initial?.filter ?? {};
129
+ this.follow = options.initial?.follow ?? options.follow ?? true;
130
+ this.collapse = options.initial?.collapse ?? options.collapse ?? true;
131
+ }
132
+ /* ── Subscription ──────────────────────────────────────────────────────── */
133
+ subscribe = (listener) => {
134
+ this.listeners.add(listener);
135
+ return () => this.listeners.delete(listener);
136
+ };
137
+ getState = () => {
138
+ if (!this.snapshot) {
139
+ const counts = {
140
+ trace: 0,
141
+ debug: 0,
142
+ info: 0,
143
+ warn: 0,
144
+ error: 0
145
+ };
146
+ for (const record of this.records) counts[record.level] += 1;
147
+ this.snapshot = {
148
+ rows: this.rows(),
149
+ sources: this.sources(),
150
+ filter: this.filter,
151
+ counts,
152
+ total: this.records.length,
153
+ follow: this.follow,
154
+ collapse: this.collapse,
155
+ dropped: this.dropped,
156
+ droppedPatches: this.droppedPatches
157
+ };
158
+ }
159
+ return this.snapshot;
160
+ };
161
+ notify() {
162
+ this.snapshot = null;
163
+ for (const listener of this.listeners) listener();
164
+ }
165
+ /* ── Ingest ────────────────────────────────────────────────────────────── */
166
+ /**
167
+ * Apply a delta.
168
+ *
169
+ * @param delta - Append / patch / replace / clear.
170
+ * @returns `true` if applied; `false` if dropped as stale.
171
+ */
172
+ apply(delta) {
173
+ if (delta.rev !== void 0 && delta.rev <= this.rev) return false;
174
+ if (delta.rev !== void 0) this.rev = delta.rev;
175
+ if (delta.clear) {
176
+ this.records = delta.source ? this.records.filter((r) => r.source !== delta.source) : [];
177
+ this.dropped = 0;
178
+ this.droppedPatches = 0;
179
+ this.reindex();
180
+ } else if (delta.replace) {
181
+ const kept = delta.source ? this.records.filter((r) => r.source !== delta.source) : [];
182
+ this.records = [...kept, ...delta.replace.map((r) => this.sanitize(r))];
183
+ this.reindex();
184
+ }
185
+ if (delta.append && delta.append.length > 0) {
186
+ for (const record of delta.append) {
187
+ const clean = this.sanitize(record);
188
+ this.records.push(clean);
189
+ this.remember(clean.id, this.origin + this.records.length - 1);
190
+ }
191
+ }
192
+ if (delta.patch) this.applyPatches(delta.patch);
193
+ if (this.records.length > this.maxRecords) {
194
+ const over = this.records.length - this.maxRecords;
195
+ for (let i = 0; i < over; i += 1) this.forget(this.records[i].id, this.origin + i);
196
+ this.records.splice(0, over);
197
+ this.origin += over;
198
+ this.dropped += over;
199
+ }
200
+ this.notify();
201
+ return true;
202
+ }
203
+ /* ── Update by id ──────────────────────────────────────────────────────── */
204
+ /**
205
+ * Apply the `patch` map of a delta.
206
+ *
207
+ * ⚠️ Every field is checked, not cast. A wire is a user-editable connection, so the wrong shape
208
+ * arriving is a normal mis-wire rather than an exceptional condition — and the failure mode here
209
+ * is not a throw but silent corruption: a `level` of `"shout"` would add a key to the level
210
+ * counts that no filter can ever match and no chip can ever reveal.
211
+ */
212
+ applyPatches(patches) {
213
+ if (typeof patches !== "object" || Array.isArray(patches)) return;
214
+ for (const [id, patch] of Object.entries(patches)) {
215
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
216
+ this.droppedPatches += 1;
217
+ continue;
218
+ }
219
+ const at = this.positionOf(id);
220
+ if (at < 0) {
221
+ this.droppedPatches += 1;
222
+ continue;
223
+ }
224
+ this.records[at] = this.merge(this.records[at], patch);
225
+ }
226
+ }
227
+ /**
228
+ * Fold a patch into a record, redacting what the patch brought in.
229
+ *
230
+ * 🔴 The append path redacts across the JOIN, not the fragment — {@link spliceRedactedAppend}
231
+ * carries the reasoning. Truncation runs last so the cap counts redacted characters; see
232
+ * {@link truncateLogMessage} for why measuring raw text would make the retained window vary.
233
+ *
234
+ * ⚠️ `source` is deliberately NOT redacted, matching `sanitize` on the append path: it is a
235
+ * stream name the host chose, and redacting it would break the per-source mute chips it keys.
236
+ */
237
+ merge(existing, patch) {
238
+ const secrets = [...this.host.getSecrets?.() ?? []];
239
+ const redact = (text) => redactForLog(text, secrets);
240
+ let message = existing.message;
241
+ if (typeof patch.message === "string") message = redact(patch.message);
242
+ if (typeof patch.appendMessage === "string" && patch.appendMessage.length > 0) {
243
+ const carry = Math.max(REDACTION_CARRY, ...secrets.map((s) => s.length));
244
+ message = spliceRedactedAppend(message, patch.appendMessage, redact, carry);
245
+ }
246
+ return {
247
+ ...existing,
248
+ // `id` is restated from the EXISTING record, never the patch: it is the virtualized row key
249
+ // and the argument to recordActivate, and a patch that moved it would remount the row
250
+ // mid-stream and repoint the host's reveal hook at a different line.
251
+ id: existing.id,
252
+ message: truncateLogMessage(message, this.maxMessageChars),
253
+ level: LEVEL_ORDER.includes(patch.level) ? patch.level : existing.level,
254
+ ts: Number.isFinite(patch.ts) ? patch.ts : existing.ts,
255
+ source: typeof patch.source === "string" ? patch.source : existing.source,
256
+ scope: typeof patch.scope === "string" ? redact(patch.scope) : existing.scope,
257
+ // One rule for every field: an absent key means "leave it alone". To empty a payload, send
258
+ // `null` — `{ data: undefined }` is indistinguishable from omitting it.
259
+ data: patch.data !== void 0 ? redactForLog(patch.data, secrets) : existing.data,
260
+ // `count` is the panel's, set by collapsing. A patch cannot claim a line repeated.
261
+ count: existing.count
262
+ };
263
+ }
264
+ /* ── The id index ──────────────────────────────────────────────────────── */
265
+ /** Array position of `id`, or `-1` when it is unknown or has been evicted. */
266
+ positionOf(id) {
267
+ const absolute = this.index.get(id);
268
+ if (absolute === void 0) return -1;
269
+ const at = absolute - this.origin;
270
+ return at >= 0 && at < this.records.length ? at : -1;
271
+ }
272
+ /**
273
+ * Index a record.
274
+ *
275
+ * ⚠️ Last-wins when a host re-uses an id, which the contract forbids but nothing prevents. For a
276
+ * PATCH that is the right answer — a producer that re-used an id is talking about the line it
277
+ * just wrote, not one that scrolled past — and it also keeps eviction correct, because the ring
278
+ * trims from the front and so retires the older twin first.
279
+ */
280
+ remember(id, absolute) {
281
+ if (typeof id === "string" && id.length > 0) this.index.set(id, absolute);
282
+ }
283
+ /** Drop an index entry, but only if it still points at the position being evicted. */
284
+ forget(id, absolute) {
285
+ if (this.index.get(id) === absolute) this.index.delete(id);
286
+ }
287
+ /**
288
+ * Rebuild the whole index.
289
+ *
290
+ * Only for `clear` and `replace`, which remove from the middle and so invalidate every position
291
+ * after the hole. Those are cold paths — a source restarting — and paying O(n) there is what buys
292
+ * O(1) on the hot ones.
293
+ */
294
+ reindex() {
295
+ this.index = /* @__PURE__ */ new Map();
296
+ this.origin = 0;
297
+ for (let i = 0; i < this.records.length; i += 1) this.remember(this.records[i].id, i);
298
+ }
299
+ /** Append records directly — sugar for the common case. */
300
+ append(records) {
301
+ return this.apply({ append: [...records] });
302
+ }
303
+ /**
304
+ * Redact a record on the way IN.
305
+ *
306
+ * Not at render: redacting late leaves the secret in the buffer, in search results, and in
307
+ * whatever `copy` puts on the clipboard.
308
+ */
309
+ sanitize(record) {
310
+ const secrets = this.host.getSecrets?.() ?? [];
311
+ return {
312
+ ...record,
313
+ /*
314
+ * 🔴 Truncate on THIS path too, not only on the streaming merge.
315
+ *
316
+ * `maxRecords` bounds how many lines are held; nothing bounded how long ONE line is — and
317
+ * the omission was invisible, because the record count never moves. Measured before this
318
+ * fix: with `maxMessageChars: 2048`, an appended 1,080,000-character message stored all
319
+ * 1,080,000, while the same text arriving as `appendMessage` fragments stored 2,048. One
320
+ * ingest path honoured a documented cap and the other ignored it.
321
+ *
322
+ * ⚠️ Order matters: redact, THEN truncate. Truncating first can split a secret across the
323
+ * elision boundary so neither half matches the registered value, and a redactor that no
324
+ * longer recognises what it is looking at is worse than no cap.
325
+ */
326
+ message: truncateLogMessage(redactForLog(record.message, secrets), this.maxMessageChars),
327
+ scope: record.scope === void 0 ? void 0 : redactForLog(record.scope, secrets),
328
+ data: record.data === void 0 ? void 0 : redactForLog(record.data, secrets)
329
+ };
330
+ }
331
+ /* ── Filtering ─────────────────────────────────────────────────────────── */
332
+ /** Replace the filter. */
333
+ setFilter(filter) {
334
+ this.filter = filter;
335
+ this.notify();
336
+ }
337
+ /** Set the search term. */
338
+ setSearch(search) {
339
+ this.filter = { ...this.filter, search };
340
+ this.notify();
341
+ }
342
+ /** Set the minimum severity. */
343
+ setLevel(minLevel) {
344
+ if (minLevel !== void 0 && !LEVEL_ORDER.includes(minLevel)) return false;
345
+ this.filter = { ...this.filter, minLevel };
346
+ this.notify();
347
+ return true;
348
+ }
349
+ /** Show or hide one source. */
350
+ toggleSource(source, enabled) {
351
+ const muted = new Set(this.filter.mutedSources ?? []);
352
+ const next = enabled ?? muted.has(source);
353
+ if (next) muted.delete(source);
354
+ else muted.add(source);
355
+ this.filter = { ...this.filter, mutedSources: [...muted] };
356
+ this.notify();
357
+ }
358
+ /** The visible rows, filtered and (optionally) collapsed. */
359
+ rows() {
360
+ const muted = new Set(this.filter.mutedSources ?? []);
361
+ const search = (this.filter.search ?? "").trim();
362
+ const visible2 = this.records.filter(
363
+ (r) => meetsLevel(r.level, this.filter.minLevel) && !muted.has(r.source ?? "") && matchesSearch(r, search)
364
+ );
365
+ return this.collapse ? collapseAdjacent(visible2) : visible2.map((r) => ({ ...r, count: 1 }));
366
+ }
367
+ /** Every source seen, with its contribution count and current visibility. */
368
+ sources() {
369
+ const muted = new Set(this.filter.mutedSources ?? []);
370
+ const counts = /* @__PURE__ */ new Map();
371
+ for (const record of this.records) {
372
+ const id = record.source ?? "";
373
+ counts.set(id, (counts.get(id) ?? 0) + 1);
374
+ }
375
+ return [...counts.entries()].map(([id, count]) => ({
376
+ id,
377
+ label: id === "" ? "(unsourced)" : id,
378
+ enabled: !muted.has(id),
379
+ count
380
+ }));
381
+ }
382
+ /* ── Interaction ───────────────────────────────────────────────────────── */
383
+ /** Follow the tail. Auto-disabled by the view when the user scrolls up. */
384
+ setFollow(follow) {
385
+ if (this.follow === follow) return;
386
+ this.follow = follow;
387
+ this.notify();
388
+ }
389
+ /** Collapse adjacent duplicates. */
390
+ setCollapse(collapse) {
391
+ if (this.collapse === collapse) return;
392
+ this.collapse = collapse;
393
+ this.notify();
394
+ }
395
+ /** Drop every record. Emits nothing — clearing a view is not an event a host acts on. */
396
+ clear() {
397
+ this.records = [];
398
+ this.dropped = 0;
399
+ this.droppedPatches = 0;
400
+ this.reindex();
401
+ this.notify();
402
+ }
403
+ /**
404
+ * A record was opened.
405
+ *
406
+ * **This is what makes the panel useful in a builder**: clicking a log line tells the host which
407
+ * node or panel produced it, so it can reveal the offender.
408
+ */
409
+ activate(recordId) {
410
+ const record = this.records.find((r) => r.id === recordId);
411
+ if (!record) return false;
412
+ const payload = {
413
+ recordId,
414
+ source: record.source,
415
+ scope: record.scope
416
+ };
417
+ this.host.emit("recordActivate", payload);
418
+ return true;
419
+ }
420
+ /** The copyable text of a record — already redacted, because the buffer is. */
421
+ copyText(recordId) {
422
+ const record = this.records.find((r) => r.id === recordId);
423
+ return record ? formatRecord(record) : null;
424
+ }
425
+ /** The copyable text of everything currently visible. */
426
+ copyVisible() {
427
+ return this.rows().map(formatRecord).join("\n");
428
+ }
429
+ /** Ask the host to open a context menu. */
430
+ requestContextMenu(recordId, x, y) {
431
+ this.host.emit("contextMenuRequest", { recordId, x, y });
432
+ }
433
+ /** The raw buffer, for `get_records`. */
434
+ all() {
435
+ return [...this.records];
436
+ }
437
+ /* ── Persistence ───────────────────────────────────────────────────────── */
438
+ /** Serialize. Filter and toggles — **never the records**; a log is not a document. */
439
+ serialize() {
440
+ return { filter: this.filter, follow: this.follow, collapse: this.collapse };
441
+ }
442
+ /** Restore. Does not emit. */
443
+ deserialize(state) {
444
+ if (state.filter) this.filter = state.filter;
445
+ if (typeof state.follow === "boolean") this.follow = state.follow;
446
+ if (typeof state.collapse === "boolean") this.collapse = state.collapse;
447
+ this.notify();
448
+ }
449
+ /** Drop listeners and the buffer. */
450
+ dispose() {
451
+ this.listeners.clear();
452
+ this.records = [];
453
+ this.index = /* @__PURE__ */ new Map();
454
+ this.origin = 0;
455
+ }
456
+ };
457
+
458
+ // src/ops/console/manifest.ts
459
+ var CONSOLE_PANEL_ID = "xeno.core.console";
460
+ var consoleManifest = {
461
+ id: CONSOLE_PANEL_ID,
462
+ version: "0.1.0",
463
+ title: "Console",
464
+ icon: "terminal",
465
+ description: "Structured log records from multiplexed sources: severity filters, search, adjacent-duplicate collapsing, follow-tail and virtualization. Every record is redacted on the way in.",
466
+ defaultSlot: "bottom",
467
+ inputs: [
468
+ {
469
+ id: "records",
470
+ name: "Records",
471
+ type: "object",
472
+ description: "An APPEND-oriented delta: {rev?, append?, patch?, replace?, clear?, source?}. A console that re-pushes its whole buffer per line is a non-starter. `patch` updates records BY ID \u2014 the channel a line that is still being written needs; a patch for an id the buffer does not hold is dropped, never upserted.",
473
+ // Fan-in by design: multiplexing several streams into one view is the point.
474
+ multiple: true
475
+ },
476
+ {
477
+ id: "filter",
478
+ name: "Filter",
479
+ type: "object",
480
+ description: "An externally set filter {minLevel?, search?, mutedSources?}.",
481
+ multiple: false
482
+ }
483
+ ],
484
+ outputs: [
485
+ {
486
+ id: "recordActivate",
487
+ name: "Record Activated",
488
+ type: "object",
489
+ description: "{recordId, source?, scope?} \u2014 a log line was opened, so the host can reveal the node or panel that produced it. This is what makes the panel useful in a builder."
490
+ },
491
+ {
492
+ id: "contextMenuRequest",
493
+ name: "Context Menu Request",
494
+ type: "object",
495
+ description: "The host opens its own menu."
496
+ }
497
+ ],
498
+ commands: [
499
+ {
500
+ id: "get_records",
501
+ title: "Get Records",
502
+ description: "Return the buffer. Already redacted \u2014 the panel never holds an unredacted value.",
503
+ parameters: {}
504
+ },
505
+ {
506
+ id: "filter",
507
+ title: "Filter",
508
+ description: "Set the search term.",
509
+ parameters: { search: { type: "string", description: "Substring over message/source/scope" } }
510
+ },
511
+ {
512
+ id: "set_level",
513
+ title: "Set Level",
514
+ description: "Set the minimum severity. Omit to show everything.",
515
+ parameters: { level: { type: "string", description: "trace|debug|info|warn|error" } }
516
+ },
517
+ { id: "clear", title: "Clear", description: "Drop every record.", parameters: {} },
518
+ {
519
+ id: "follow_tail",
520
+ title: "Follow Tail",
521
+ description: "Stick to the newest record.",
522
+ parameters: { follow: { type: "boolean", description: "Desired state" } }
523
+ },
524
+ {
525
+ id: "reveal_source",
526
+ title: "Reveal Source",
527
+ description: "Emit recordActivate for a record, so the host can reveal its origin.",
528
+ parameters: { recordId: { type: "string", description: "Record id", required: true } }
529
+ }
530
+ ],
531
+ config: [
532
+ {
533
+ key: "maxRecords",
534
+ label: "Buffer size",
535
+ type: "number",
536
+ defaultValue: 5e3,
537
+ description: "Ring-buffer capacity. A console is a window on recent output, not an archive.",
538
+ placeholder: "5000"
539
+ },
540
+ {
541
+ key: "maxMessageChars",
542
+ label: "Max line length",
543
+ type: "number",
544
+ defaultValue: 65536,
545
+ description: "Cap on ONE record\u2019s message, so a producer streaming into a single line cannot grow without bound while the record count stays still. The newest text is kept.",
546
+ placeholder: "65536"
547
+ },
548
+ {
549
+ key: "rowHeight",
550
+ label: "Row height",
551
+ type: "number",
552
+ defaultValue: 20,
553
+ description: "Fixed \u2014 what keeps virtualization O(1). No incumbent virtualizes at all.",
554
+ placeholder: "20"
555
+ },
556
+ {
557
+ key: "collapse",
558
+ label: "Collapse duplicates",
559
+ type: "boolean",
560
+ defaultValue: true,
561
+ description: "Collapse ADJACENT identical records into one row with a count. Adjacent-run, not global \u2014 collapsing globally would hide when a recurrence happened."
562
+ },
563
+ {
564
+ key: "follow",
565
+ label: "Follow tail",
566
+ type: "boolean",
567
+ defaultValue: true,
568
+ description: "Stick to the newest record. Auto-disabled when the user scrolls up."
569
+ },
570
+ {
571
+ key: "minLevel",
572
+ label: "Minimum level",
573
+ type: "select",
574
+ options: [
575
+ { label: "Trace", value: "trace" },
576
+ { label: "Debug", value: "debug" },
577
+ { label: "Info", value: "info" },
578
+ { label: "Warn", value: "warn" },
579
+ { label: "Error", value: "error" }
580
+ ],
581
+ description: "Hide anything less severe."
582
+ },
583
+ {
584
+ key: "showTimestamps",
585
+ label: "Timestamps",
586
+ type: "boolean",
587
+ defaultValue: true,
588
+ description: "Render a time column."
589
+ },
590
+ {
591
+ key: "emptyHint",
592
+ label: "Empty hint",
593
+ type: "text",
594
+ description: "Say what would produce output, not that there is none.",
595
+ placeholder: "Run the graph to see its output here."
596
+ }
597
+ ],
598
+ capabilities: ["storage.local"],
599
+ sdk: "^1.1.0"
600
+ };
601
+
602
+ // src/ops/console/react/ConsolePanelView.tsx
603
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
604
+ import {
605
+ Badge,
606
+ EmptyState,
607
+ IconButton,
608
+ Row,
609
+ RowList,
610
+ ScrollArea,
611
+ SearchField,
612
+ SegmentedControl,
613
+ StatusBar,
614
+ Toolbar,
615
+ ToolbarGroup
616
+ } from "@xenosystem/workbench/primitives/react";
617
+ import { computeWindow } from "@xenosystem/tree-core";
618
+
619
+ // src/ops/console/react/icons.tsx
620
+ import { jsx, jsxs } from "react/jsx-runtime";
621
+ function Svg({ children }) {
622
+ return /* @__PURE__ */ jsx(
623
+ "svg",
624
+ {
625
+ width: 11,
626
+ height: 11,
627
+ viewBox: "0 0 24 24",
628
+ fill: "none",
629
+ stroke: "currentColor",
630
+ strokeWidth: "1.5",
631
+ strokeLinecap: "round",
632
+ strokeLinejoin: "round",
633
+ "aria-hidden": "true",
634
+ children
635
+ }
636
+ );
637
+ }
638
+ function CopyGlyph() {
639
+ return /* @__PURE__ */ jsxs(Svg, { children: [
640
+ /* @__PURE__ */ jsx("rect", { x: "9", y: "9", width: "12", height: "12", rx: "2" }),
641
+ /* @__PURE__ */ jsx("path", { d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" })
642
+ ] });
643
+ }
644
+ function ClearGlyph() {
645
+ return /* @__PURE__ */ jsxs(Svg, { children: [
646
+ /* @__PURE__ */ jsx("rect", { x: "3", y: "3", width: "18", height: "18", rx: "3" }),
647
+ /* @__PURE__ */ jsx("path", { d: "m6 6 12 12" })
648
+ ] });
649
+ }
650
+
651
+ // src/ops/console/react/ConsolePanelView.tsx
652
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
653
+ var TONE = {
654
+ trace: "neutral",
655
+ debug: "neutral",
656
+ info: "info",
657
+ warn: "warning",
658
+ error: "error"
659
+ };
660
+ function ConsolePanelView({
661
+ controller,
662
+ rowHeight = 20,
663
+ showTimestamps = true,
664
+ emptyHint
665
+ }) {
666
+ const state = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
667
+ const scrollRef = useRef(null);
668
+ const [viewport, setViewport] = useState({ height: 0, scrollTop: 0 });
669
+ const [expanded, setExpanded] = useState(null);
670
+ useEffect(() => {
671
+ const element = scrollRef.current;
672
+ if (!element || typeof ResizeObserver === "undefined") return;
673
+ const observer = new ResizeObserver(() => setViewport((v) => ({ ...v, height: element.clientHeight })));
674
+ observer.observe(element);
675
+ setViewport((v) => ({ ...v, height: element.clientHeight }));
676
+ return () => observer.disconnect();
677
+ }, []);
678
+ useEffect(() => {
679
+ const element = scrollRef.current;
680
+ if (!element || !state.follow) return;
681
+ element.scrollTop = element.scrollHeight;
682
+ }, [state.rows.length, state.follow]);
683
+ const onScroll = useCallback(
684
+ (event) => {
685
+ const el = event.currentTarget;
686
+ setViewport((v) => ({ ...v, scrollTop: el.scrollTop }));
687
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < rowHeight;
688
+ if (state.follow && !atBottom) controller.setFollow(false);
689
+ else if (!state.follow && atBottom) controller.setFollow(true);
690
+ },
691
+ [controller, rowHeight, state.follow]
692
+ );
693
+ const window = computeWindow({
694
+ scrollTop: viewport.scrollTop,
695
+ viewportHeight: viewport.height || rowHeight * 20,
696
+ rowHeight,
697
+ rowCount: state.rows.length
698
+ });
699
+ const slice = state.rows.slice(window.startIndex, window.endIndex);
700
+ return /* @__PURE__ */ jsxs2("div", { style: { display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }, children: [
701
+ /* @__PURE__ */ jsx2(
702
+ Toolbar,
703
+ {
704
+ left: /* @__PURE__ */ jsxs2(Fragment, { children: [
705
+ /* @__PURE__ */ jsx2(
706
+ SearchField,
707
+ {
708
+ value: state.filter.search ?? "",
709
+ onChange: (term) => controller.setSearch(term),
710
+ placeholder: "Filter\u2026"
711
+ }
712
+ ),
713
+ /* @__PURE__ */ jsx2(
714
+ SegmentedControl,
715
+ {
716
+ size: "sm",
717
+ label: "Minimum level",
718
+ options: LEVEL_ORDER.map((l) => ({ value: l, label: l.slice(0, 1).toUpperCase() })),
719
+ value: state.filter.minLevel ?? "trace",
720
+ onChange: (level) => controller.setLevel(level === "trace" ? void 0 : level)
721
+ }
722
+ )
723
+ ] }),
724
+ right: /* @__PURE__ */ jsxs2(ToolbarGroup, { end: true, children: [
725
+ state.counts.error > 0 ? /* @__PURE__ */ jsx2(Badge, { tone: "error", children: String(state.counts.error) }) : null,
726
+ state.counts.warn > 0 ? /* @__PURE__ */ jsx2(Badge, { tone: "warning", children: String(state.counts.warn) }) : null,
727
+ /* @__PURE__ */ jsx2(
728
+ IconButton,
729
+ {
730
+ icon: /* @__PURE__ */ jsx2(CopyGlyph, {}),
731
+ label: "Copy visible",
732
+ onClick: () => {
733
+ void navigator?.clipboard?.writeText(controller.copyVisible());
734
+ }
735
+ }
736
+ ),
737
+ /* @__PURE__ */ jsx2(IconButton, { icon: /* @__PURE__ */ jsx2(ClearGlyph, {}), label: "Clear", onClick: () => controller.clear() })
738
+ ] })
739
+ }
740
+ ),
741
+ state.sources.length > 1 ? /* @__PURE__ */ jsx2("div", { style: { display: "flex", gap: 4, padding: "2px 6px", flexWrap: "wrap" }, children: state.sources.map((source) => /* @__PURE__ */ jsx2(
742
+ "span",
743
+ {
744
+ onClick: () => controller.toggleSource(source.id),
745
+ style: { cursor: "pointer", opacity: source.enabled ? 1 : 0.4 },
746
+ children: /* @__PURE__ */ jsx2(Badge, { title: `${source.count} records`, children: source.label ?? source.id })
747
+ },
748
+ source.id
749
+ )) }) : null,
750
+ state.rows.length === 0 ? /* @__PURE__ */ jsx2(
751
+ EmptyState,
752
+ {
753
+ title: state.total > 0 ? "No matches" : "No output",
754
+ hint: state.total > 0 ? "Nothing matches the current filter." : emptyHint ?? "Run something to see its output here."
755
+ }
756
+ ) : /* @__PURE__ */ jsx2(ScrollArea, { children: /* @__PURE__ */ jsx2("div", { ref: scrollRef, onScroll, style: { height: "100%", overflow: "auto" }, children: /* @__PURE__ */ jsx2("div", { style: { height: window.totalHeight, position: "relative" }, children: /* @__PURE__ */ jsx2("div", { style: { transform: `translateY(${window.offsetTop}px)` }, children: /* @__PURE__ */ jsx2(RowList, { role: "log", children: slice.map((record) => /* @__PURE__ */ jsx2(
757
+ LogRow,
758
+ {
759
+ record,
760
+ controller,
761
+ rowHeight,
762
+ showTimestamps,
763
+ expanded: expanded === record.id,
764
+ onToggle: () => setExpanded(expanded === record.id ? null : record.id)
765
+ },
766
+ record.id
767
+ )) }) }) }) }) }),
768
+ /* @__PURE__ */ jsx2(
769
+ StatusBar,
770
+ {
771
+ left: `${state.rows.length} of ${state.total}` + (state.dropped > 0 ? ` \xB7 ${state.dropped} dropped` : "") + // A patch that addressed nothing is the one streaming failure with no visible symptom:
772
+ // the producer sees no line grow, and a mis-keyed wire looks exactly like a dead one.
773
+ (state.droppedPatches > 0 ? ` \xB7 ${state.droppedPatches} unmatched` : ""),
774
+ right: state.follow ? "following" : void 0
775
+ }
776
+ )
777
+ ] });
778
+ }
779
+ function LogRow({
780
+ record,
781
+ controller,
782
+ rowHeight,
783
+ showTimestamps,
784
+ expanded,
785
+ onToggle
786
+ }) {
787
+ return /* @__PURE__ */ jsxs2(Fragment, { children: [
788
+ /* @__PURE__ */ jsx2(
789
+ Row,
790
+ {
791
+ style: { height: rowHeight, alignItems: "flex-start" },
792
+ noIcon: true,
793
+ onClick: () => {
794
+ if (record.data !== void 0) onToggle();
795
+ controller.activate(record.id);
796
+ },
797
+ onContextMenu: (e) => {
798
+ e.preventDefault();
799
+ controller.requestContextMenu(record.id, e.clientX, e.clientY);
800
+ },
801
+ label: /* @__PURE__ */ jsxs2("span", { style: { display: "flex", gap: 6, alignItems: "center", minWidth: 0 }, children: [
802
+ showTimestamps ? /* @__PURE__ */ jsx2("span", { style: { opacity: 0.45, fontVariantNumeric: "tabular-nums", flex: "0 0 auto" }, children: new Date(record.ts).toISOString().slice(11, 23) }) : null,
803
+ /* @__PURE__ */ jsx2(Badge, { tone: TONE[record.level], children: record.level }),
804
+ record.source ? /* @__PURE__ */ jsx2("span", { style: { opacity: 0.55 }, children: record.source }) : null,
805
+ /* @__PURE__ */ jsx2(
806
+ "span",
807
+ {
808
+ style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 },
809
+ children: record.message
810
+ }
811
+ )
812
+ ] }),
813
+ trailing: (record.count ?? 1) > 1 ? (
814
+ // The best feature in any incumbent: a run of identical lines is one row with a count.
815
+ /* @__PURE__ */ jsx2(Badge, { title: "Repeated consecutively", children: `x${record.count}` })
816
+ ) : null
817
+ }
818
+ ),
819
+ expanded && record.data !== void 0 ? /* @__PURE__ */ jsx2(
820
+ "pre",
821
+ {
822
+ style: {
823
+ margin: 0,
824
+ padding: "2px 10px 6px 24px",
825
+ fontSize: 10,
826
+ lineHeight: 1.4,
827
+ whiteSpace: "pre-wrap",
828
+ wordBreak: "break-all",
829
+ opacity: 0.8
830
+ },
831
+ children: safeJson(record.data)
832
+ }
833
+ ) : null
834
+ ] });
835
+ }
836
+
837
+ // src/ops/console/panel.ts
838
+ function createConsolePanel(options = {}) {
839
+ return {
840
+ manifest: consoleManifest,
841
+ activate(host) {
842
+ const config = host.config ?? {};
843
+ const controller = new ConsoleController({
844
+ host: {
845
+ emit: (portId, value) => host.emit(portId, value),
846
+ getSecrets: options.getSecrets
847
+ },
848
+ maxRecords: numberConfig(config.maxRecords, 5e3),
849
+ maxMessageChars: numberConfig(config.maxMessageChars, 65536),
850
+ collapse: config.collapse !== false,
851
+ follow: config.follow !== false,
852
+ initial: {
853
+ filter: typeof config.minLevel === "string" ? { minLevel: config.minLevel } : {}
854
+ }
855
+ });
856
+ const resolve = (config2) => ({
857
+ rowHeight: numberConfig(config2.rowHeight, 20),
858
+ showTimestamps: config2.showTimestamps !== false,
859
+ emptyHint: typeof config2.emptyHint === "string" ? config2.emptyHint : void 0
860
+ });
861
+ let renderConfig = resolve(host.config ?? {});
862
+ let unrender = null;
863
+ let root = null;
864
+ let currentEl = null;
865
+ const draw = (el) => {
866
+ unrender?.();
867
+ if (options.render) {
868
+ unrender = options.render(el, { controller, config: renderConfig });
869
+ return;
870
+ }
871
+ root = createRoot(el);
872
+ root.render(createElement(ConsolePanelView, { controller, ...renderConfig }));
873
+ unrender = () => {
874
+ root?.unmount();
875
+ root = null;
876
+ };
877
+ };
878
+ const unbindConfig = bindConfig(host, (config2) => {
879
+ renderConfig = resolve(config2);
880
+ if (currentEl) draw(currentEl);
881
+ });
882
+ return {
883
+ render(el) {
884
+ currentEl = el;
885
+ draw(el);
886
+ },
887
+ onInput(portId, value) {
888
+ if (portId === "records") {
889
+ if (Array.isArray(value)) controller.apply({ append: value });
890
+ else if (isRecord(value)) controller.apply(value);
891
+ } else if (portId === "filter") {
892
+ if (isRecord(value)) controller.setFilter(value);
893
+ }
894
+ },
895
+ async onCommand(commandId, params) {
896
+ switch (commandId) {
897
+ case "get_records":
898
+ return controller.all();
899
+ case "filter":
900
+ controller.setSearch(String(params.search ?? ""));
901
+ return true;
902
+ case "set_level":
903
+ return controller.setLevel(
904
+ typeof params.level === "string" ? params.level : void 0
905
+ );
906
+ case "clear":
907
+ controller.clear();
908
+ return true;
909
+ case "follow_tail":
910
+ controller.setFollow(params.follow !== false);
911
+ return true;
912
+ case "reveal_source":
913
+ return controller.activate(String(params.recordId));
914
+ default:
915
+ return;
916
+ }
917
+ },
918
+ serialize() {
919
+ return controller.serialize();
920
+ },
921
+ deserialize(state) {
922
+ controller.deserialize(state ?? {});
923
+ },
924
+ dispose() {
925
+ unbindConfig();
926
+ currentEl = null;
927
+ unrender?.();
928
+ unrender = null;
929
+ controller.dispose();
930
+ }
931
+ };
932
+ }
933
+ };
934
+ }
935
+ function numberConfig(value, fallback) {
936
+ const n = typeof value === "number" ? value : Number(value);
937
+ return Number.isFinite(n) && n > 0 ? n : fallback;
938
+ }
939
+ var consolePanel = createConsolePanel();
940
+
941
+ // src/ops/runs/panel.ts
942
+ import { createElement as createElement2 } from "react";
943
+ import { createRoot as createRoot2 } from "react-dom/client";
944
+ import {
945
+ isRecord as isRecord2,
946
+ bindConfig as bindConfig2
947
+ } from "@xenosystem/panel-sdk";
948
+
949
+ // src/ops/runs/controller.ts
950
+ import { createThrottle } from "@xenosystem/tree-core";
951
+
952
+ // src/ops/runs/types.ts
953
+ var RUN_STATUSES = [
954
+ "requested",
955
+ "queued",
956
+ "running",
957
+ "paused",
958
+ "blocked",
959
+ "awaiting-decision",
960
+ "awaiting-permission",
961
+ "succeeded",
962
+ "failed",
963
+ "cancelled"
964
+ ];
965
+ var RUN_LIFECYCLE = {
966
+ requested: "active",
967
+ queued: "active",
968
+ running: "active",
969
+ paused: "active",
970
+ blocked: "waiting",
971
+ "awaiting-decision": "waiting",
972
+ "awaiting-permission": "waiting",
973
+ succeeded: "terminal",
974
+ failed: "terminal",
975
+ cancelled: "terminal"
976
+ };
977
+ function lifecycleOf(status) {
978
+ return RUN_LIFECYCLE[status] ?? "waiting";
979
+ }
980
+ var TERMINAL_STATUSES = new Set(
981
+ RUN_STATUSES.filter((status) => RUN_LIFECYCLE[status] === "terminal")
982
+ );
983
+ function isTerminal(status) {
984
+ return TERMINAL_STATUSES.has(status);
985
+ }
986
+ function isActive(status) {
987
+ return lifecycleOf(status) === "active";
988
+ }
989
+ function isWaiting(status) {
990
+ return lifecycleOf(status) === "waiting";
991
+ }
992
+ var RUN_PATH_SEPARATOR = "\0";
993
+ function encodeRunPath(path) {
994
+ return path.join(RUN_PATH_SEPARATOR);
995
+ }
996
+ function decodeRunPath(key) {
997
+ return key.split(RUN_PATH_SEPARATOR);
998
+ }
999
+ var DEFAULT_MAX_STEP_DEPTH = 32;
1000
+ var ALL_STATUSES = RUN_STATUSES;
1001
+ function canCancel(run) {
1002
+ if (run.canCancel !== void 0) return run.canCancel;
1003
+ return !isTerminal(run.status);
1004
+ }
1005
+ function canRetry(run) {
1006
+ if (run.canRetry !== void 0) return run.canRetry;
1007
+ if (run.status !== "failed" && run.status !== "cancelled") return false;
1008
+ return run.error?.retryable !== false;
1009
+ }
1010
+ function durationOf(run, now) {
1011
+ const from = run.executionStartedAt ?? run.startedAt;
1012
+ if (run.endedAt !== void 0) return Math.max(0, run.endedAt - from);
1013
+ if (isTerminal(run.status)) return null;
1014
+ return Math.max(0, now - from);
1015
+ }
1016
+ function queueDurationOf(run, now) {
1017
+ if (run.executionStartedAt !== void 0) {
1018
+ return Math.max(0, run.executionStartedAt - run.startedAt);
1019
+ }
1020
+ if (isTerminal(run.status)) return null;
1021
+ if (isActive(run.status) && run.status !== "requested" && run.status !== "queued") return null;
1022
+ return Math.max(0, now - run.startedAt);
1023
+ }
1024
+ function matchesSearch2(run, term) {
1025
+ if (!term) return true;
1026
+ const needle = term.toLowerCase();
1027
+ return run.label.toLowerCase().includes(needle) || (run.source ?? "").toLowerCase().includes(needle) || (run.trigger ?? "").toLowerCase().includes(needle) || (run.error?.code ?? "").toLowerCase().includes(needle);
1028
+ }
1029
+ function computeStats(runs) {
1030
+ let terminal = 0;
1031
+ let active = 0;
1032
+ let waiting = 0;
1033
+ let succeeded = 0;
1034
+ let failed = 0;
1035
+ let cancelled = 0;
1036
+ let durationSum = 0;
1037
+ let durationCount = 0;
1038
+ for (const run of runs) {
1039
+ if (isTerminal(run.status)) {
1040
+ terminal += 1;
1041
+ if (run.status === "succeeded") succeeded += 1;
1042
+ else if (run.status === "failed") failed += 1;
1043
+ else cancelled += 1;
1044
+ if (run.endedAt !== void 0) {
1045
+ durationSum += Math.max(0, run.endedAt - (run.executionStartedAt ?? run.startedAt));
1046
+ durationCount += 1;
1047
+ }
1048
+ } else if (isActive(run.status)) {
1049
+ active += 1;
1050
+ } else {
1051
+ waiting += 1;
1052
+ }
1053
+ }
1054
+ return {
1055
+ total: runs.length,
1056
+ terminal,
1057
+ active,
1058
+ waiting,
1059
+ succeeded,
1060
+ failed,
1061
+ cancelled,
1062
+ // OVER TERMINAL RUNS ONLY. Counting in-flight runs makes the rate dip every time work starts.
1063
+ successRate: terminal === 0 ? null : succeeded / terminal,
1064
+ meanDurationMs: durationCount === 0 ? null : durationSum / durationCount
1065
+ };
1066
+ }
1067
+ function formatDuration(ms) {
1068
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
1069
+ const seconds = ms / 1e3;
1070
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
1071
+ const s = Math.floor(seconds % 60);
1072
+ const m = Math.floor(seconds / 60 % 60);
1073
+ const h = Math.floor(seconds / 3600);
1074
+ const pad = (n) => String(n).padStart(2, "0");
1075
+ return h > 0 ? `${h}h ${pad(m)}m` : `${m}m ${pad(s)}s`;
1076
+ }
1077
+
1078
+ // src/ops/runs/steps.ts
1079
+ function resolveStepPath(steps, path) {
1080
+ if (!steps || path.length === 0) return null;
1081
+ let level = steps;
1082
+ let found = null;
1083
+ for (const id of path) {
1084
+ const next = level?.find((step) => step.id === id);
1085
+ if (!next) return null;
1086
+ found = next;
1087
+ level = next.steps;
1088
+ }
1089
+ return found;
1090
+ }
1091
+ function patchStepAtPath(steps, path, patch) {
1092
+ if (!steps || path.length === 0) return null;
1093
+ const index = steps.findIndex((step) => step.id === path[0]);
1094
+ if (index < 0) return null;
1095
+ const target = steps[index];
1096
+ let replacement;
1097
+ if (path.length === 1) {
1098
+ replacement = { ...target, ...patch, id: target.id };
1099
+ } else {
1100
+ const children = patchStepAtPath(target.steps, path.slice(1), patch);
1101
+ if (!children) return null;
1102
+ replacement = { ...target, steps: children };
1103
+ }
1104
+ const next = steps.slice();
1105
+ next[index] = replacement;
1106
+ return next;
1107
+ }
1108
+ function appendStepsAtPath(steps, path, incoming) {
1109
+ if (path.length === 0) return upsert(steps ?? [], incoming);
1110
+ if (!steps) return null;
1111
+ const index = steps.findIndex((step) => step.id === path[0]);
1112
+ if (index < 0) return null;
1113
+ const target = steps[index];
1114
+ const children = appendStepsAtPath(target.steps, path.slice(1), incoming);
1115
+ if (!children) return null;
1116
+ const next = steps.slice();
1117
+ next[index] = { ...target, steps: children };
1118
+ return next;
1119
+ }
1120
+ function isRunStep(value) {
1121
+ return typeof value === "object" && value !== null && typeof value.id === "string";
1122
+ }
1123
+ function upsert(existing, incoming) {
1124
+ const next = existing.slice();
1125
+ for (const step of incoming) {
1126
+ if (!isRunStep(step)) continue;
1127
+ const at = next.findIndex((candidate) => isRunStep(candidate) && candidate.id === step.id);
1128
+ if (at >= 0) next[at] = step;
1129
+ else next.push(step);
1130
+ }
1131
+ return next;
1132
+ }
1133
+ function flattenRunRows(runs, options) {
1134
+ const maxDepth = Math.max(0, options.maxDepth ?? DEFAULT_MAX_STEP_DEPTH);
1135
+ const rows = [];
1136
+ const usedKeys = /* @__PURE__ */ new Set();
1137
+ const keyFor = (path) => {
1138
+ const base = encodeRunPath(path);
1139
+ if (!usedKeys.has(base)) {
1140
+ usedKeys.add(base);
1141
+ return base;
1142
+ }
1143
+ let n = 2;
1144
+ while (usedKeys.has(`${base}#${n}`)) n += 1;
1145
+ const disambiguated = `${base}#${n}`;
1146
+ usedKeys.add(disambiguated);
1147
+ return disambiguated;
1148
+ };
1149
+ for (const run of runs) {
1150
+ const runExpanded = options.expanded.has(encodeRunPath([run.id]));
1151
+ const runHasChildren = (run.steps?.length ?? 0) > 0;
1152
+ const runTruncated = runHasChildren && maxDepth < 1 ? "depth" : void 0;
1153
+ rows.push({
1154
+ kind: "run",
1155
+ key: keyFor([run.id]),
1156
+ runId: run.id,
1157
+ run,
1158
+ depth: 0,
1159
+ stepPath: [],
1160
+ hasChildren: runHasChildren,
1161
+ expanded: runExpanded && runHasChildren && !runTruncated,
1162
+ truncated: runTruncated
1163
+ });
1164
+ if (!runHasChildren || !runExpanded || runTruncated) continue;
1165
+ const ancestors = /* @__PURE__ */ new Set();
1166
+ const walk = (steps, parentPath, depth) => {
1167
+ for (const step of steps) {
1168
+ if (!isRunStep(step)) continue;
1169
+ const stepPath = [...parentPath, step.id];
1170
+ const hasChildren = (step.steps?.length ?? 0) > 0;
1171
+ let truncated;
1172
+ if (hasChildren) {
1173
+ if (ancestors.has(step)) truncated = "cycle";
1174
+ else if (depth >= maxDepth) truncated = "depth";
1175
+ }
1176
+ const open = options.expanded.has(encodeRunPath([run.id, ...stepPath])) && hasChildren && !truncated;
1177
+ rows.push({
1178
+ kind: "step",
1179
+ key: keyFor([run.id, ...stepPath]),
1180
+ runId: run.id,
1181
+ run,
1182
+ depth,
1183
+ stepPath,
1184
+ step,
1185
+ hasChildren,
1186
+ expanded: open,
1187
+ truncated
1188
+ });
1189
+ if (open && step.steps) {
1190
+ ancestors.add(step);
1191
+ walk(step.steps, stepPath, depth + 1);
1192
+ ancestors.delete(step);
1193
+ }
1194
+ }
1195
+ };
1196
+ walk(run.steps ?? [], [], 1);
1197
+ }
1198
+ return rows;
1199
+ }
1200
+
1201
+ // src/ops/runs/controller.ts
1202
+ var RunsController = class {
1203
+ host;
1204
+ listeners = /* @__PURE__ */ new Set();
1205
+ maxRuns;
1206
+ maxDepth;
1207
+ now;
1208
+ notifyThrottled;
1209
+ /** Runs by id — patching by id is O(1), which is the whole point of the delta. */
1210
+ byId = /* @__PURE__ */ new Map();
1211
+ order = [];
1212
+ filter;
1213
+ expanded;
1214
+ rev = 0;
1215
+ dropped = 0;
1216
+ snapshot = null;
1217
+ constructor(options) {
1218
+ this.host = options.host;
1219
+ this.maxRuns = Math.max(1, options.maxRuns ?? 500);
1220
+ this.maxDepth = Math.max(0, options.maxDepth ?? DEFAULT_MAX_STEP_DEPTH);
1221
+ this.now = options.host.now ?? (() => Date.now());
1222
+ this.filter = options.initial?.filter ?? {};
1223
+ this.expanded = new Set(options.initial?.expanded ?? []);
1224
+ const throttleMs = Math.max(0, options.throttleMs ?? 100);
1225
+ this.notifyThrottled = createThrottle(
1226
+ () => this.flush(),
1227
+ throttleMs,
1228
+ options.host.clock
1229
+ );
1230
+ }
1231
+ /* ── Subscription ──────────────────────────────────────────────────────── */
1232
+ subscribe = (listener) => {
1233
+ this.listeners.add(listener);
1234
+ return () => this.listeners.delete(listener);
1235
+ };
1236
+ getState = () => {
1237
+ if (!this.snapshot) {
1238
+ const all = this.all();
1239
+ const counts = Object.fromEntries(RUN_STATUSES.map((s) => [s, 0]));
1240
+ for (const run of all) {
1241
+ counts[run.status] = (counts[run.status] ?? 0) + 1;
1242
+ }
1243
+ const runs = this.visible();
1244
+ this.snapshot = {
1245
+ runs,
1246
+ rows: flattenRunRows(runs, { expanded: this.expanded, maxDepth: this.maxDepth }),
1247
+ sources: this.sources(),
1248
+ filter: this.filter,
1249
+ counts,
1250
+ stats: computeStats(all),
1251
+ expanded: [...this.expanded],
1252
+ total: all.length,
1253
+ dropped: this.dropped
1254
+ };
1255
+ }
1256
+ return this.snapshot;
1257
+ };
1258
+ /** Invalidate and notify immediately. */
1259
+ flush() {
1260
+ this.snapshot = null;
1261
+ for (const listener of this.listeners) listener();
1262
+ }
1263
+ /** Invalidate now, notify on the throttle — the hot path. */
1264
+ notify() {
1265
+ this.snapshot = null;
1266
+ this.notifyThrottled.call();
1267
+ }
1268
+ /** Emit any pending notification immediately. Call before reading in a test. */
1269
+ flushNow() {
1270
+ this.notifyThrottled.flush();
1271
+ }
1272
+ /* ── Ingest ────────────────────────────────────────────────────────────── */
1273
+ /**
1274
+ * Apply a delta.
1275
+ *
1276
+ * @param delta - append / patch / remove / replace / clear.
1277
+ * @returns `true` if applied; `false` if dropped as stale.
1278
+ */
1279
+ apply(delta) {
1280
+ if (delta.rev !== void 0 && delta.rev <= this.rev) return false;
1281
+ if (delta.rev !== void 0) this.rev = delta.rev;
1282
+ if (delta.clear) {
1283
+ if (delta.source) {
1284
+ for (const [id, run] of [...this.byId]) {
1285
+ if (run.source === delta.source) this.byId.delete(id);
1286
+ }
1287
+ this.order = this.order.filter((id) => this.byId.has(id));
1288
+ } else {
1289
+ this.byId.clear();
1290
+ this.order = [];
1291
+ }
1292
+ this.dropped = 0;
1293
+ } else if (delta.replace) {
1294
+ if (delta.source) {
1295
+ for (const [id, run] of [...this.byId]) {
1296
+ if (run.source === delta.source) this.byId.delete(id);
1297
+ }
1298
+ this.order = this.order.filter((id) => this.byId.has(id));
1299
+ } else {
1300
+ this.byId.clear();
1301
+ this.order = [];
1302
+ }
1303
+ for (const run of delta.replace) this.insert(run);
1304
+ }
1305
+ if (delta.remove) {
1306
+ for (const id of delta.remove) this.byId.delete(id);
1307
+ this.order = this.order.filter((id) => this.byId.has(id));
1308
+ }
1309
+ if (delta.append) {
1310
+ for (const run of delta.append) this.insert(run);
1311
+ }
1312
+ if (delta.patch) {
1313
+ for (const [id, patch] of Object.entries(delta.patch)) {
1314
+ const existing = this.byId.get(id);
1315
+ if (!existing) continue;
1316
+ this.byId.set(id, { ...existing, ...patch, id });
1317
+ }
1318
+ }
1319
+ if (Array.isArray(delta.appendSteps)) {
1320
+ for (const entry of delta.appendSteps) {
1321
+ if (!entry || !Array.isArray(entry.steps)) continue;
1322
+ const path = normalizePath(entry.path);
1323
+ if (!path) continue;
1324
+ this.applyStepAppend(entry.runId, path, entry.steps);
1325
+ }
1326
+ }
1327
+ if (Array.isArray(delta.patchSteps)) {
1328
+ for (const entry of delta.patchSteps) {
1329
+ if (!entry || !entry.patch || typeof entry.patch !== "object") continue;
1330
+ const path = normalizePath(entry.path);
1331
+ if (!path) continue;
1332
+ this.applyStepPatch(entry.runId, path, entry.patch);
1333
+ }
1334
+ }
1335
+ this.trim();
1336
+ this.notify();
1337
+ return true;
1338
+ }
1339
+ insert(run) {
1340
+ if (!this.byId.has(run.id)) this.order.push(run.id);
1341
+ this.byId.set(run.id, run);
1342
+ }
1343
+ /**
1344
+ * Merge a patch into one step of one run.
1345
+ *
1346
+ * 🔴 The run object is replaced but its untouched subtrees are SHARED — `patchStepAtPath` copies
1347
+ * only the spine. This is the whole reason per-step addressing exists: the alternative a host had
1348
+ * was resending `steps` wholesale on every tick, which is the array-copy storm the delta was
1349
+ * designed to prevent, reintroduced one level down.
1350
+ *
1351
+ * @returns `true` when the address resolved and the patch landed.
1352
+ */
1353
+ applyStepPatch(runId, path, patch) {
1354
+ const run = this.byId.get(runId);
1355
+ if (!run || path.length === 0) return false;
1356
+ if (path.length > this.maxDepth) return false;
1357
+ const steps = patchStepAtPath(run.steps, path, patch);
1358
+ if (!steps) return false;
1359
+ this.byId.set(runId, { ...run, steps });
1360
+ return true;
1361
+ }
1362
+ /**
1363
+ * Add steps to a run, or to a step inside one.
1364
+ *
1365
+ * An unresolvable PARENT is refused rather than created, for the same reason a patch for an
1366
+ * unknown run is: a parent conjured from an append has no label and no status, so it renders as
1367
+ * a row that will never resolve into anything.
1368
+ *
1369
+ * @returns `true` when the parent resolved and the steps landed.
1370
+ */
1371
+ applyStepAppend(runId, path, incoming) {
1372
+ const run = this.byId.get(runId);
1373
+ if (!run || incoming.length === 0) return false;
1374
+ if (path.length >= this.maxDepth) return false;
1375
+ const steps = appendStepsAtPath(run.steps, path, incoming);
1376
+ if (!steps) return false;
1377
+ this.byId.set(runId, { ...run, steps });
1378
+ return true;
1379
+ }
1380
+ /** Ring-buffer the OLDEST runs away, never the newest. */
1381
+ trim() {
1382
+ if (this.order.length <= this.maxRuns) return;
1383
+ const sorted = this.all();
1384
+ const keep = new Set(sorted.slice(0, this.maxRuns).map((r) => r.id));
1385
+ let removed = 0;
1386
+ for (const id of [...this.byId.keys()]) {
1387
+ if (!keep.has(id)) {
1388
+ this.byId.delete(id);
1389
+ removed += 1;
1390
+ }
1391
+ }
1392
+ this.order = this.order.filter((id) => this.byId.has(id));
1393
+ this.dropped += removed;
1394
+ }
1395
+ /** Every run, newest first. */
1396
+ all() {
1397
+ return this.order.map((id) => this.byId.get(id)).filter((r) => !!r).sort((a, b) => b.startedAt - a.startedAt || (a.id < b.id ? -1 : 1));
1398
+ }
1399
+ /* ── Filtering ─────────────────────────────────────────────────────────── */
1400
+ /** Replace the filter. */
1401
+ setFilter(filter) {
1402
+ this.filter = filter;
1403
+ this.flush();
1404
+ }
1405
+ /** Set the search term. */
1406
+ setSearch(search) {
1407
+ this.filter = { ...this.filter, search };
1408
+ this.flush();
1409
+ }
1410
+ /**
1411
+ * Show only these statuses. Empty/absent shows everything.
1412
+ *
1413
+ * **A dead-letter view is `statuses: ['failed']` plus a search — not a second panel.** That is
1414
+ * exactly how one incumbent ended up maintaining two panels for one archetype.
1415
+ */
1416
+ setStatuses(statuses) {
1417
+ this.filter = { ...this.filter, statuses: [...statuses] };
1418
+ this.flush();
1419
+ }
1420
+ /** Show or hide one source. */
1421
+ toggleSource(source, enabled) {
1422
+ const muted = new Set(this.filter.mutedSources ?? []);
1423
+ const next = enabled ?? muted.has(source);
1424
+ if (next) muted.delete(source);
1425
+ else muted.add(source);
1426
+ this.filter = { ...this.filter, mutedSources: [...muted] };
1427
+ this.flush();
1428
+ }
1429
+ /** The visible runs, newest first. */
1430
+ visible() {
1431
+ const statuses = this.filter.statuses;
1432
+ const wanted = statuses && statuses.length > 0 ? new Set(statuses) : null;
1433
+ const muted = new Set(this.filter.mutedSources ?? []);
1434
+ const search = (this.filter.search ?? "").trim();
1435
+ return this.all().filter(
1436
+ (run) => (!wanted || wanted.has(run.status)) && !muted.has(run.source ?? "") && matchesSearch2(run, search)
1437
+ );
1438
+ }
1439
+ /** Every source seen. */
1440
+ sources() {
1441
+ const muted = new Set(this.filter.mutedSources ?? []);
1442
+ const counts = /* @__PURE__ */ new Map();
1443
+ for (const run of this.byId.values()) {
1444
+ const id = run.source ?? "";
1445
+ counts.set(id, (counts.get(id) ?? 0) + 1);
1446
+ }
1447
+ return [...counts.entries()].map(([id, count]) => ({
1448
+ id,
1449
+ label: id === "" ? "(unsourced)" : id,
1450
+ enabled: !muted.has(id),
1451
+ count
1452
+ }));
1453
+ }
1454
+ /* ── Intents ───────────────────────────────────────────────────────────── */
1455
+ /**
1456
+ * Ask the host to cancel a run.
1457
+ *
1458
+ * Refuses a run that cannot be cancelled, and **does not flip the status locally**: the run
1459
+ * becomes `cancelled` when the host says so, not when the panel asks.
1460
+ */
1461
+ cancel(runId, step) {
1462
+ const run = this.byId.get(runId);
1463
+ if (!run || !canCancel(run)) return false;
1464
+ this.host.emit("action", { runId, action: "cancel", ...targetOf(step) });
1465
+ return true;
1466
+ }
1467
+ /** Ask the host to retry a run. */
1468
+ retry(runId, step) {
1469
+ const run = this.byId.get(runId);
1470
+ if (!run || !canRetry(run)) return false;
1471
+ this.host.emit("action", { runId, action: "retry", ...targetOf(step) });
1472
+ return true;
1473
+ }
1474
+ /**
1475
+ * Clear TERMINAL runs from the view.
1476
+ *
1477
+ * Only terminal ones: clearing a running download would remove the row while the transfer
1478
+ * continues, and the user would have no way back to it.
1479
+ *
1480
+ * @returns How many were removed.
1481
+ */
1482
+ clearTerminal() {
1483
+ let removed = 0;
1484
+ for (const [id, run] of [...this.byId]) {
1485
+ if (isTerminal(run.status)) {
1486
+ this.byId.delete(id);
1487
+ removed += 1;
1488
+ }
1489
+ }
1490
+ if (removed > 0) {
1491
+ this.order = this.order.filter((id) => this.byId.has(id));
1492
+ this.host.emit("action", { runId: "", action: "clear" });
1493
+ this.flush();
1494
+ }
1495
+ return removed;
1496
+ }
1497
+ /** A run was opened — the reveal hook. */
1498
+ activate(runId, step) {
1499
+ const run = this.byId.get(runId);
1500
+ if (!run) return false;
1501
+ this.host.emit("runActivate", {
1502
+ runId,
1503
+ source: run.source,
1504
+ ...targetOf(step)
1505
+ });
1506
+ return true;
1507
+ }
1508
+ /**
1509
+ * Expand or collapse a run, or one step inside it.
1510
+ *
1511
+ * @param runId - The run.
1512
+ * @param stepPath - Address of a step within it. Absent ⇒ the run's own row.
1513
+ * @returns `true` when the target existed and its state flipped.
1514
+ *
1515
+ * ⚠️ The key for a run is its bare id, so a `RunsPanelState` written before step expansion
1516
+ * existed restores unchanged — those stored strings ARE the encoded run-level paths.
1517
+ *
1518
+ * 🔴 A step path is resolved before it is stored. An address that names nothing would otherwise
1519
+ * accumulate in a set that is serialized, so a host with a churning step tree would grow its
1520
+ * persisted state without bound and never be able to tell which entries still meant anything.
1521
+ */
1522
+ toggleExpanded(runId, stepPath) {
1523
+ const run = this.byId.get(runId);
1524
+ if (!run) return false;
1525
+ if (stepPath && stepPath.length > 0 && !resolveStepPath(run.steps, stepPath)) return false;
1526
+ const key = encodeRunPath([runId, ...stepPath ?? []]);
1527
+ if (this.expanded.has(key)) this.expanded.delete(key);
1528
+ else this.expanded.add(key);
1529
+ this.flush();
1530
+ return true;
1531
+ }
1532
+ /** Ask the host to open a context menu. */
1533
+ requestContextMenu(runId, x, y) {
1534
+ this.host.emit("contextMenuRequest", { runId, x, y });
1535
+ }
1536
+ /* ── Persistence ───────────────────────────────────────────────────────── */
1537
+ /** Serialize. Filter and expansion — **never the runs**; an execution log is not a document. */
1538
+ serialize() {
1539
+ return { filter: this.filter, expanded: [...this.expanded] };
1540
+ }
1541
+ /** Restore. Does not emit. */
1542
+ deserialize(state) {
1543
+ if (state.filter) this.filter = state.filter;
1544
+ if (state.expanded) this.expanded = new Set(state.expanded);
1545
+ this.flush();
1546
+ }
1547
+ /** Drop listeners, timers and the buffer. */
1548
+ dispose() {
1549
+ this.notifyThrottled.cancel();
1550
+ this.listeners.clear();
1551
+ this.byId.clear();
1552
+ this.order = [];
1553
+ }
1554
+ };
1555
+ function normalizePath(path) {
1556
+ if (path === void 0 || path === null) return [];
1557
+ if (!Array.isArray(path)) return null;
1558
+ return path.every((id) => typeof id === "string") ? path : null;
1559
+ }
1560
+ function targetOf(step) {
1561
+ if (step === void 0) return {};
1562
+ const path = typeof step === "string" ? [step] : [...step];
1563
+ if (path.length === 0) return {};
1564
+ return { stepId: path[path.length - 1], stepPath: path };
1565
+ }
1566
+
1567
+ // src/ops/runs/manifest.ts
1568
+ var RUNS_PANEL_ID = "xeno.core.runs";
1569
+ var runsManifest = {
1570
+ id: RUNS_PANEL_ID,
1571
+ // 0.2.0: steps became a TREE, the status union widened, and a step became addressable in a
1572
+ // delta. Every change is additive — a 0.1.0 host that sets none of it behaves identically.
1573
+ version: "0.2.0",
1574
+ title: "Runs",
1575
+ icon: "play-circle",
1576
+ description: "Ordered executions with lifecycle state, progress and per-item actions. Not an undo stack (that is history) and not document snapshots (that is versions).",
1577
+ defaultSlot: "bottom",
1578
+ inputs: [
1579
+ {
1580
+ id: "runs",
1581
+ name: "Runs",
1582
+ type: "object",
1583
+ description: "A delta: {rev?, append?, patch?, appendSteps?, patchSteps?, remove?, replace?, clear?, source?}. `patch` addresses runs BY ID and `patchSteps` addresses one step by PATH ({runId, path: string[], patch}) \u2014 re-pushing the whole list per progress tick, or a run\u2019s whole `steps` array per step tick, is the failure mode this exists to avoid.",
1584
+ // Fan-in: several producers (graph execution, downloads, generation queues) in one view.
1585
+ multiple: true
1586
+ },
1587
+ {
1588
+ id: "filter",
1589
+ name: "Filter",
1590
+ type: "object",
1591
+ description: "An externally set filter {statuses?, mutedSources?, search?}.",
1592
+ multiple: false
1593
+ }
1594
+ ],
1595
+ outputs: [
1596
+ {
1597
+ id: "action",
1598
+ name: "Action",
1599
+ type: "object",
1600
+ description: "{runId, action: cancel|retry|clear, stepId?, stepPath?} \u2014 an INTENT. The panel never executes or cancels anything, and never optimistically flips a status. `stepId` is the last element of `stepPath`; a nested step needs the path, because one step id may occur under several parents."
1601
+ },
1602
+ {
1603
+ id: "runActivate",
1604
+ name: "Run Activated",
1605
+ type: "object",
1606
+ description: "{runId, source?, stepId?, stepPath?} \u2014 a run or one of its steps was opened, so the host can reveal the node, panel or file behind it."
1607
+ },
1608
+ {
1609
+ id: "contextMenuRequest",
1610
+ name: "Context Menu Request",
1611
+ type: "object",
1612
+ description: "The host opens its own menu."
1613
+ }
1614
+ ],
1615
+ commands: [
1616
+ { id: "get_runs", title: "Get Runs", description: "Return every held run, newest first.", parameters: {} },
1617
+ {
1618
+ id: "get_stats",
1619
+ title: "Get Stats",
1620
+ description: "Aggregates. successRate is computed over TERMINAL runs only, and is null when none have finished.",
1621
+ parameters: {}
1622
+ },
1623
+ {
1624
+ id: "filter_runs",
1625
+ title: "Filter",
1626
+ description: "Set the search term.",
1627
+ parameters: { search: { type: "string", description: "Substring over label/source/trigger/error code" } }
1628
+ },
1629
+ {
1630
+ id: "set_statuses",
1631
+ title: "Set Statuses",
1632
+ description: 'Show only these statuses. A dead-letter view is ["failed"] \u2014 not a separate panel.',
1633
+ parameters: { statuses: { type: "array", description: "XenoRunStatus[]; empty shows all", required: true } }
1634
+ },
1635
+ {
1636
+ id: "cancel_run",
1637
+ title: "Cancel Run",
1638
+ description: "Ask the host to cancel. Refused for a terminal run.",
1639
+ parameters: {
1640
+ runId: { type: "string", description: "Run id", required: true },
1641
+ stepId: { type: "string", description: "Target one TOP-LEVEL step. Ambiguous once steps nest \u2014 use stepPath." },
1642
+ stepPath: { type: "array", description: "Step ids from the run down to the target. Wins over stepId when both are given." }
1643
+ }
1644
+ },
1645
+ {
1646
+ id: "retry_run",
1647
+ title: "Retry Run",
1648
+ description: "Ask the host to retry. Refused when the error says it is not retryable.",
1649
+ parameters: {
1650
+ runId: { type: "string", description: "Run id", required: true },
1651
+ stepId: { type: "string", description: "Target one TOP-LEVEL step. Ambiguous once steps nest \u2014 use stepPath." },
1652
+ stepPath: { type: "array", description: "Step ids from the run down to the target. Wins over stepId when both are given." }
1653
+ }
1654
+ },
1655
+ {
1656
+ id: "clear_finished",
1657
+ title: "Clear Finished",
1658
+ description: "Remove TERMINAL runs from the view. Never removes a running one \u2014 the work would continue with no row.",
1659
+ parameters: {}
1660
+ },
1661
+ {
1662
+ id: "reveal_run",
1663
+ title: "Reveal Run",
1664
+ description: "Emit runActivate so the host can reveal the origin.",
1665
+ parameters: { runId: { type: "string", description: "Run id", required: true } }
1666
+ }
1667
+ ],
1668
+ config: [
1669
+ {
1670
+ key: "maxRuns",
1671
+ label: "Buffer size",
1672
+ type: "number",
1673
+ defaultValue: 500,
1674
+ description: "Ring capacity; the oldest are dropped. Recent activity, not an archive.",
1675
+ placeholder: "500"
1676
+ },
1677
+ {
1678
+ key: "throttleMs",
1679
+ label: "Update throttle",
1680
+ type: "number",
1681
+ defaultValue: 100,
1682
+ description: "Minimum ms between re-projections. Twenty rows patching at 60 Hz is 1 200 re-filters a second without it.",
1683
+ placeholder: "100"
1684
+ },
1685
+ {
1686
+ key: "maxDepth",
1687
+ label: "Nesting depth",
1688
+ type: "number",
1689
+ defaultValue: 32,
1690
+ description: "How deep below a run the step tree is rendered. A cut subtree is marked as cut, never silently shown as a leaf.",
1691
+ placeholder: "32"
1692
+ },
1693
+ {
1694
+ key: "rowHeight",
1695
+ label: "Row height",
1696
+ type: "number",
1697
+ defaultValue: 24,
1698
+ description: "Fixed \u2014 what keeps virtualization O(1).",
1699
+ placeholder: "24"
1700
+ },
1701
+ {
1702
+ key: "showStats",
1703
+ label: "Show summary",
1704
+ type: "boolean",
1705
+ defaultValue: true,
1706
+ description: "Render the success-rate and mean-duration strip."
1707
+ },
1708
+ {
1709
+ key: "showProgress",
1710
+ label: "Show progress bars",
1711
+ type: "boolean",
1712
+ defaultValue: true,
1713
+ description: "Render a bar for runs that report progress."
1714
+ },
1715
+ {
1716
+ key: "defaultStatuses",
1717
+ label: "Opening filter",
1718
+ type: "json",
1719
+ description: 'Statuses shown on mount. ["failed"] makes this a dead-letter queue.',
1720
+ placeholder: '["failed"]'
1721
+ },
1722
+ {
1723
+ key: "emptyHint",
1724
+ label: "Empty hint",
1725
+ type: "text",
1726
+ description: "Say what would produce a run, not that there are none.",
1727
+ placeholder: "Run the workflow to see its executions here."
1728
+ }
1729
+ ],
1730
+ capabilities: ["storage.local"],
1731
+ sdk: "^1.1.0"
1732
+ };
1733
+
1734
+ // src/ops/runs/react/RunsPanelView.tsx
1735
+ import { useSyncExternalStore as useSyncExternalStore2 } from "react";
1736
+ import {
1737
+ Badge as Badge2,
1738
+ EmptyState as EmptyState2,
1739
+ IconButton as IconButton2,
1740
+ ProportionBar,
1741
+ Row as Row2,
1742
+ RowList as RowList2,
1743
+ ScrollArea as ScrollArea2,
1744
+ SearchField as SearchField2,
1745
+ StatusBar as StatusBar2,
1746
+ TextButton,
1747
+ Toolbar as Toolbar2,
1748
+ ToolbarGroup as ToolbarGroup2
1749
+ } from "@xenosystem/workbench/primitives/react";
1750
+
1751
+ // src/ops/runs/react/icons.tsx
1752
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1753
+ function Svg2({ children }) {
1754
+ return /* @__PURE__ */ jsx3(
1755
+ "svg",
1756
+ {
1757
+ width: 11,
1758
+ height: 11,
1759
+ viewBox: "0 0 24 24",
1760
+ fill: "none",
1761
+ stroke: "currentColor",
1762
+ strokeWidth: "1.5",
1763
+ strokeLinecap: "round",
1764
+ strokeLinejoin: "round",
1765
+ "aria-hidden": "true",
1766
+ children
1767
+ }
1768
+ );
1769
+ }
1770
+ function CancelGlyph() {
1771
+ return /* @__PURE__ */ jsx3(Svg2, { children: /* @__PURE__ */ jsx3("rect", { x: "5", y: "5", width: "14", height: "14", rx: "2" }) });
1772
+ }
1773
+ function RetryGlyph() {
1774
+ return /* @__PURE__ */ jsxs3(Svg2, { children: [
1775
+ /* @__PURE__ */ jsx3("path", { d: "M21 12a9 9 0 1 1-3.36-7" }),
1776
+ /* @__PURE__ */ jsx3("path", { d: "M21 3v6h-6" })
1777
+ ] });
1778
+ }
1779
+
1780
+ // src/ops/runs/react/RunsPanelView.tsx
1781
+ import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1782
+ var TONE2 = {
1783
+ requested: "neutral",
1784
+ queued: "neutral",
1785
+ running: "info",
1786
+ paused: "warning",
1787
+ blocked: "warning",
1788
+ "awaiting-decision": "warning",
1789
+ "awaiting-permission": "warning",
1790
+ succeeded: "success",
1791
+ failed: "error",
1792
+ cancelled: "neutral"
1793
+ };
1794
+ function toneOf(status) {
1795
+ return TONE2[status] ?? "neutral";
1796
+ }
1797
+ function RunsPanelView({
1798
+ controller,
1799
+ showStats = true,
1800
+ showProgress = true,
1801
+ emptyHint,
1802
+ now = () => Date.now()
1803
+ }) {
1804
+ const state = useSyncExternalStore2(controller.subscribe, controller.getState, controller.getState);
1805
+ const active = new Set(state.filter.statuses ?? []);
1806
+ return /* @__PURE__ */ jsxs4("div", { style: { display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }, children: [
1807
+ /* @__PURE__ */ jsx4(
1808
+ Toolbar2,
1809
+ {
1810
+ left: /* @__PURE__ */ jsx4(
1811
+ SearchField2,
1812
+ {
1813
+ value: state.filter.search ?? "",
1814
+ onChange: (term) => controller.setSearch(term),
1815
+ placeholder: "Filter runs\u2026"
1816
+ }
1817
+ ),
1818
+ right: /* @__PURE__ */ jsx4(ToolbarGroup2, { end: true, children: /* @__PURE__ */ jsx4(TextButton, { onClick: () => controller.clearTerminal(), children: "Clear finished" }) })
1819
+ }
1820
+ ),
1821
+ /* @__PURE__ */ jsxs4("div", { style: { display: "flex", gap: 4, padding: "2px 6px", flexWrap: "wrap" }, children: [
1822
+ ALL_STATUSES.filter((s) => state.counts[s] > 0).map((status) => /* @__PURE__ */ jsx4(
1823
+ "span",
1824
+ {
1825
+ onClick: () => controller.setStatuses(
1826
+ active.has(status) ? [...active].filter((s) => s !== status) : [...active, status]
1827
+ ),
1828
+ style: { cursor: "pointer", opacity: active.size === 0 || active.has(status) ? 1 : 0.4 },
1829
+ children: /* @__PURE__ */ jsx4(Badge2, { tone: TONE2[status], children: `${status} ${state.counts[status]}` })
1830
+ },
1831
+ status
1832
+ )),
1833
+ state.sources.length > 1 ? state.sources.map((source) => /* @__PURE__ */ jsx4(
1834
+ "span",
1835
+ {
1836
+ onClick: () => controller.toggleSource(source.id),
1837
+ style: { cursor: "pointer", opacity: source.enabled ? 1 : 0.4 },
1838
+ children: /* @__PURE__ */ jsx4(Badge2, { title: `${source.count} runs`, children: source.label })
1839
+ },
1840
+ source.id
1841
+ )) : null
1842
+ ] }),
1843
+ state.rows.length === 0 ? /* @__PURE__ */ jsx4(
1844
+ EmptyState2,
1845
+ {
1846
+ title: state.total > 0 ? "No matching runs" : "No runs yet",
1847
+ hint: state.total > 0 ? "Nothing matches the current filter." : emptyHint ?? "Run something to see it here."
1848
+ }
1849
+ ) : /* @__PURE__ */ jsx4(ScrollArea2, { children: /* @__PURE__ */ jsx4(RowList2, { role: "list", children: state.rows.map(
1850
+ (row) => row.kind === "run" ? /* @__PURE__ */ jsx4(RunRow, { row, controller, showProgress, now }, row.key) : /* @__PURE__ */ jsx4(StepRow, { row, controller, showProgress }, row.key)
1851
+ ) }) }),
1852
+ /* @__PURE__ */ jsx4(
1853
+ StatusBar2,
1854
+ {
1855
+ left: `${state.runs.length} of ${state.total}${state.dropped > 0 ? ` \xB7 ${state.dropped} dropped` : ""}`,
1856
+ right: showStats && state.stats.terminal > 0 ? `${Math.round((state.stats.successRate ?? 0) * 100)}% ok${state.stats.meanDurationMs !== null ? ` \xB7 ~${formatDuration(state.stats.meanDurationMs)}` : ""}` : (
1857
+ // `null` success rate is NOT 0% — nothing has finished, which is a different fact.
1858
+ state.stats.active > 0 ? `${state.stats.active} active` : void 0
1859
+ )
1860
+ }
1861
+ )
1862
+ ] });
1863
+ }
1864
+ function TruncationBadge({ reason }) {
1865
+ return /* @__PURE__ */ jsx4(
1866
+ Badge2,
1867
+ {
1868
+ tone: "warning",
1869
+ title: reason === "cycle" ? "This step is nested inside itself \u2014 the panel stopped here rather than repeat it." : "Nesting is deeper than this panel will render.",
1870
+ children: reason === "cycle" ? "cycle" : "deeper\u2026"
1871
+ }
1872
+ );
1873
+ }
1874
+ function RowLabel({
1875
+ status,
1876
+ text,
1877
+ trailing
1878
+ }) {
1879
+ return /* @__PURE__ */ jsxs4("span", { style: { display: "flex", gap: 6, alignItems: "center", minWidth: 0 }, children: [
1880
+ /* @__PURE__ */ jsx4(Badge2, { tone: toneOf(status), children: status }),
1881
+ /* @__PURE__ */ jsx4("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: text }),
1882
+ trailing
1883
+ ] });
1884
+ }
1885
+ function RunRow({
1886
+ row,
1887
+ controller,
1888
+ showProgress,
1889
+ now
1890
+ }) {
1891
+ const run = row.run;
1892
+ const duration = durationOf(run, now());
1893
+ return /* @__PURE__ */ jsxs4(Fragment2, { children: [
1894
+ /* @__PURE__ */ jsx4(
1895
+ Row2,
1896
+ {
1897
+ noIcon: true,
1898
+ onClick: () => {
1899
+ if (row.hasChildren) controller.toggleExpanded(run.id);
1900
+ controller.activate(run.id);
1901
+ },
1902
+ onContextMenu: (e) => {
1903
+ e.preventDefault();
1904
+ controller.requestContextMenu(run.id, e.clientX, e.clientY);
1905
+ },
1906
+ label: /* @__PURE__ */ jsx4(
1907
+ RowLabel,
1908
+ {
1909
+ status: run.status,
1910
+ text: run.label,
1911
+ trailing: /* @__PURE__ */ jsxs4(Fragment2, { children: [
1912
+ run.trigger ? /* @__PURE__ */ jsx4("span", { style: { opacity: 0.5, fontSize: 9 }, children: run.trigger }) : null,
1913
+ (run.retryCount ?? 0) > 0 ? /* @__PURE__ */ jsx4(Badge2, { title: "Retries so far", children: `retry x${run.retryCount}` }) : null,
1914
+ row.truncated ? /* @__PURE__ */ jsx4(TruncationBadge, { reason: row.truncated }) : null
1915
+ ] })
1916
+ }
1917
+ ),
1918
+ meta: showProgress && run.progress !== void 0 ? /* @__PURE__ */ jsx4("span", { style: { width: 48, display: "inline-flex" }, children: /* @__PURE__ */ jsx4(
1919
+ ProportionBar,
1920
+ {
1921
+ label: `${Math.round(run.progress * 100)}%`,
1922
+ segments: [
1923
+ { value: run.progress, opacity: 0.45 },
1924
+ { value: Math.max(0, 1 - run.progress), opacity: 0.06 }
1925
+ ]
1926
+ }
1927
+ ) }) : duration !== null ? /* @__PURE__ */ jsx4("span", { style: { fontVariantNumeric: "tabular-nums", opacity: 0.6 }, children: formatDuration(duration) }) : null,
1928
+ trailing: /* @__PURE__ */ jsxs4(Fragment2, { children: [
1929
+ canCancel(run) ? /* @__PURE__ */ jsx4(
1930
+ IconButton2,
1931
+ {
1932
+ size: "sm",
1933
+ icon: /* @__PURE__ */ jsx4(CancelGlyph, {}),
1934
+ label: `Cancel ${run.label}`,
1935
+ onClick: () => controller.cancel(run.id)
1936
+ }
1937
+ ) : null,
1938
+ canRetry(run) ? /* @__PURE__ */ jsx4(
1939
+ IconButton2,
1940
+ {
1941
+ size: "sm",
1942
+ icon: /* @__PURE__ */ jsx4(RetryGlyph, {}),
1943
+ label: `Retry ${run.label}`,
1944
+ onClick: () => controller.retry(run.id)
1945
+ }
1946
+ ) : null
1947
+ ] })
1948
+ }
1949
+ ),
1950
+ run.error ? /* @__PURE__ */ jsxs4("div", { style: { padding: "0 10px 3px 24px", fontSize: 10, opacity: 0.75 }, children: [
1951
+ /* @__PURE__ */ jsx4(Badge2, { tone: "error", children: run.error.code }),
1952
+ " ",
1953
+ run.error.message
1954
+ ] }) : null
1955
+ ] });
1956
+ }
1957
+ function StepRow({
1958
+ row,
1959
+ controller,
1960
+ showProgress
1961
+ }) {
1962
+ const step = row.step;
1963
+ const elapsed = step.startedAt !== void 0 && step.endedAt !== void 0 ? step.endedAt - step.startedAt : null;
1964
+ return /* @__PURE__ */ jsx4(
1965
+ Row2,
1966
+ {
1967
+ noIcon: true,
1968
+ style: { paddingLeft: 12 + row.depth * 12, opacity: 0.85 },
1969
+ onClick: () => {
1970
+ if (row.hasChildren) controller.toggleExpanded(row.runId, row.stepPath);
1971
+ controller.activate(row.runId, row.stepPath);
1972
+ },
1973
+ label: /* @__PURE__ */ jsx4(
1974
+ RowLabel,
1975
+ {
1976
+ status: step.status,
1977
+ text: step.label,
1978
+ trailing: row.truncated ? /* @__PURE__ */ jsx4(TruncationBadge, { reason: row.truncated }) : null
1979
+ }
1980
+ ),
1981
+ meta: showProgress && step.progress !== void 0 ? /* @__PURE__ */ jsx4("span", { style: { width: 48, display: "inline-flex" }, children: /* @__PURE__ */ jsx4(
1982
+ ProportionBar,
1983
+ {
1984
+ label: `${Math.round(step.progress * 100)}%`,
1985
+ segments: [
1986
+ { value: step.progress, opacity: 0.45 },
1987
+ { value: Math.max(0, 1 - step.progress), opacity: 0.06 }
1988
+ ]
1989
+ }
1990
+ ) }) : elapsed !== null ? /* @__PURE__ */ jsx4("span", { style: { fontVariantNumeric: "tabular-nums", opacity: 0.6 }, children: formatDuration(elapsed) }) : null
1991
+ }
1992
+ );
1993
+ }
1994
+
1995
+ // src/ops/runs/panel.ts
1996
+ function createRunsPanel(options = {}) {
1997
+ return {
1998
+ manifest: runsManifest,
1999
+ activate(host) {
2000
+ const config = host.config ?? {};
2001
+ const defaultStatuses = parseJson(config.defaultStatuses);
2002
+ const controller = new RunsController({
2003
+ host: { emit: (portId, value) => host.emit(portId, value) },
2004
+ maxRuns: numberConfig2(config.maxRuns, 500),
2005
+ throttleMs: numberConfig2(config.throttleMs, 100, true),
2006
+ maxDepth: numberConfig2(config.maxDepth, 32),
2007
+ initial: { filter: defaultStatuses ? { statuses: defaultStatuses } : {}, expanded: [] }
2008
+ });
2009
+ const resolve = (config2) => ({
2010
+ showStats: config2.showStats !== false,
2011
+ showProgress: config2.showProgress !== false,
2012
+ rowHeight: numberConfig2(config2.rowHeight, 24),
2013
+ emptyHint: typeof config2.emptyHint === "string" ? config2.emptyHint : void 0
2014
+ });
2015
+ let renderConfig = resolve(host.config ?? {});
2016
+ let unrender = null;
2017
+ let root = null;
2018
+ let currentEl = null;
2019
+ const draw = (el) => {
2020
+ unrender?.();
2021
+ if (options.render) {
2022
+ unrender = options.render(el, { controller, config: renderConfig });
2023
+ return;
2024
+ }
2025
+ root = createRoot2(el);
2026
+ root.render(
2027
+ createElement2(RunsPanelView, {
2028
+ controller,
2029
+ showStats: renderConfig.showStats,
2030
+ showProgress: renderConfig.showProgress,
2031
+ emptyHint: renderConfig.emptyHint
2032
+ })
2033
+ );
2034
+ unrender = () => {
2035
+ root?.unmount();
2036
+ root = null;
2037
+ };
2038
+ };
2039
+ const unbindConfig = bindConfig2(host, (config2) => {
2040
+ renderConfig = resolve(config2);
2041
+ if (currentEl) draw(currentEl);
2042
+ });
2043
+ return {
2044
+ render(el) {
2045
+ currentEl = el;
2046
+ draw(el);
2047
+ },
2048
+ onInput(portId, value) {
2049
+ if (portId === "runs") {
2050
+ if (Array.isArray(value)) controller.apply({ append: value });
2051
+ else if (isRecord2(value)) controller.apply(value);
2052
+ } else if (portId === "filter") {
2053
+ if (isRecord2(value)) controller.setFilter(value);
2054
+ }
2055
+ },
2056
+ async onCommand(commandId, params) {
2057
+ switch (commandId) {
2058
+ case "get_runs":
2059
+ controller.flushNow();
2060
+ return controller.all();
2061
+ case "get_stats":
2062
+ controller.flushNow();
2063
+ return controller.getState().stats;
2064
+ case "filter_runs":
2065
+ controller.setSearch(String(params.search ?? ""));
2066
+ return true;
2067
+ case "set_statuses":
2068
+ controller.setStatuses(
2069
+ Array.isArray(params.statuses) ? params.statuses : []
2070
+ );
2071
+ return true;
2072
+ case "cancel_run":
2073
+ return controller.cancel(String(params.runId), stepTarget(params));
2074
+ case "retry_run":
2075
+ return controller.retry(String(params.runId), stepTarget(params));
2076
+ case "clear_finished":
2077
+ return controller.clearTerminal();
2078
+ case "reveal_run":
2079
+ return controller.activate(String(params.runId));
2080
+ default:
2081
+ return;
2082
+ }
2083
+ },
2084
+ serialize() {
2085
+ return controller.serialize();
2086
+ },
2087
+ deserialize(state) {
2088
+ controller.deserialize(state ?? {});
2089
+ },
2090
+ dispose() {
2091
+ unbindConfig();
2092
+ currentEl = null;
2093
+ unrender?.();
2094
+ unrender = null;
2095
+ controller.dispose();
2096
+ }
2097
+ };
2098
+ }
2099
+ };
2100
+ }
2101
+ function stepTarget(params) {
2102
+ if (Array.isArray(params.stepPath)) {
2103
+ const path = params.stepPath.filter((s) => typeof s === "string");
2104
+ if (path.length > 0) return path;
2105
+ }
2106
+ return typeof params.stepId === "string" ? params.stepId : void 0;
2107
+ }
2108
+ function parseJson(value) {
2109
+ if (value === void 0 || value === null) return void 0;
2110
+ if (typeof value !== "string") return value;
2111
+ try {
2112
+ return JSON.parse(value);
2113
+ } catch {
2114
+ return void 0;
2115
+ }
2116
+ }
2117
+ function numberConfig2(value, fallback, allowZero = false) {
2118
+ const n = typeof value === "number" ? value : Number(value);
2119
+ if (!Number.isFinite(n)) return fallback;
2120
+ return allowZero ? Math.max(0, n) : n > 0 ? n : fallback;
2121
+ }
2122
+ var runsPanel = createRunsPanel();
2123
+
2124
+ // src/ops/terminal/panel.ts
2125
+ import { createElement as createElement3 } from "react";
2126
+ import { createRoot as createRoot3 } from "react-dom/client";
2127
+ import {
2128
+ bindConfig as bindConfig3
2129
+ } from "@xenosystem/panel-sdk";
2130
+
2131
+ // src/ops/terminal/controller.ts
2132
+ var counter = 0;
2133
+ var defaultMakeId = () => `term-${Date.now().toString(36)}-${(counter++).toString(36)}`;
2134
+ var TerminalController = class {
2135
+ host;
2136
+ makeId;
2137
+ stopOnClose;
2138
+ scrollback;
2139
+ instances = /* @__PURE__ */ new Map();
2140
+ order = [];
2141
+ activeId = null;
2142
+ fontSize;
2143
+ search = null;
2144
+ listeners = /* @__PURE__ */ new Set();
2145
+ snapshot = null;
2146
+ constructor(options) {
2147
+ this.host = options.host;
2148
+ this.makeId = options.host.makeId ?? defaultMakeId;
2149
+ this.scrollback = options.scrollback ?? 5e3;
2150
+ this.fontSize = options.fontSize ?? 12;
2151
+ this.stopOnClose = options.stopOnClose !== false;
2152
+ }
2153
+ /* ── Subscription ──────────────────────────────────────────────────────── */
2154
+ subscribe = (listener) => {
2155
+ this.listeners.add(listener);
2156
+ return () => this.listeners.delete(listener);
2157
+ };
2158
+ getState = () => {
2159
+ if (!this.snapshot) {
2160
+ const instances = this.order.map((id) => this.instances.get(id)).filter((i) => Boolean(i)).map((i) => ({
2161
+ session: i.session,
2162
+ active: i.session.id === this.activeId,
2163
+ cols: i.cols,
2164
+ rows: i.rows
2165
+ }));
2166
+ this.snapshot = {
2167
+ instances,
2168
+ activeId: this.activeId,
2169
+ fontSize: this.fontSize,
2170
+ search: this.search
2171
+ };
2172
+ }
2173
+ return this.snapshot;
2174
+ };
2175
+ notify() {
2176
+ this.snapshot = null;
2177
+ for (const listener of this.listeners) listener();
2178
+ }
2179
+ emit(intent) {
2180
+ this.host.emit("intent", intent);
2181
+ }
2182
+ /* ── Instances ─────────────────────────────────────────────────────────── */
2183
+ /**
2184
+ * Open a terminal instance.
2185
+ *
2186
+ * **No `start` intent is emitted here.** The panel cannot say how big the terminal is until an
2187
+ * emulator has attached and measured, and a PTY started at a guessed size renders its first
2188
+ * screen wrong. `start` goes out from {@link attach}, once.
2189
+ *
2190
+ * @param id - An explicit id, or one is minted.
2191
+ * @param session - Opening session fields.
2192
+ * @returns The instance id.
2193
+ */
2194
+ open(id = this.makeId(), session = {}) {
2195
+ if (this.instances.has(id)) return id;
2196
+ this.instances.set(id, {
2197
+ session: { id, status: "idle", ...session },
2198
+ emulator: null,
2199
+ cols: null,
2200
+ rows: null,
2201
+ pending: [],
2202
+ started: false,
2203
+ unsubscribers: []
2204
+ });
2205
+ this.order.push(id);
2206
+ if (this.activeId === null) this.activeId = id;
2207
+ this.notify();
2208
+ return id;
2209
+ }
2210
+ /**
2211
+ * Close an instance.
2212
+ *
2213
+ * @param id - The instance.
2214
+ * @returns Whether it existed.
2215
+ */
2216
+ close(id) {
2217
+ const instance = this.instances.get(id);
2218
+ if (!instance) return false;
2219
+ this.detach(id);
2220
+ this.instances.delete(id);
2221
+ this.order = this.order.filter((x) => x !== id);
2222
+ if (this.activeId === id) this.activeId = this.order[this.order.length - 1] ?? null;
2223
+ if (this.stopOnClose && instance.started) this.emit({ type: "stop", id });
2224
+ this.notify();
2225
+ return true;
2226
+ }
2227
+ /** Bring an instance to the front. */
2228
+ setActive(id) {
2229
+ if (id !== null && !this.instances.has(id)) return;
2230
+ this.activeId = id;
2231
+ this.notify();
2232
+ }
2233
+ /** Instance ids, in open order. */
2234
+ ids() {
2235
+ return [...this.order];
2236
+ }
2237
+ /* ── The emulator seam ─────────────────────────────────────────────────── */
2238
+ /**
2239
+ * Attach an emulator to an instance and wire it up.
2240
+ *
2241
+ * Emits `start` **only once a real grid has been measured** — the fix for a mistake both
2242
+ * incumbents make in opposite directions (a fixed 80×24 guess, or a synchronous fit before
2243
+ * layout settles).
2244
+ *
2245
+ * @param id - The instance.
2246
+ * @param emulator - The emulator.
2247
+ * @param element - Where it mounts.
2248
+ * @returns A detach function.
2249
+ */
2250
+ attach(id, emulator, element) {
2251
+ const instance = this.instances.get(id);
2252
+ if (!instance) return () => {
2253
+ };
2254
+ this.detach(id);
2255
+ instance.emulator = emulator;
2256
+ emulator.open(element);
2257
+ instance.unsubscribers.push(
2258
+ // Raw pass-through, control characters included. A terminal that interpreted Ctrl+C itself
2259
+ // would be a terminal that cannot interrupt anything.
2260
+ emulator.onData((data) => {
2261
+ if (isLive(instance.session.status)) this.emit({ type: "input", id, data });
2262
+ }),
2263
+ emulator.onResize((size) => {
2264
+ instance.cols = size.cols;
2265
+ instance.rows = size.rows;
2266
+ if (instance.started) this.emit({ type: "resize", id, cols: size.cols, rows: size.rows });
2267
+ this.notify();
2268
+ })
2269
+ );
2270
+ for (const chunk of instance.pending) emulator.write(chunk);
2271
+ instance.pending = [];
2272
+ this.fit(id);
2273
+ return () => this.detach(id);
2274
+ }
2275
+ /**
2276
+ * Re-fit an instance, and start it if it has never been measured before.
2277
+ *
2278
+ * @param id - The instance.
2279
+ * @returns The measured grid, or `null`.
2280
+ */
2281
+ fit(id) {
2282
+ const instance = this.instances.get(id);
2283
+ if (!instance?.emulator) return null;
2284
+ const size = instance.emulator.fit();
2285
+ if (!measuredSize(size)) return null;
2286
+ instance.cols = size.cols;
2287
+ instance.rows = size.rows;
2288
+ if (!instance.started) {
2289
+ instance.started = true;
2290
+ instance.session = { ...instance.session, status: "starting" };
2291
+ this.emit({ type: "start", id, cols: size.cols, rows: size.rows, cwd: instance.session.cwd });
2292
+ } else {
2293
+ this.emit({ type: "resize", id, cols: size.cols, rows: size.rows });
2294
+ }
2295
+ this.notify();
2296
+ return size;
2297
+ }
2298
+ /**
2299
+ * Detach an instance's emulator.
2300
+ *
2301
+ * **Never stops the session.** A panel can unmount because a tab was hidden, a layout changed,
2302
+ * or React StrictMode double-invoked an effect; killing a shell for any of those would lose work
2303
+ * the user did not ask to lose.
2304
+ *
2305
+ * @param id - The instance.
2306
+ */
2307
+ detach(id) {
2308
+ const instance = this.instances.get(id);
2309
+ if (!instance) return;
2310
+ for (const off of instance.unsubscribers) off();
2311
+ instance.unsubscribers = [];
2312
+ instance.emulator?.dispose();
2313
+ instance.emulator = null;
2314
+ }
2315
+ /** Ask the host to replay its buffer — the reattach path. */
2316
+ requestSnapshot(id) {
2317
+ if (!this.instances.has(id)) return false;
2318
+ this.emit({ type: "snapshot", id });
2319
+ return true;
2320
+ }
2321
+ /* ── Host-pushed state ─────────────────────────────────────────────────── */
2322
+ /**
2323
+ * Write host output into a terminal.
2324
+ *
2325
+ * @param payload - `{id, data}`.
2326
+ * @returns Whether the instance exists.
2327
+ */
2328
+ write(payload) {
2329
+ const instance = this.instances.get(payload.id);
2330
+ if (!instance) return false;
2331
+ if (instance.emulator) instance.emulator.write(payload.data);
2332
+ else {
2333
+ instance.pending.push(payload.data);
2334
+ if (instance.pending.length > 512) instance.pending.splice(0, instance.pending.length - 512);
2335
+ }
2336
+ return true;
2337
+ }
2338
+ /**
2339
+ * Update a session's host-reported state.
2340
+ *
2341
+ * The panel never sets `running` or `exited` itself — it asked, and it waits to be told.
2342
+ *
2343
+ * @param session - Fields to merge, keyed by `id`.
2344
+ * @returns Whether the instance exists.
2345
+ */
2346
+ setSession(session) {
2347
+ const instance = this.instances.get(session.id);
2348
+ if (!instance) return false;
2349
+ instance.session = { ...instance.session, ...session };
2350
+ this.notify();
2351
+ return true;
2352
+ }
2353
+ /** Replace a terminal's contents — used when a snapshot arrives. */
2354
+ replace(id, data) {
2355
+ const instance = this.instances.get(id);
2356
+ if (!instance) return false;
2357
+ instance.emulator?.clear();
2358
+ instance.pending = [];
2359
+ return this.write({ id, data });
2360
+ }
2361
+ /* ── View state ────────────────────────────────────────────────────────── */
2362
+ /** Set the font size on every attached emulator. */
2363
+ setFontSize(size) {
2364
+ this.fontSize = Math.max(6, Math.min(32, size));
2365
+ for (const [id, instance] of this.instances) {
2366
+ instance.emulator?.setFontSize?.(this.fontSize);
2367
+ this.fit(id);
2368
+ }
2369
+ this.notify();
2370
+ }
2371
+ /** Open, update, or close the find bar. */
2372
+ setSearch(query) {
2373
+ this.search = query;
2374
+ this.notify();
2375
+ }
2376
+ /** Run a search in the active terminal. */
2377
+ find(direction = "next") {
2378
+ const instance = this.activeId ? this.instances.get(this.activeId) : null;
2379
+ if (!instance?.emulator?.search || !this.search) return false;
2380
+ return instance.emulator.search(this.search, direction);
2381
+ }
2382
+ /** The active instance's session, if any. */
2383
+ active() {
2384
+ return this.activeId ? this.instances.get(this.activeId)?.session ?? null : null;
2385
+ }
2386
+ /* ── Lifecycle ─────────────────────────────────────────────────────────── */
2387
+ /**
2388
+ * Serialize.
2389
+ *
2390
+ * Which tab was in front, and the font size. **Never the scrollback** — terminal output routinely
2391
+ * contains tokens, connection strings and keys that a user pasted or a tool printed, and `.xapp`
2392
+ * is a plain JSON file. `XENO AUTH - SPEC.md` L9 keeps secrets out of exactly this kind of store.
2393
+ */
2394
+ serialize() {
2395
+ return { activeId: this.activeId, fontSize: this.fontSize };
2396
+ }
2397
+ /** Restore view preferences. Sessions are not restored — the host re-declares them. */
2398
+ deserialize(state) {
2399
+ if (!state || typeof state !== "object") return;
2400
+ const s = state;
2401
+ if (typeof s.fontSize === "number") this.fontSize = s.fontSize;
2402
+ if (typeof s.activeId === "string" || s.activeId === null) this.activeId = s.activeId ?? null;
2403
+ this.notify();
2404
+ }
2405
+ /**
2406
+ * Tear down.
2407
+ *
2408
+ * Detaches every emulator and **stops nothing**. Sessions outlive the panel by design; the host
2409
+ * decides when a process dies.
2410
+ */
2411
+ dispose() {
2412
+ for (const id of [...this.instances.keys()]) this.detach(id);
2413
+ this.instances.clear();
2414
+ this.order = [];
2415
+ this.activeId = null;
2416
+ this.listeners.clear();
2417
+ }
2418
+ };
2419
+
2420
+ // src/ops/terminal/manifest.ts
2421
+ import { WELL_KNOWN_PORT_SCHEMAS } from "@xenosystem/panel-sdk";
2422
+ var TERMINAL_PANEL_ID = "xeno.core.terminal";
2423
+ var terminalManifest = {
2424
+ id: TERMINAL_PANEL_ID,
2425
+ version: "0.1.0",
2426
+ title: "Terminal",
2427
+ icon: "square-terminal",
2428
+ description: "The emulator surface: xterm instance, fit-to-PTY sizing, canonical theme, selection, search and scrollback. The host owns the PTY \u2014 the panel never spawns a process.",
2429
+ defaultSlot: "bottom",
2430
+ inputs: [
2431
+ {
2432
+ id: "data",
2433
+ name: "Data",
2434
+ type: "object",
2435
+ schema: WELL_KNOWN_PORT_SCHEMAS.TERMINAL_DATA,
2436
+ description: "{id, data} \u2014 raw output for one instance, escape sequences intact. Never pre-parsed; the emulator is the parser.",
2437
+ multiple: true
2438
+ },
2439
+ {
2440
+ id: "session",
2441
+ name: "Session",
2442
+ type: "object",
2443
+ description: "{id, status, cwd?, exitCode?, pty?, message?}. The host is authoritative \u2014 the panel never sets `running` or `exited` itself.",
2444
+ multiple: true
2445
+ },
2446
+ {
2447
+ id: "snapshot",
2448
+ name: "Snapshot",
2449
+ type: "object",
2450
+ description: "{id, data} \u2014 a full buffer replay, for reattaching to a session that outlived the panel.",
2451
+ multiple: false
2452
+ }
2453
+ ],
2454
+ outputs: [
2455
+ {
2456
+ id: "intent",
2457
+ name: "Intent",
2458
+ type: "object",
2459
+ schema: WELL_KNOWN_PORT_SCHEMAS.TERMINAL_INTENT,
2460
+ description: "start | input | resize | stop | snapshot, always addressed by instance id. `start` carries a MEASURED grid \u2014 the panel does not ask until it can say how big the terminal is."
2461
+ }
2462
+ ],
2463
+ commands: [
2464
+ {
2465
+ id: "get_sessions",
2466
+ title: "Get Sessions",
2467
+ description: "Return every instance with its status and grid. No output text \u2014 see the note on scrollback in the doc pack.",
2468
+ parameters: {}
2469
+ },
2470
+ {
2471
+ id: "open",
2472
+ title: "Open Terminal",
2473
+ description: "Open a new instance. Starting is still gated on a real measurement.",
2474
+ parameters: { cwd: { type: "string", description: "Working directory" } }
2475
+ },
2476
+ {
2477
+ id: "close",
2478
+ title: "Close Terminal",
2479
+ description: "Close an instance, and ask the host to stop it.",
2480
+ parameters: { id: { type: "string", description: "Instance id", required: true } }
2481
+ },
2482
+ {
2483
+ id: "set_active",
2484
+ title: "Set Active",
2485
+ description: "Bring an instance to the front.",
2486
+ parameters: { id: { type: "string", description: "Instance id", required: true } }
2487
+ },
2488
+ {
2489
+ id: "find",
2490
+ title: "Find",
2491
+ description: "Search the active terminal\u2019s scrollback.",
2492
+ parameters: {
2493
+ query: { type: "string", description: "Text to find", required: true },
2494
+ direction: { type: "string", description: "next | previous" }
2495
+ }
2496
+ }
2497
+ // NOTE: there is deliberately no `input` command. An agent that could write to a terminal
2498
+ // could run any command the shell can run, with the user's credentials, without ever passing
2499
+ // a tool-approval check — the panel would become a hole straight through the agent's own
2500
+ // permission model. Agents drive shells through the agent SDK's tool surface, which is
2501
+ // approvable; this panel is where a HUMAN types.
2502
+ ],
2503
+ config: [
2504
+ {
2505
+ key: "scrollback",
2506
+ label: "Scrollback lines",
2507
+ type: "number",
2508
+ defaultValue: 5e3,
2509
+ description: "Lines the emulator retains."
2510
+ },
2511
+ {
2512
+ key: "fontSize",
2513
+ label: "Font size",
2514
+ type: "number",
2515
+ defaultValue: 12,
2516
+ description: "Terminal font size in px."
2517
+ },
2518
+ {
2519
+ key: "stopOnClose",
2520
+ label: "Stop on close",
2521
+ type: "boolean",
2522
+ defaultValue: true,
2523
+ description: "Ask the host to end a session when its tab is closed. Closing a tab is explicit; unmounting is not, and never stops anything."
2524
+ },
2525
+ {
2526
+ key: "multiInstance",
2527
+ label: "Multiple terminals",
2528
+ type: "boolean",
2529
+ defaultValue: true,
2530
+ description: "Show the tab strip. Both existing implementations need more than one terminal."
2531
+ }
2532
+ ],
2533
+ capabilities: ["storage.local"],
2534
+ sdk: "^1.1.0"
2535
+ };
2536
+
2537
+ // src/ops/terminal/react/TerminalPanelView.tsx
2538
+ import { useEffect as useEffect2, useRef as useRef2, useSyncExternalStore as useSyncExternalStore3 } from "react";
2539
+ import {
2540
+ Badge as Badge3,
2541
+ EmptyState as EmptyState3,
2542
+ IconButton as IconButton3,
2543
+ SearchField as SearchField3,
2544
+ StatusBar as StatusBar3,
2545
+ TextButton as TextButton2,
2546
+ Toolbar as Toolbar3,
2547
+ ToolbarGroup as ToolbarGroup3
2548
+ } from "@xenosystem/workbench/primitives/react";
2549
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
2550
+ function CloseIcon() {
2551
+ return /* @__PURE__ */ jsx5("svg", { width: "10", height: "10", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", children: /* @__PURE__ */ jsx5("path", { d: "M18 6 6 18M6 6l12 12" }) });
2552
+ }
2553
+ function PlusIcon() {
2554
+ return /* @__PURE__ */ jsx5("svg", { width: "10", height: "10", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", children: /* @__PURE__ */ jsx5("path", { d: "M12 5v14M5 12h14" }) });
2555
+ }
2556
+ function TerminalSurface({
2557
+ id,
2558
+ controller,
2559
+ createEmulator,
2560
+ visible: visible2
2561
+ }) {
2562
+ const ref = useRef2(null);
2563
+ useEffect2(() => {
2564
+ const element = ref.current;
2565
+ if (!element) return;
2566
+ let detach = null;
2567
+ let cancelled = false;
2568
+ void createEmulator().then((emulator) => {
2569
+ if (cancelled) {
2570
+ emulator.dispose();
2571
+ return;
2572
+ }
2573
+ detach = controller.attach(id, emulator, element);
2574
+ });
2575
+ const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => controller.fit(id)) : null;
2576
+ if (observer) observer.observe(element);
2577
+ return () => {
2578
+ cancelled = true;
2579
+ observer?.disconnect();
2580
+ detach?.();
2581
+ };
2582
+ }, [id, controller, createEmulator]);
2583
+ useEffect2(() => {
2584
+ if (visible2) controller.fit(id);
2585
+ }, [visible2, id, controller]);
2586
+ return /* @__PURE__ */ jsx5(
2587
+ "div",
2588
+ {
2589
+ ref,
2590
+ "data-terminal": id,
2591
+ style: {
2592
+ // Hidden, not unmounted: unmounting would dispose the emulator and lose the on-screen
2593
+ // buffer every time the user glanced at another tab.
2594
+ display: visible2 ? "block" : "none",
2595
+ position: "absolute",
2596
+ inset: 0
2597
+ }
2598
+ }
2599
+ );
2600
+ }
2601
+ function TerminalPanelView({
2602
+ controller,
2603
+ createEmulator,
2604
+ multiInstance = true
2605
+ }) {
2606
+ const state = useSyncExternalStore3(controller.subscribe, controller.getState, controller.getState);
2607
+ const active = state.instances.find((i) => i.active);
2608
+ if (!createEmulator) {
2609
+ return /* @__PURE__ */ jsx5(
2610
+ EmptyState3,
2611
+ {
2612
+ title: "No emulator configured",
2613
+ hint: "Pass `createEmulator` (or use `createXtermEmulator`) so the panel has something to render into."
2614
+ }
2615
+ );
2616
+ }
2617
+ return /* @__PURE__ */ jsxs5("div", { style: { display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }, children: [
2618
+ multiInstance ? /* @__PURE__ */ jsx5(
2619
+ Toolbar3,
2620
+ {
2621
+ left: /* @__PURE__ */ jsx5("div", { style: { display: "flex", gap: 2, alignItems: "center", overflowX: "auto" }, children: state.instances.map((instance) => {
2622
+ const label = statusLabel(instance.session);
2623
+ return /* @__PURE__ */ jsxs5(
2624
+ "div",
2625
+ {
2626
+ style: { display: "flex", alignItems: "center", gap: 2 },
2627
+ children: [
2628
+ /* @__PURE__ */ jsx5(
2629
+ TextButton2,
2630
+ {
2631
+ strong: instance.active,
2632
+ onClick: () => controller.setActive(instance.session.id),
2633
+ children: instance.session.title ?? instance.session.id.slice(0, 8)
2634
+ }
2635
+ ),
2636
+ label ? /* @__PURE__ */ jsx5(Badge3, { tone: instance.session.status === "failed" ? "error" : "neutral", children: label }) : null,
2637
+ /* @__PURE__ */ jsx5(
2638
+ IconButton3,
2639
+ {
2640
+ label: "Close terminal",
2641
+ size: "sm",
2642
+ icon: /* @__PURE__ */ jsx5(CloseIcon, {}),
2643
+ onClick: () => controller.close(instance.session.id)
2644
+ }
2645
+ )
2646
+ ]
2647
+ },
2648
+ instance.session.id
2649
+ );
2650
+ }) }),
2651
+ right: /* @__PURE__ */ jsxs5(ToolbarGroup3, { end: true, children: [
2652
+ /* @__PURE__ */ jsx5(IconButton3, { label: "New terminal", icon: /* @__PURE__ */ jsx5(PlusIcon, {}), onClick: () => controller.open() }),
2653
+ /* @__PURE__ */ jsx5(TextButton2, { onClick: () => controller.setSearch(state.search === null ? "" : null), children: "Find" })
2654
+ ] })
2655
+ }
2656
+ ) : null,
2657
+ state.search !== null ? /* @__PURE__ */ jsx5(
2658
+ Toolbar3,
2659
+ {
2660
+ left: /* @__PURE__ */ jsx5(
2661
+ SearchField3,
2662
+ {
2663
+ value: state.search,
2664
+ onChange: (value) => controller.setSearch(value),
2665
+ placeholder: "Find in terminal",
2666
+ autoFocus: true
2667
+ }
2668
+ ),
2669
+ right: /* @__PURE__ */ jsxs5(ToolbarGroup3, { end: true, children: [
2670
+ /* @__PURE__ */ jsx5(TextButton2, { onClick: () => controller.find("previous"), children: "Previous" }),
2671
+ /* @__PURE__ */ jsx5(TextButton2, { onClick: () => controller.find("next"), children: "Next" })
2672
+ ] })
2673
+ }
2674
+ ) : null,
2675
+ /* @__PURE__ */ jsx5("div", { style: { position: "relative", flex: 1, minHeight: 0 }, children: state.instances.length === 0 ? /* @__PURE__ */ jsx5(EmptyState3, { title: "No terminal", hint: "Open one to get a shell." }) : state.instances.map((instance) => /* @__PURE__ */ jsx5(
2676
+ TerminalSurface,
2677
+ {
2678
+ id: instance.session.id,
2679
+ controller,
2680
+ createEmulator,
2681
+ visible: instance.active
2682
+ },
2683
+ instance.session.id
2684
+ )) }),
2685
+ /* @__PURE__ */ jsx5(
2686
+ StatusBar3,
2687
+ {
2688
+ left: active?.session.cwd,
2689
+ right: active && active.cols ? `${active.cols}\xD7${active.rows}` : void 0
2690
+ }
2691
+ )
2692
+ ] });
2693
+ }
2694
+
2695
+ // src/ops/terminal/panel.ts
2696
+ function createTerminalPanel(options = {}) {
2697
+ return {
2698
+ manifest: terminalManifest,
2699
+ activate(host) {
2700
+ const config = host.config ?? {};
2701
+ const controller = new TerminalController({
2702
+ host: { emit: (portId, value) => host.emit(portId, value) },
2703
+ scrollback: typeof config.scrollback === "number" ? config.scrollback : void 0,
2704
+ fontSize: typeof config.fontSize === "number" ? config.fontSize : void 0,
2705
+ stopOnClose: config.stopOnClose !== false
2706
+ });
2707
+ const resolve = (config2) => ({
2708
+ multiInstance: config2.multiInstance !== false
2709
+ });
2710
+ let renderConfig = resolve(host.config ?? {});
2711
+ const createEmulator = options.createEmulator ?? (async () => {
2712
+ const { createXtermEmulator } = await import("../xterm-R3GIKSHA.js");
2713
+ return createXtermEmulator({
2714
+ fontSize: controller.getState().fontSize,
2715
+ scrollback: controller.scrollback
2716
+ });
2717
+ });
2718
+ let unrender = null;
2719
+ let root = null;
2720
+ let currentEl = null;
2721
+ const draw = (el) => {
2722
+ unrender?.();
2723
+ if (options.render) {
2724
+ unrender = options.render(el, { controller, config: renderConfig });
2725
+ return;
2726
+ }
2727
+ root = createRoot3(el);
2728
+ root.render(
2729
+ createElement3(TerminalPanelView, { controller, createEmulator, ...renderConfig })
2730
+ );
2731
+ unrender = () => {
2732
+ root?.unmount();
2733
+ root = null;
2734
+ };
2735
+ };
2736
+ const unbindConfig = bindConfig3(host, (config2) => {
2737
+ renderConfig = resolve(config2);
2738
+ if (currentEl) draw(currentEl);
2739
+ });
2740
+ return {
2741
+ render(el) {
2742
+ currentEl = el;
2743
+ draw(el);
2744
+ },
2745
+ onInput(portId, value) {
2746
+ if (portId === "data") {
2747
+ const payload = value;
2748
+ if (payload?.id && typeof payload.data === "string") controller.write(payload);
2749
+ } else if (portId === "session") {
2750
+ const payload = value;
2751
+ if (typeof payload?.id === "string") {
2752
+ controller.open(payload.id, payload);
2753
+ controller.setSession(payload);
2754
+ }
2755
+ } else if (portId === "snapshot") {
2756
+ const payload = value;
2757
+ if (payload?.id && typeof payload.data === "string") {
2758
+ controller.replace(payload.id, payload.data);
2759
+ }
2760
+ }
2761
+ },
2762
+ async onCommand(commandId, params) {
2763
+ switch (commandId) {
2764
+ case "get_sessions": {
2765
+ const state = controller.getState();
2766
+ return state.instances.map((i) => ({
2767
+ id: i.session.id,
2768
+ status: i.session.status,
2769
+ title: i.session.title,
2770
+ cwd: i.session.cwd,
2771
+ cols: i.cols,
2772
+ rows: i.rows
2773
+ }));
2774
+ }
2775
+ case "open":
2776
+ return controller.open(void 0, {
2777
+ cwd: typeof params.cwd === "string" ? params.cwd : void 0
2778
+ });
2779
+ case "close":
2780
+ return controller.close(String(params.id));
2781
+ case "set_active":
2782
+ controller.setActive(String(params.id));
2783
+ return true;
2784
+ case "find":
2785
+ controller.setSearch(String(params.query ?? ""));
2786
+ return controller.find(params.direction === "previous" ? "previous" : "next");
2787
+ default:
2788
+ return;
2789
+ }
2790
+ },
2791
+ serialize() {
2792
+ return controller.serialize();
2793
+ },
2794
+ deserialize(state) {
2795
+ controller.deserialize(state);
2796
+ },
2797
+ dispose() {
2798
+ unbindConfig();
2799
+ currentEl = null;
2800
+ unrender?.();
2801
+ unrender = null;
2802
+ controller.dispose();
2803
+ }
2804
+ };
2805
+ }
2806
+ };
2807
+ }
2808
+ var terminalPanel = createTerminalPanel();
2809
+
2810
+ // src/ops/diff/panel.ts
2811
+ import { createElement as createElement4 } from "react";
2812
+ import { createRoot as createRoot4 } from "react-dom/client";
2813
+ import {
2814
+ bindConfig as bindConfig4
2815
+ } from "@xenosystem/panel-sdk";
2816
+
2817
+ // src/ops/diff/types.ts
2818
+ function isSettled(state) {
2819
+ return state === "applied";
2820
+ }
2821
+ function needsAttention(state) {
2822
+ return state === "conflict" || state === "failed";
2823
+ }
2824
+ function countChanges(file) {
2825
+ if (file.additions !== void 0 && file.deletions !== void 0) {
2826
+ return { additions: file.additions, deletions: file.deletions };
2827
+ }
2828
+ let additions = 0;
2829
+ let deletions = 0;
2830
+ for (const hunk of file.hunks) {
2831
+ for (const line of hunk.lines) {
2832
+ if (line.kind === "added") additions += 1;
2833
+ else if (line.kind === "removed") deletions += 1;
2834
+ }
2835
+ }
2836
+ return { additions, deletions };
2837
+ }
2838
+ function splitRows(hunk) {
2839
+ const rows = [];
2840
+ let index = 0;
2841
+ while (index < hunk.lines.length) {
2842
+ const line = hunk.lines[index];
2843
+ if (line.kind === "context") {
2844
+ rows.push({ left: line, right: line });
2845
+ index += 1;
2846
+ continue;
2847
+ }
2848
+ const removed = [];
2849
+ const added = [];
2850
+ while (index < hunk.lines.length && hunk.lines[index].kind !== "context") {
2851
+ if (hunk.lines[index].kind === "removed") removed.push(hunk.lines[index]);
2852
+ else added.push(hunk.lines[index]);
2853
+ index += 1;
2854
+ }
2855
+ const height = Math.max(removed.length, added.length);
2856
+ for (let i = 0; i < height; i += 1) {
2857
+ rows.push({ left: removed[i] ?? null, right: added[i] ?? null });
2858
+ }
2859
+ }
2860
+ return rows;
2861
+ }
2862
+ function flattenHunks(model) {
2863
+ const out = [];
2864
+ for (const file of model.files) {
2865
+ for (const hunk of file.hunks) out.push({ path: file.path, hunkId: hunk.id });
2866
+ }
2867
+ return out;
2868
+ }
2869
+ function omissionLabel(omission) {
2870
+ switch (omission) {
2871
+ case "binary":
2872
+ return "binary \u2014 not compared";
2873
+ case "tooLarge":
2874
+ return "too large \u2014 not compared";
2875
+ case "unreadable":
2876
+ return "could not be read";
2877
+ case "identical":
2878
+ return "no changes";
2879
+ }
2880
+ }
2881
+
2882
+ // src/ops/diff/controller.ts
2883
+ var EMPTY = { files: [], status: "idle" };
2884
+ var DiffController = class {
2885
+ host;
2886
+ reviewEnabled;
2887
+ model = EMPTY;
2888
+ rev = -1;
2889
+ decisions = /* @__PURE__ */ new Map();
2890
+ view;
2891
+ collapsed = /* @__PURE__ */ new Set();
2892
+ wrap = false;
2893
+ showWhitespace = false;
2894
+ cursor = null;
2895
+ listeners = /* @__PURE__ */ new Set();
2896
+ snapshot = null;
2897
+ constructor(options) {
2898
+ this.host = options.host;
2899
+ this.view = options.view ?? "unified";
2900
+ this.reviewEnabled = options.review === true;
2901
+ }
2902
+ /* ── Subscription ──────────────────────────────────────────────────────── */
2903
+ subscribe = (listener) => {
2904
+ this.listeners.add(listener);
2905
+ return () => this.listeners.delete(listener);
2906
+ };
2907
+ getState = () => {
2908
+ if (!this.snapshot) {
2909
+ let additions = 0;
2910
+ let deletions = 0;
2911
+ let hunks = 0;
2912
+ const files = this.model.files.map((file) => {
2913
+ const counts = countChanges(file);
2914
+ additions += counts.additions;
2915
+ deletions += counts.deletions;
2916
+ hunks += file.hunks.length;
2917
+ const fileDecisions = {};
2918
+ for (const hunk of file.hunks) {
2919
+ const decision = this.decisions.get(this.key(file.path, hunk.id));
2920
+ if (decision) fileDecisions[hunk.id] = decision;
2921
+ }
2922
+ return {
2923
+ file,
2924
+ collapsed: this.collapsed.has(file.path),
2925
+ decisions: fileDecisions,
2926
+ additions: counts.additions,
2927
+ deletions: counts.deletions
2928
+ };
2929
+ });
2930
+ const all = [...this.decisions.values()];
2931
+ const status = this.model.status ?? (this.model.files.length > 0 ? "ready" : "idle");
2932
+ this.snapshot = {
2933
+ files,
2934
+ status,
2935
+ message: this.model.message ?? null,
2936
+ view: this.view,
2937
+ wrap: this.wrap,
2938
+ showWhitespace: this.showWhitespace,
2939
+ oldLabel: this.model.oldLabel ?? "Original",
2940
+ newLabel: this.model.newLabel ?? "Modified",
2941
+ totals: { files: this.model.files.length, additions, deletions, hunks },
2942
+ review: this.reviewEnabled && hunks > 0 ? {
2943
+ decided: all.filter((d) => isSettled(d.state)).length,
2944
+ total: hunks,
2945
+ conflicts: all.filter((d) => d.state === "conflict").length,
2946
+ pending: all.filter((d) => d.state === "pending").length
2947
+ } : null,
2948
+ // 🔴 "Nothing changed" is claimed ONLY when the comparison actually ran and every file was
2949
+ // compared. A binary file, a file past the size ceiling and an unreadable one all have zero
2950
+ // hunks and none of them means the content is the same.
2951
+ identical: status === "ready" && hunks === 0 && this.model.files.length > 0 && this.model.files.every((f) => f.omitted === void 0 || f.omitted === "identical"),
2952
+ cursor: this.cursor
2953
+ };
2954
+ }
2955
+ return this.snapshot;
2956
+ };
2957
+ notify() {
2958
+ this.snapshot = null;
2959
+ for (const listener of this.listeners) listener();
2960
+ }
2961
+ key(path, hunkId) {
2962
+ return `${path}\0${hunkId}`;
2963
+ }
2964
+ /* ── Host-pushed state ─────────────────────────────────────────────────── */
2965
+ /**
2966
+ * Install a comparison.
2967
+ *
2968
+ * @param model - The host-computed diff.
2969
+ * @returns Whether it was applied (an older `rev` is ignored).
2970
+ */
2971
+ setModel(model) {
2972
+ if (model.rev !== void 0 && model.rev <= this.rev) return false;
2973
+ if (model.rev !== void 0) this.rev = model.rev;
2974
+ this.model = { ...model, files: model.files ?? [] };
2975
+ const live = /* @__PURE__ */ new Set();
2976
+ for (const file of this.model.files) {
2977
+ for (const hunk of file.hunks) live.add(this.key(file.path, hunk.id));
2978
+ }
2979
+ for (const key of [...this.decisions.keys()]) {
2980
+ if (!live.has(key)) this.decisions.delete(key);
2981
+ }
2982
+ if (this.cursor && !live.has(this.key(this.cursor.path, this.cursor.hunkId))) this.cursor = null;
2983
+ if (!this.cursor) this.cursor = flattenHunks(this.model)[0] ?? null;
2984
+ this.notify();
2985
+ return true;
2986
+ }
2987
+ /** Set the comparison's status without replacing the model. */
2988
+ setStatus(status, message) {
2989
+ this.model = { ...this.model, status, message };
2990
+ if (status === "computing" || status === "failed") {
2991
+ this.model = { ...this.model, files: status === "failed" ? [] : this.model.files };
2992
+ }
2993
+ this.notify();
2994
+ }
2995
+ /**
2996
+ * Record a host-reported decision outcome.
2997
+ *
2998
+ * The **only** way a decision reaches `applied`. The panel writes `pending` when it asks, and
2999
+ * nothing else.
3000
+ *
3001
+ * @param decision - The outcome.
3002
+ * @returns Whether it was recorded.
3003
+ */
3004
+ setDecision(decision) {
3005
+ if (!decision?.hunkId || !decision.path) return false;
3006
+ this.decisions.set(this.key(decision.path, decision.hunkId), decision);
3007
+ this.notify();
3008
+ return true;
3009
+ }
3010
+ /** Record several at once. */
3011
+ setDecisions(decisions) {
3012
+ for (const decision of decisions) {
3013
+ if (decision?.hunkId && decision.path) {
3014
+ this.decisions.set(this.key(decision.path, decision.hunkId), decision);
3015
+ }
3016
+ }
3017
+ this.notify();
3018
+ }
3019
+ /* ── Decisions ─────────────────────────────────────────────────────────── */
3020
+ /**
3021
+ * Decide a hunk.
3022
+ *
3023
+ * Emits an intent and marks the decision `pending`. **It does not apply anything and does not
3024
+ * render as accepted.** The surveyed host runs a two-phase confirm and a checkpointed filesystem
3025
+ * transaction whose outcome may be `applied`, `failed` or `conflict`; a panel that showed a tick
3026
+ * on click would be asserting the outcome of work that has not started.
3027
+ *
3028
+ * @param path - The file.
3029
+ * @param hunkId - The hunk.
3030
+ * @param action - accept, reject or comment.
3031
+ * @param comment - For `comment`, or a note on either.
3032
+ * @returns Whether an intent was emitted.
3033
+ */
3034
+ decide(path, hunkId, action, comment) {
3035
+ if (!this.reviewEnabled) return false;
3036
+ const file = this.model.files.find((f) => f.path === path);
3037
+ if (!file?.hunks.some((h) => h.id === hunkId)) return false;
3038
+ const existing = this.decisions.get(this.key(path, hunkId));
3039
+ if (existing?.state === "pending") return false;
3040
+ this.decisions.set(this.key(path, hunkId), {
3041
+ path,
3042
+ hunkId,
3043
+ action,
3044
+ comment,
3045
+ state: "pending",
3046
+ updatedAt: Date.now()
3047
+ });
3048
+ this.host.emit("decision", { path, hunkId, action, comment });
3049
+ this.notify();
3050
+ return true;
3051
+ }
3052
+ /** Clear a decision locally so it can be re-made — the remedy for a conflict. */
3053
+ clearDecision(path, hunkId) {
3054
+ const removed = this.decisions.delete(this.key(path, hunkId));
3055
+ if (removed) this.notify();
3056
+ return removed;
3057
+ }
3058
+ /** Every recorded decision. */
3059
+ allDecisions() {
3060
+ return [...this.decisions.values()];
3061
+ }
3062
+ /* ── The hunk cursor ───────────────────────────────────────────────────── */
3063
+ /** Move to the next hunk, across file boundaries. */
3064
+ nextHunk() {
3065
+ return this.moveCursor(1);
3066
+ }
3067
+ /** Move to the previous hunk. */
3068
+ previousHunk() {
3069
+ return this.moveCursor(-1);
3070
+ }
3071
+ moveCursor(delta) {
3072
+ const all = flattenHunks(this.model);
3073
+ if (all.length === 0) return null;
3074
+ const at = this.cursor ? all.findIndex((h) => h.path === this.cursor?.path && h.hunkId === this.cursor?.hunkId) : -1;
3075
+ const next = Math.max(0, Math.min(all.length - 1, at + delta));
3076
+ this.cursor = all[next] ?? null;
3077
+ if (this.cursor) this.emitNavigate(this.cursor.path, this.cursor.hunkId);
3078
+ this.notify();
3079
+ return this.cursor;
3080
+ }
3081
+ /** Point at a specific hunk. */
3082
+ focusHunk(path, hunkId) {
3083
+ const file = this.model.files.find((f) => f.path === path);
3084
+ if (!file?.hunks.some((h) => h.id === hunkId)) return false;
3085
+ this.cursor = { path, hunkId };
3086
+ this.emitNavigate(path, hunkId);
3087
+ this.notify();
3088
+ return true;
3089
+ }
3090
+ emitNavigate(path, hunkId) {
3091
+ const file = this.model.files.find((f) => f.path === path);
3092
+ const hunk = file?.hunks.find((h) => h.id === hunkId);
3093
+ this.host.emit("navigate", { path, hunkId, line: hunk?.newStart ?? hunk?.oldStart });
3094
+ }
3095
+ /* ── View state ────────────────────────────────────────────────────────── */
3096
+ /** Unified or split. */
3097
+ setView(view) {
3098
+ this.view = view;
3099
+ this.notify();
3100
+ }
3101
+ /** Collapse or expand a file. */
3102
+ setCollapsed(path, collapsed) {
3103
+ if (collapsed) this.collapsed.add(path);
3104
+ else this.collapsed.delete(path);
3105
+ this.notify();
3106
+ }
3107
+ /** Soft-wrap long lines. */
3108
+ setWrap(wrap) {
3109
+ this.wrap = wrap;
3110
+ this.notify();
3111
+ }
3112
+ /** Render whitespace characters. */
3113
+ setShowWhitespace(show) {
3114
+ this.showWhitespace = show;
3115
+ this.notify();
3116
+ }
3117
+ /** Ask the host for a context menu. */
3118
+ requestContextMenu(path, hunkId, x, y) {
3119
+ this.host.emit("contextMenuRequest", { path, hunkId, x, y });
3120
+ }
3121
+ /* ── Lifecycle ─────────────────────────────────────────────────────────── */
3122
+ /**
3123
+ * Serialize.
3124
+ *
3125
+ * View preferences only. **Never the model and never the decisions** — the model is a comparison
3126
+ * of content that has almost certainly moved on, and a restored decision would claim a review
3127
+ * outcome the host never confirmed.
3128
+ */
3129
+ serialize() {
3130
+ return {
3131
+ view: this.view,
3132
+ collapsed: [...this.collapsed],
3133
+ wrap: this.wrap,
3134
+ showWhitespace: this.showWhitespace
3135
+ };
3136
+ }
3137
+ /** Restore view preferences. */
3138
+ deserialize(state) {
3139
+ if (!state || typeof state !== "object") return;
3140
+ const s = state;
3141
+ if (s.view === "unified" || s.view === "split") this.view = s.view;
3142
+ if (Array.isArray(s.collapsed)) this.collapsed = new Set(s.collapsed);
3143
+ if (typeof s.wrap === "boolean") this.wrap = s.wrap;
3144
+ if (typeof s.showWhitespace === "boolean") this.showWhitespace = s.showWhitespace;
3145
+ this.notify();
3146
+ }
3147
+ /** Tear down. */
3148
+ dispose() {
3149
+ this.listeners.clear();
3150
+ this.model = EMPTY;
3151
+ this.decisions.clear();
3152
+ }
3153
+ };
3154
+
3155
+ // src/ops/diff/manifest.ts
3156
+ import { WELL_KNOWN_PORT_SCHEMAS as WELL_KNOWN_PORT_SCHEMAS2 } from "@xenosystem/panel-sdk";
3157
+ var DIFF_PANEL_ID = "xeno.core.diff";
3158
+ var diffManifest = {
3159
+ id: DIFF_PANEL_ID,
3160
+ version: "0.1.0",
3161
+ title: "Diff",
3162
+ icon: "file-diff",
3163
+ description: "Renders a host-computed comparison \u2014 unified or split, line or word marks \u2014 with optional hunk-level accept/reject as intents. Bundles no diff engine and no editor.",
3164
+ defaultSlot: "center",
3165
+ inputs: [
3166
+ {
3167
+ id: "model",
3168
+ name: "Model",
3169
+ type: "object",
3170
+ schema: WELL_KNOWN_PORT_SCHEMAS2.DIFF_MODEL,
3171
+ description: "{files[], status?, message?, oldLabel?, newLabel?, rev?}. A file with no hunks carries `omitted` when the reason is not equality \u2014 binary, too large, unreadable.",
3172
+ multiple: false
3173
+ },
3174
+ {
3175
+ id: "decisions",
3176
+ name: "Decisions",
3177
+ type: "array",
3178
+ schema: WELL_KNOWN_PORT_SCHEMAS2.DIFF_DECISION,
3179
+ description: "Host-reported outcomes. The ONLY way a decision reaches `applied`; the panel writes `pending` when it asks and nothing else.",
3180
+ multiple: true
3181
+ },
3182
+ {
3183
+ id: "status",
3184
+ name: "Status",
3185
+ type: "object",
3186
+ description: "{status, message?} \u2014 idle | computing | ready | failed, out of band, so a host never has to express a failed comparison as an empty file list.",
3187
+ multiple: false
3188
+ }
3189
+ ],
3190
+ outputs: [
3191
+ {
3192
+ id: "decision",
3193
+ name: "Decision",
3194
+ type: "object",
3195
+ schema: WELL_KNOWN_PORT_SCHEMAS2.DIFF_DECISION_INTENT,
3196
+ description: "{path, hunkId, action, comment?}. An INTENT \u2014 the host runs its confirm-and-commit and reports back `applied` / `failed` / `conflict`."
3197
+ },
3198
+ {
3199
+ id: "navigate",
3200
+ name: "Navigate",
3201
+ type: "object",
3202
+ description: "{path, hunkId, line?} \u2014 reveal this hunk in the editor."
3203
+ },
3204
+ {
3205
+ id: "contextMenuRequest",
3206
+ name: "Context Menu",
3207
+ type: "object",
3208
+ description: "{path, hunkId, x, y}. Deliberately untagged \u2014 the payload is panel-local."
3209
+ }
3210
+ ],
3211
+ commands: [
3212
+ {
3213
+ id: "get_summary",
3214
+ title: "Get Summary",
3215
+ description: 'Return per-file counts, omission reasons and review progress. Includes `identical` so a caller cannot mistake "not compared" for "unchanged".',
3216
+ parameters: {}
3217
+ },
3218
+ {
3219
+ id: "next_hunk",
3220
+ title: "Next Hunk",
3221
+ description: "Move the cursor to the next hunk. Clamps at the end; never wraps.",
3222
+ parameters: {}
3223
+ },
3224
+ {
3225
+ id: "previous_hunk",
3226
+ title: "Previous Hunk",
3227
+ description: "Move the cursor back.",
3228
+ parameters: {}
3229
+ },
3230
+ {
3231
+ id: "set_view",
3232
+ title: "Set View",
3233
+ description: "unified | split.",
3234
+ parameters: { view: { type: "string", description: "unified | split", required: true } }
3235
+ }
3236
+ // NOTE: `accept` and `reject` are deliberately NOT commands. A hunk decision rewrites the
3237
+ // user's files, and the surveyed host implements it as a two-phase confirm precisely because a
3238
+ // human is meant to be in the loop. An agent-invocable accept would let an agent approve its
3239
+ // OWN patch — the review step exists to check the agent, and the agent must not be able to
3240
+ // operate the thing that checks it.
3241
+ ],
3242
+ config: [
3243
+ {
3244
+ key: "view",
3245
+ label: "Default view",
3246
+ type: "select",
3247
+ defaultValue: "unified",
3248
+ options: [
3249
+ { label: "Unified", value: "unified" },
3250
+ { label: "Split", value: "split" }
3251
+ ],
3252
+ description: "Unified reads better inline; split reads better for wide rewrites."
3253
+ },
3254
+ {
3255
+ key: "review",
3256
+ label: "Allow accept/reject",
3257
+ type: "boolean",
3258
+ defaultValue: false,
3259
+ description: "Offer hunk decisions. Off by default \u2014 most diffs are read-only, and a review surface that appears where nobody can act is worse than none."
3260
+ },
3261
+ {
3262
+ key: "wrap",
3263
+ label: "Wrap long lines",
3264
+ type: "boolean",
3265
+ defaultValue: false,
3266
+ description: "Soft-wrap instead of scrolling horizontally."
3267
+ },
3268
+ {
3269
+ key: "showWhitespace",
3270
+ label: "Show whitespace",
3271
+ type: "boolean",
3272
+ defaultValue: false,
3273
+ description: "Render spaces and tabs \u2014 the only way to read a whitespace-only change."
3274
+ },
3275
+ {
3276
+ key: "contextLines",
3277
+ label: "Context lines",
3278
+ type: "number",
3279
+ defaultValue: 3,
3280
+ description: "Advisory: passed to the host, which computes the hunks."
3281
+ }
3282
+ ],
3283
+ capabilities: ["storage.local"],
3284
+ sdk: "^1.1.0"
3285
+ };
3286
+
3287
+ // src/ops/diff/react/DiffPanelView.tsx
3288
+ import { useSyncExternalStore as useSyncExternalStore4 } from "react";
3289
+ import {
3290
+ Badge as Badge4,
3291
+ EmptyState as EmptyState4,
3292
+ ScrollArea as ScrollArea3,
3293
+ SegmentedControl as SegmentedControl2,
3294
+ StatusBar as StatusBar4,
3295
+ TextButton as TextButton3,
3296
+ Toolbar as Toolbar4,
3297
+ ToolbarGroup as ToolbarGroup4
3298
+ } from "@xenosystem/workbench/primitives/react";
3299
+ import { Fragment as Fragment3, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
3300
+ var MARK = {
3301
+ added: { background: "var(--xeno-hover)" },
3302
+ removed: { background: "var(--xeno-border-subtle)" },
3303
+ context: {}
3304
+ };
3305
+ var SIGIL = { added: "+", removed: "-", context: " " };
3306
+ function visible(text, show) {
3307
+ return show ? text.replace(/\t/g, "\u2192 ").replace(/ /g, "\xB7") : text;
3308
+ }
3309
+ function LineText({
3310
+ line,
3311
+ language,
3312
+ highlighter,
3313
+ showWhitespace
3314
+ }) {
3315
+ if (line.segments?.length) {
3316
+ return /* @__PURE__ */ jsx6(Fragment3, { children: line.segments.map((segment, i) => /* @__PURE__ */ jsx6(
3317
+ "span",
3318
+ {
3319
+ style: segment.kind === "equal" ? void 0 : {
3320
+ background: "var(--xeno-border)",
3321
+ borderRadius: 2,
3322
+ textDecoration: segment.kind === "delete" ? "line-through" : void 0
3323
+ },
3324
+ children: visible(segment.text, showWhitespace)
3325
+ },
3326
+ i
3327
+ )) });
3328
+ }
3329
+ const tokens = highlighter?.highlight(line.text, language) ?? null;
3330
+ if (tokens) {
3331
+ return /* @__PURE__ */ jsx6(Fragment3, { children: tokens.map((token, i) => /* @__PURE__ */ jsx6("span", { className: token.token ? `xeno-tok-${token.token}` : void 0, children: visible(token.text, showWhitespace) }, i)) });
3332
+ }
3333
+ return /* @__PURE__ */ jsx6(Fragment3, { children: visible(line.text, showWhitespace) });
3334
+ }
3335
+ var GUTTER = {
3336
+ display: "inline-block",
3337
+ width: 40,
3338
+ textAlign: "right",
3339
+ paddingRight: 6,
3340
+ opacity: 0.35,
3341
+ fontVariantNumeric: "tabular-nums",
3342
+ userSelect: "none"
3343
+ };
3344
+ function DecisionChip({ decision }) {
3345
+ if (!decision) return null;
3346
+ if (decision.state === "pending") return /* @__PURE__ */ jsx6(Badge4, { children: "deciding\u2026" });
3347
+ if (decision.state === "conflict") {
3348
+ return /* @__PURE__ */ jsx6(Badge4, { tone: "error", title: decision.message ?? "The file changed since this was reviewed.", children: "conflict \u2014 re-review" });
3349
+ }
3350
+ if (decision.state === "failed") {
3351
+ return /* @__PURE__ */ jsx6(Badge4, { tone: "error", title: decision.message ?? void 0, children: "failed" });
3352
+ }
3353
+ if (isSettled(decision.state)) {
3354
+ return /* @__PURE__ */ jsx6(Badge4, { tone: "success", children: decision.action === "accept" ? "accepted" : "rejected" });
3355
+ }
3356
+ return null;
3357
+ }
3358
+ function DiffPanelView({ controller, highlighter, review = false }) {
3359
+ const state = useSyncExternalStore4(controller.subscribe, controller.getState, controller.getState);
3360
+ if (state.status === "idle") {
3361
+ return /* @__PURE__ */ jsx6(EmptyState4, { title: "Diff", hint: "Nothing to compare yet." });
3362
+ }
3363
+ if (state.status === "computing") {
3364
+ return /* @__PURE__ */ jsx6(EmptyState4, { title: "Comparing\u2026", hint: "The host is computing the differences." });
3365
+ }
3366
+ if (state.status === "failed") {
3367
+ return /* @__PURE__ */ jsx6(
3368
+ EmptyState4,
3369
+ {
3370
+ title: "Comparison failed",
3371
+ hint: state.message ?? "The host could not compute the differences."
3372
+ }
3373
+ );
3374
+ }
3375
+ if (state.identical) {
3376
+ return /* @__PURE__ */ jsx6(EmptyState4, { title: "No changes", hint: "The two sides are identical." });
3377
+ }
3378
+ const mono = "var(--xeno-font-mono)";
3379
+ const renderLine = (line, file, key) => /* @__PURE__ */ jsxs6(
3380
+ "div",
3381
+ {
3382
+ style: {
3383
+ ...MARK[line.kind],
3384
+ whiteSpace: state.wrap ? "pre-wrap" : "pre",
3385
+ fontFamily: mono,
3386
+ fontSize: 11,
3387
+ lineHeight: "16px"
3388
+ },
3389
+ children: [
3390
+ /* @__PURE__ */ jsx6("span", { style: GUTTER, children: line.oldLineNumber ?? "" }),
3391
+ /* @__PURE__ */ jsx6("span", { style: GUTTER, children: line.newLineNumber ?? "" }),
3392
+ /* @__PURE__ */ jsxs6("span", { style: { opacity: 0.45, userSelect: "none" }, children: [
3393
+ SIGIL[line.kind],
3394
+ " "
3395
+ ] }),
3396
+ /* @__PURE__ */ jsx6(
3397
+ LineText,
3398
+ {
3399
+ line,
3400
+ language: file.file.language,
3401
+ highlighter,
3402
+ showWhitespace: state.showWhitespace
3403
+ }
3404
+ )
3405
+ ]
3406
+ },
3407
+ key
3408
+ );
3409
+ const renderSplitCell = (line, file, side) => /* @__PURE__ */ jsxs6(
3410
+ "div",
3411
+ {
3412
+ style: {
3413
+ flex: 1,
3414
+ minWidth: 0,
3415
+ ...line ? MARK[line.kind] : { opacity: 0.25 },
3416
+ whiteSpace: state.wrap ? "pre-wrap" : "pre",
3417
+ overflow: "hidden",
3418
+ fontFamily: mono,
3419
+ fontSize: 11,
3420
+ lineHeight: "16px"
3421
+ },
3422
+ children: [
3423
+ /* @__PURE__ */ jsx6("span", { style: GUTTER, children: (side === "l" ? line?.oldLineNumber : line?.newLineNumber) ?? "" }),
3424
+ line ? /* @__PURE__ */ jsx6(
3425
+ LineText,
3426
+ {
3427
+ line,
3428
+ language: file.file.language,
3429
+ highlighter,
3430
+ showWhitespace: state.showWhitespace
3431
+ }
3432
+ ) : null
3433
+ ]
3434
+ }
3435
+ );
3436
+ return /* @__PURE__ */ jsxs6("div", { style: { display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }, children: [
3437
+ /* @__PURE__ */ jsx6(
3438
+ Toolbar4,
3439
+ {
3440
+ left: /* @__PURE__ */ jsx6(
3441
+ SegmentedControl2,
3442
+ {
3443
+ label: "View",
3444
+ value: state.view,
3445
+ onChange: (value) => controller.setView(value),
3446
+ options: [
3447
+ { label: "Unified", value: "unified" },
3448
+ { label: "Split", value: "split" }
3449
+ ]
3450
+ }
3451
+ ),
3452
+ right: /* @__PURE__ */ jsxs6(ToolbarGroup4, { end: true, children: [
3453
+ /* @__PURE__ */ jsx6(TextButton3, { onClick: () => controller.setWrap(!state.wrap), children: state.wrap ? "No wrap" : "Wrap" }),
3454
+ /* @__PURE__ */ jsx6(TextButton3, { onClick: () => controller.setShowWhitespace(!state.showWhitespace), children: state.showWhitespace ? "Hide \u2423" : "Show \u2423" }),
3455
+ /* @__PURE__ */ jsx6(TextButton3, { onClick: () => controller.previousHunk(), children: "Prev" }),
3456
+ /* @__PURE__ */ jsx6(TextButton3, { onClick: () => controller.nextHunk(), children: "Next" })
3457
+ ] })
3458
+ }
3459
+ ),
3460
+ state.review && state.review.conflicts > 0 ? /* @__PURE__ */ jsx6("div", { style: { padding: "2px 8px", fontSize: 9 }, children: /* @__PURE__ */ jsxs6(Badge4, { tone: "error", children: [
3461
+ state.review.conflicts,
3462
+ " conflict",
3463
+ state.review.conflicts === 1 ? "" : "s",
3464
+ " \u2014 the file changed since review"
3465
+ ] }) }) : null,
3466
+ /* @__PURE__ */ jsx6(ScrollArea3, { children: state.files.map((file) => /* @__PURE__ */ jsxs6("div", { style: { borderBottom: "1px solid var(--xeno-border-subtle)" }, children: [
3467
+ /* @__PURE__ */ jsxs6(
3468
+ "div",
3469
+ {
3470
+ style: {
3471
+ display: "flex",
3472
+ alignItems: "center",
3473
+ gap: 6,
3474
+ padding: "3px 8px",
3475
+ position: "sticky",
3476
+ top: 0,
3477
+ background: "var(--xeno-surface)",
3478
+ zIndex: 1
3479
+ },
3480
+ children: [
3481
+ /* @__PURE__ */ jsx6(TextButton3, { onClick: () => controller.setCollapsed(file.file.path, !file.collapsed), children: file.collapsed ? "\u25B8" : "\u25BE" }),
3482
+ /* @__PURE__ */ jsx6("span", { style: { fontSize: 11, fontWeight: 600 }, children: file.file.oldPath && file.file.oldPath !== file.file.path ? `${file.file.oldPath} \u2192 ${file.file.path}` : file.file.path }),
3483
+ /* @__PURE__ */ jsx6(Badge4, { children: file.file.status }),
3484
+ /* @__PURE__ */ jsxs6("span", { style: { fontSize: 10, fontVariantNumeric: "tabular-nums", opacity: 0.6 }, children: [
3485
+ "+",
3486
+ file.additions,
3487
+ " \u2212",
3488
+ file.deletions
3489
+ ] })
3490
+ ]
3491
+ }
3492
+ ),
3493
+ file.collapsed ? null : file.file.omitted ? (
3494
+ // 🔴 Never an empty body. "Binary — not compared" and "no changes" are different
3495
+ // facts, and a reviewer who reads the first as the second approves something nobody
3496
+ // looked at.
3497
+ /* @__PURE__ */ jsx6("div", { style: { padding: "6px 12px", fontSize: 10, opacity: 0.6 }, children: omissionLabel(file.file.omitted) })
3498
+ ) : file.file.hunks.map((hunk) => {
3499
+ const decision = file.decisions[hunk.id];
3500
+ const focused = state.cursor?.path === file.file.path && state.cursor.hunkId === hunk.id;
3501
+ return /* @__PURE__ */ jsxs6(
3502
+ "div",
3503
+ {
3504
+ onContextMenu: (event) => {
3505
+ event.preventDefault();
3506
+ controller.requestContextMenu(
3507
+ file.file.path,
3508
+ hunk.id,
3509
+ event.clientX,
3510
+ event.clientY
3511
+ );
3512
+ },
3513
+ style: {
3514
+ borderLeft: focused ? "2px solid var(--xeno-active)" : "2px solid transparent",
3515
+ opacity: isSettled(decision?.state ?? "undecided") ? 0.55 : 1
3516
+ },
3517
+ children: [
3518
+ /* @__PURE__ */ jsxs6(
3519
+ "div",
3520
+ {
3521
+ style: {
3522
+ display: "flex",
3523
+ alignItems: "center",
3524
+ gap: 6,
3525
+ padding: "1px 8px",
3526
+ fontSize: 9,
3527
+ opacity: 0.55
3528
+ },
3529
+ children: [
3530
+ /* @__PURE__ */ jsx6("span", { style: { fontFamily: mono }, children: hunk.header ?? `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@` }),
3531
+ /* @__PURE__ */ jsx6(DecisionChip, { decision }),
3532
+ review ? /* @__PURE__ */ jsx6("span", { style: { marginLeft: "auto", display: "flex", gap: 4 }, children: needsAttention(decision?.state ?? "undecided") ? /* @__PURE__ */ jsx6(
3533
+ TextButton3,
3534
+ {
3535
+ onClick: () => controller.clearDecision(file.file.path, hunk.id),
3536
+ children: "Re-review"
3537
+ }
3538
+ ) : /* @__PURE__ */ jsxs6(Fragment3, { children: [
3539
+ /* @__PURE__ */ jsx6(
3540
+ TextButton3,
3541
+ {
3542
+ disabled: decision?.state === "pending",
3543
+ onClick: () => controller.decide(file.file.path, hunk.id, "accept"),
3544
+ children: "Accept"
3545
+ }
3546
+ ),
3547
+ /* @__PURE__ */ jsx6(
3548
+ TextButton3,
3549
+ {
3550
+ disabled: decision?.state === "pending",
3551
+ onClick: () => controller.decide(file.file.path, hunk.id, "reject"),
3552
+ children: "Reject"
3553
+ }
3554
+ )
3555
+ ] }) }) : null
3556
+ ]
3557
+ }
3558
+ ),
3559
+ state.view === "unified" ? hunk.lines.map((line, i) => renderLine(line, file, `${hunk.id}-${i}`)) : splitRows(hunk).map((row, i) => /* @__PURE__ */ jsxs6("div", { style: { display: "flex", gap: 1 }, children: [
3560
+ renderSplitCell(row.left, file, "l"),
3561
+ renderSplitCell(row.right, file, "r")
3562
+ ] }, `${hunk.id}-s${i}`))
3563
+ ]
3564
+ },
3565
+ hunk.id
3566
+ );
3567
+ })
3568
+ ] }, file.file.path)) }),
3569
+ /* @__PURE__ */ jsx6(
3570
+ StatusBar4,
3571
+ {
3572
+ left: `${state.totals.files} file${state.totals.files === 1 ? "" : "s"} \xB7 +${state.totals.additions} \u2212${state.totals.deletions}`,
3573
+ right: state.review ? `${state.review.decided}/${state.review.total} reviewed` : `${state.oldLabel} \u2192 ${state.newLabel}`
3574
+ }
3575
+ )
3576
+ ] });
3577
+ }
3578
+
3579
+ // src/ops/diff/panel.ts
3580
+ function createDiffPanel(options = {}) {
3581
+ return {
3582
+ manifest: diffManifest,
3583
+ activate(host) {
3584
+ const config = host.config ?? {};
3585
+ const controller = new DiffController({
3586
+ host: { emit: (portId, value) => host.emit(portId, value) },
3587
+ view: config.view ?? "unified",
3588
+ review: config.review === true
3589
+ });
3590
+ if (config.wrap === true) controller.setWrap(true);
3591
+ if (config.showWhitespace === true) controller.setShowWhitespace(true);
3592
+ const resolve = (config2) => ({ review: config2.review === true });
3593
+ let renderConfig = resolve(host.config ?? {});
3594
+ let unrender = null;
3595
+ let root = null;
3596
+ let currentEl = null;
3597
+ const draw = (el) => {
3598
+ unrender?.();
3599
+ if (options.render) {
3600
+ unrender = options.render(el, { controller, config: renderConfig });
3601
+ return;
3602
+ }
3603
+ root = createRoot4(el);
3604
+ root.render(
3605
+ createElement4(DiffPanelView, {
3606
+ controller,
3607
+ highlighter: options.highlighter,
3608
+ ...renderConfig
3609
+ })
3610
+ );
3611
+ unrender = () => {
3612
+ root?.unmount();
3613
+ root = null;
3614
+ };
3615
+ };
3616
+ const unbindConfig = bindConfig4(host, (config2) => {
3617
+ renderConfig = resolve(config2);
3618
+ if (currentEl) draw(currentEl);
3619
+ });
3620
+ return {
3621
+ render(el) {
3622
+ currentEl = el;
3623
+ draw(el);
3624
+ },
3625
+ onInput(portId, value) {
3626
+ if (portId === "model") {
3627
+ if (value && typeof value === "object" && Array.isArray(value.files)) {
3628
+ controller.setModel(value);
3629
+ }
3630
+ } else if (portId === "decisions") {
3631
+ if (Array.isArray(value)) controller.setDecisions(value);
3632
+ else if (value && typeof value === "object") controller.setDecision(value);
3633
+ } else if (portId === "status") {
3634
+ const payload = value;
3635
+ if (payload?.status) controller.setStatus(payload.status, payload.message);
3636
+ }
3637
+ },
3638
+ async onCommand(commandId, params) {
3639
+ switch (commandId) {
3640
+ case "get_summary": {
3641
+ const state = controller.getState();
3642
+ return {
3643
+ status: state.status,
3644
+ identical: state.identical,
3645
+ totals: state.totals,
3646
+ review: state.review,
3647
+ files: state.files.map((f) => {
3648
+ const counts = countChanges(f.file);
3649
+ return {
3650
+ path: f.file.path,
3651
+ status: f.file.status,
3652
+ omitted: f.file.omitted,
3653
+ hunks: f.file.hunks.length,
3654
+ ...counts
3655
+ };
3656
+ })
3657
+ };
3658
+ }
3659
+ case "next_hunk":
3660
+ return controller.nextHunk();
3661
+ case "previous_hunk":
3662
+ return controller.previousHunk();
3663
+ case "set_view":
3664
+ controller.setView(params.view === "split" ? "split" : "unified");
3665
+ return true;
3666
+ default:
3667
+ return;
3668
+ }
3669
+ },
3670
+ serialize() {
3671
+ return controller.serialize();
3672
+ },
3673
+ deserialize(state) {
3674
+ controller.deserialize(state);
3675
+ },
3676
+ dispose() {
3677
+ unbindConfig();
3678
+ currentEl = null;
3679
+ unrender?.();
3680
+ unrender = null;
3681
+ controller.dispose();
3682
+ }
3683
+ };
3684
+ }
3685
+ };
3686
+ }
3687
+ var diffPanel = createDiffPanel();
3688
+ export {
3689
+ ALL_STATUSES,
3690
+ CONSOLE_PANEL_ID,
3691
+ ConsoleController,
3692
+ ConsolePanelView,
3693
+ DEFAULT_MAX_STEP_DEPTH,
3694
+ DIFF_PANEL_ID,
3695
+ DiffController,
3696
+ DiffPanelView,
3697
+ LEVEL_ORDER,
3698
+ MESSAGE_ELISION,
3699
+ REDACTION_CARRY,
3700
+ RUNS_PANEL_ID,
3701
+ RUN_PATH_SEPARATOR,
3702
+ RUN_STATUSES,
3703
+ RunsController,
3704
+ RunsPanelView,
3705
+ TERMINAL_PANEL_ID,
3706
+ TERMINAL_STATUSES,
3707
+ TerminalController,
3708
+ TerminalPanelView,
3709
+ XENO_TERMINAL_THEME_KEYS,
3710
+ appendStepsAtPath,
3711
+ canCancel,
3712
+ canRetry,
3713
+ collapseAdjacent,
3714
+ computeStats,
3715
+ consoleManifest,
3716
+ consolePanel,
3717
+ countChanges,
3718
+ createConsolePanel,
3719
+ createDiffPanel,
3720
+ createRunsPanel,
3721
+ createTerminalPanel,
3722
+ decodeRunPath,
3723
+ diffManifest,
3724
+ diffPanel,
3725
+ durationOf,
3726
+ encodeRunPath,
3727
+ flattenHunks,
3728
+ flattenRunRows,
3729
+ formatDuration,
3730
+ formatRecord,
3731
+ isActive,
3732
+ isLive,
3733
+ isSettled,
3734
+ isTerminal,
3735
+ isWaiting,
3736
+ lifecycleOf,
3737
+ matchesSearch2 as matchesRunSearch,
3738
+ matchesSearch,
3739
+ measuredSize,
3740
+ meetsLevel,
3741
+ needsAttention,
3742
+ omissionLabel,
3743
+ patchStepAtPath,
3744
+ queueDurationOf,
3745
+ readTerminalTheme,
3746
+ resolveStepPath,
3747
+ runsManifest,
3748
+ runsPanel,
3749
+ safeJson,
3750
+ spliceRedactedAppend,
3751
+ splitRows,
3752
+ statusLabel,
3753
+ terminalManifest,
3754
+ terminalPanel,
3755
+ terminalThemeVar,
3756
+ truncateLogMessage
3757
+ };