@llblab/pi-actors 0.40.1 → 0.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,873 @@
1
+ /**
2
+ * Keyboard-driven actor inspector overlay.
3
+ * Zones: overlay shell, tabs, owned-run/subagent selection, compact striped rows
4
+ * Owns interactive TUI navigation; evidence parsing remains in inspector/session domains.
5
+ */
6
+ import * as path from "node:path";
7
+ import { stripVTControlCharacters } from "node:util";
8
+ import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
9
+ import * as Inspector from "./inspector.js";
10
+ const DETAIL_SECTION_BREAK = { sectionBreak: true };
11
+ export class ActorInspectorOverlay {
12
+ done;
13
+ ownerId;
14
+ stateRoot;
15
+ theme;
16
+ tui;
17
+ refreshTimer;
18
+ runIndex = 0;
19
+ communicationChannel = "all";
20
+ communicationFrom = "all";
21
+ communicationUnread = false;
22
+ contentStripeIndices = [];
23
+ detailCommunication;
24
+ detailOpen = false;
25
+ detailScroll = 0;
26
+ detailTurn;
27
+ detailView = "evidence";
28
+ readKeys = new Set();
29
+ focus = "tabs";
30
+ filterControlIndex = 0;
31
+ menuLevel = "filter";
32
+ selectorIndex = 0;
33
+ rowIndex = 0;
34
+ selectedRun;
35
+ subagentIndex = 0;
36
+ tab = "communications";
37
+ constructor(options) {
38
+ this.done = options.done;
39
+ this.ownerId = options.ownerId;
40
+ this.stateRoot = options.stateRoot;
41
+ this.theme = options.theme;
42
+ this.tui = options.tui;
43
+ this.refreshTimer = setInterval(() => this.tui.requestRender(), 1000);
44
+ this.refreshTimer.unref?.();
45
+ }
46
+ handleInput(data) {
47
+ const runs = this.runs();
48
+ this.ensureSelectedRun(runs);
49
+ if (matchesKey(data, "ctrl+c")) {
50
+ this.done();
51
+ return;
52
+ }
53
+ if (matchesKey(data, "escape")) {
54
+ if (this.focus === "select") {
55
+ if (this.menuLevel === "value") {
56
+ this.menuLevel = "filter";
57
+ this.selectorIndex = this.filterControlIndex;
58
+ }
59
+ else
60
+ this.focus = this.menuLevel === "run" ? "runs" : "tabs";
61
+ }
62
+ else if (this.focus === "detail")
63
+ this.backDetail();
64
+ else
65
+ this.done();
66
+ this.tui.requestRender();
67
+ return;
68
+ }
69
+ if (this.focus === "select") {
70
+ const options = this.menuOptions();
71
+ if (matchesKey(data, "up"))
72
+ this.selectorIndex = Math.max(0, this.selectorIndex - 1);
73
+ else if (matchesKey(data, "down"))
74
+ this.selectorIndex = Math.min(options.length - 1, this.selectorIndex + 1);
75
+ else if (matchesKey(data, "left"))
76
+ this.backMenu();
77
+ else if (matchesKey(data, "return") ||
78
+ (matchesKey(data, "right") && this.menuLevel !== "value"))
79
+ this.applyMenuOption(options[this.selectorIndex]);
80
+ this.tui.requestRender();
81
+ return;
82
+ }
83
+ if (this.focus === "detail") {
84
+ if (matchesKey(data, "up"))
85
+ this.detailScroll = Math.max(0, this.detailScroll - 1);
86
+ else if (matchesKey(data, "down"))
87
+ this.detailScroll += 1;
88
+ else if (matchesKey(data, "left"))
89
+ this.backDetail();
90
+ else if ((matchesKey(data, "right") || matchesKey(data, "return")) &&
91
+ this.detailView === "evidence" &&
92
+ this.tab === "turns") {
93
+ this.detailView = "transcript";
94
+ this.detailScroll = 0;
95
+ }
96
+ this.tui.requestRender();
97
+ return;
98
+ }
99
+ if (this.focus === "runs") {
100
+ if (matchesKey(data, "left"))
101
+ this.cycleRun(runs, -1);
102
+ else if (matchesKey(data, "right"))
103
+ this.cycleRun(runs, 1);
104
+ else if (matchesKey(data, "down"))
105
+ this.focus = "tabs";
106
+ else if (matchesKey(data, "return") && runs.length > 0)
107
+ this.openRunMenu();
108
+ }
109
+ else if (this.focus === "tabs") {
110
+ if (matchesKey(data, "left") || matchesKey(data, "right")) {
111
+ this.tab = this.tab === "communications" ? "turns" : "communications";
112
+ this.filterControlIndex = 0;
113
+ this.rowIndex = 0;
114
+ }
115
+ else if (matchesKey(data, "up"))
116
+ this.focus = "runs";
117
+ else if (matchesKey(data, "down") && this.listItemCount() > 0)
118
+ this.focus = "list";
119
+ else if (matchesKey(data, "return"))
120
+ this.openFilterMenu();
121
+ }
122
+ else if (this.focus === "list") {
123
+ const count = this.tab === "communications" ? this.communicationPreviews().length : this.turnItems().length;
124
+ if (matchesKey(data, "up")) {
125
+ if (this.rowIndex === 0)
126
+ this.focus = "tabs";
127
+ else
128
+ this.rowIndex -= 1;
129
+ }
130
+ else if (matchesKey(data, "down"))
131
+ this.rowIndex = Math.min(Math.max(0, count - 1), this.rowIndex + 1);
132
+ else if (matchesKey(data, "return") || matchesKey(data, "right"))
133
+ this.openDetail();
134
+ }
135
+ this.tui.requestRender();
136
+ }
137
+ render(width) {
138
+ this.ensureSelectedRun(this.runs());
139
+ const safeWidth = Math.max(24, width);
140
+ const innerWidth = Math.max(1, safeWidth - 2);
141
+ const lines = [];
142
+ lines.push(this.border("╭", " Actor Inspector ", "╮", innerWidth));
143
+ lines.push(this.row(this.renderRunControl(), innerWidth));
144
+ let selector = this.focus === "select"
145
+ ? this.menuLevel === "run"
146
+ ? this.renderMenuBox(this.runs().map((run, index, runs) => `#${runs.length - index} ${run.run} ${run.status}`), this.selectorIndex, undefined, undefined, false, false, this.contentViewportRows())
147
+ : this.renderFilterMenus(innerWidth)
148
+ : [];
149
+ const selectorAnchor = this.selectorAnchor();
150
+ if (this.menuLevel === "run" && selector.length > 0) {
151
+ const popup = selector[0];
152
+ const popupWidth = visibleWidth(popup);
153
+ const leading = this.fit(this.takeVisiblePrefix(this.renderTabs(), selectorAnchor), selectorAnchor);
154
+ const trailingWidth = Math.max(0, innerWidth - selectorAnchor - popupWidth);
155
+ const trailing = this.fit(this.dropVisiblePrefix(this.renderTabs(), selectorAnchor + popupWidth), trailingWidth);
156
+ lines.push(this.row(`${leading}${popup}${trailing}`, innerWidth, true));
157
+ selector = selector.slice(1);
158
+ }
159
+ else
160
+ lines.push(this.row(this.renderTabs(), innerWidth));
161
+ const selectorTop = selector[0];
162
+ if (selectorTop) {
163
+ const leadingBorder = "─".repeat(selectorAnchor);
164
+ const remainingBorder = "─".repeat(Math.max(0, innerWidth - visibleWidth(selectorTop) - selectorAnchor));
165
+ lines.push(`${this.theme.fg("border", `├${leadingBorder}`)}${selectorTop}${this.theme.fg("border", `${remainingBorder}┤`)}`);
166
+ }
167
+ else
168
+ lines.push(this.border("├", "", "┤", innerWidth));
169
+ this.contentStripeIndices = [];
170
+ const content = this.detailOpen
171
+ ? this.renderDetail(innerWidth)
172
+ : this.selectedRun
173
+ ? this.renderTimeline(innerWidth)
174
+ : this.renderRunSelector(innerWidth);
175
+ const contentViewportRows = this.contentViewportRows();
176
+ const selectorRows = selector.slice(1);
177
+ for (let index = 0; index < Math.max(content.length, contentViewportRows); index += 1) {
178
+ const base = content[index] ?? "";
179
+ const stripeIndex = index < content.length
180
+ ? this.contentStripeIndices[index] ?? index
181
+ : 0;
182
+ const popup = selectorRows[index];
183
+ if (!popup) {
184
+ lines.push(this.stripedRow(base, innerWidth, stripeIndex));
185
+ continue;
186
+ }
187
+ const transparentPrefix = popup.match(/^ +/)?.[0].length ?? 0;
188
+ const visiblePopup = popup.slice(transparentPrefix);
189
+ const popupWidth = visibleWidth(visiblePopup);
190
+ const popupAnchor = selectorAnchor + transparentPrefix;
191
+ const baseWidth = Math.max(0, innerWidth - popupWidth - popupAnchor);
192
+ const leadingBase = this.stripeBackground(this.fit(this.takeVisiblePrefix(base, popupAnchor), popupAnchor), stripeIndex);
193
+ const preservedBase = this.stripeBackground(this.fit(this.dropVisiblePrefix(base, popupWidth + popupAnchor), baseWidth), stripeIndex);
194
+ lines.push(this.row(`${leadingBase}${visiblePopup}${preservedBase}`, innerWidth, true));
195
+ }
196
+ lines.push(this.border("├", "", "┤", innerWidth));
197
+ lines.push(this.row(this.renderKeyHints(), innerWidth));
198
+ lines.push(this.border("╰", "", "╯", innerWidth));
199
+ return lines;
200
+ }
201
+ invalidate() { }
202
+ dispose() {
203
+ clearInterval(this.refreshTimer);
204
+ }
205
+ runs() {
206
+ return Inspector.readActorInspectorRuns(this.stateRoot, this.ownerId);
207
+ }
208
+ ensureSelectedRun(runs) {
209
+ if (!this.selectedRun)
210
+ this.selectedRun = runs[0]?.run;
211
+ }
212
+ contentViewportRows() {
213
+ const overlayRows = Math.floor(this.tui.terminal.rows * 0.94);
214
+ return Math.max(4, Math.min(16, overlayRows - 7));
215
+ }
216
+ selectRun(run, index) {
217
+ this.selectedRun = run;
218
+ this.runIndex = index;
219
+ this.rowIndex = 0;
220
+ this.subagentIndex = 0;
221
+ this.communicationFrom = "all";
222
+ }
223
+ cycleRun(runs, direction) {
224
+ if (runs.length === 0)
225
+ return;
226
+ const currentIndex = Math.max(0, runs.findIndex((run) => run.run === this.selectedRun));
227
+ const nextIndex = (currentIndex + direction + runs.length) % runs.length;
228
+ const next = runs[nextIndex];
229
+ if (next)
230
+ this.selectRun(next.run, nextIndex);
231
+ }
232
+ listItemCount() {
233
+ return this.tab === "communications"
234
+ ? this.communicationPreviews().length
235
+ : this.turnItems().length;
236
+ }
237
+ selectorAnchor() {
238
+ if (this.menuLevel === "run") {
239
+ const runControl = stripVTControlCharacters(this.renderRunControl());
240
+ return Math.max(0, runControl.indexOf("Run:") - 2);
241
+ }
242
+ const label = this.tab === "communications" ? "Messages" : "Turns";
243
+ const tabs = stripVTControlCharacters(this.renderTabs());
244
+ return Math.max(0, tabs.indexOf(label) - 2);
245
+ }
246
+ renderKeyHints() {
247
+ const hint = (keys, description) => `${this.theme.fg("accent", keys)}${this.theme.fg("dim", ` ${description}`)}`;
248
+ if (this.focus === "select")
249
+ return this.menuLevel === "value"
250
+ ? ` ${hint("↑↓", "option")} ${hint("enter", "apply")} ${hint("←/esc", "back")}`
251
+ : ` ${hint("↑↓", "option")} ${hint("→/enter", "open")} ${hint("←/esc", "back")}`;
252
+ if (this.focus === "detail")
253
+ return this.tab === "turns" && this.detailView === "evidence"
254
+ ? ` ${hint("↑↓", "scroll")} ${hint("→/enter", "readable")} ${hint("←/esc", "back")}`
255
+ : ` ${hint("↑↓", "scroll")} ${hint("←/esc", this.detailView === "transcript" ? "evidence" : "back")}`;
256
+ if (this.focus === "list")
257
+ return ` ${hint("↑↓", "row")} ${hint("→/enter", "open")} ${hint("esc", "close")}`;
258
+ if (this.focus === "runs")
259
+ return ` ${hint("←→", "run")} ${hint("↓", "tabs")} ${hint("enter", "list")} ${hint("esc", "close")}`;
260
+ return ` ${hint("←→", "navigate")} ${hint("↑↓", "change row")} ${hint("enter", "select")} ${hint("esc", "close")}`;
261
+ }
262
+ renderRunControl() {
263
+ const runs = this.runs();
264
+ const runIndex = runs.findIndex((item) => item.run === this.selectedRun);
265
+ const run = runs[runIndex];
266
+ const focused = this.focus === "runs";
267
+ const active = focused || (this.focus === "select" && this.menuLevel === "run");
268
+ const prefix = active ? this.theme.fg("accent", " ← ") : " ";
269
+ const suffix = active ? this.theme.fg("accent", " → ") : " ";
270
+ if (!run) {
271
+ const empty = this.theme.fg("muted", `${prefix}Run: none${suffix}`);
272
+ return focused ? this.theme.bg("selectedBg", empty) : empty;
273
+ }
274
+ const statusColor = run.status === "failed"
275
+ ? "error"
276
+ : run.status === "running"
277
+ ? "warning"
278
+ : run.status === "done"
279
+ ? "success"
280
+ : "muted";
281
+ const sequence = runs.length - runIndex;
282
+ const value = `${prefix}${this.theme.fg("muted", "Run: ")}${this.theme.fg("text", `#${sequence}`)} ${this.theme.fg("accent", run.run)} ${this.theme.fg(statusColor, run.status)}${suffix}`;
283
+ return focused ? this.theme.bg("selectedBg", value) : value;
284
+ }
285
+ activeMessageFilters() {
286
+ return [
287
+ ...(this.communicationChannel === "all" ? [] : [this.communicationChannel]),
288
+ ...(this.communicationUnread ? ["unread"] : []),
289
+ ...(this.communicationFrom === "all" ? [] : [this.communicationFrom]),
290
+ ];
291
+ }
292
+ renderTabs() {
293
+ const tab = (id, label) => {
294
+ const selected = this.tab === id;
295
+ const focused = selected && this.focus === "tabs";
296
+ const runActive = this.focus === "runs" ||
297
+ (this.focus === "select" && this.menuLevel === "run");
298
+ const display = selected && !runActive ? `[ ${label} ]` : ` ${label} `;
299
+ const value = ` ${display} `;
300
+ if (!selected)
301
+ return this.theme.fg("muted", value);
302
+ const colored = this.theme.fg("accent", value);
303
+ return focused ? this.theme.bg("selectedBg", colored) : colored;
304
+ };
305
+ const messageFilters = this.activeMessageFilters();
306
+ const subagent = this.subagents()[this.subagentIndex] ?? "all";
307
+ const messagesLabel = `Messages${messageFilters.length > 0 ? ` (${messageFilters.join(", ")})` : ""}`;
308
+ const turnsLabel = `Turns${subagent === "all" ? "" : ` (${subagent})`}`;
309
+ return `${tab("communications", messagesLabel)} ${tab("turns", turnsLabel)}`;
310
+ }
311
+ renderRunSelector(width) {
312
+ const runs = this.runs();
313
+ if (runs.length === 0)
314
+ return [this.theme.fg("muted", " No owned actor runs")];
315
+ this.runIndex = Math.min(this.runIndex, runs.length - 1);
316
+ return runs.slice(0, this.contentViewportRows()).map((run, index) => {
317
+ const selected = index === this.runIndex;
318
+ const prefix = selected ? this.theme.fg("accent", " ▶ ") : " ";
319
+ const label = `${prefix}#${runs.length - index} run:${run.run}`;
320
+ const status = ` ${run.status}`;
321
+ return selected
322
+ ? this.theme.fg("accent", `${label}${status}`)
323
+ : `${label}${this.theme.fg("muted", status)}`;
324
+ });
325
+ }
326
+ renderTimeline(width) {
327
+ if (!this.selectedRun)
328
+ return [];
329
+ const run = this.runs().find((item) => item.run === this.selectedRun);
330
+ if (!run) {
331
+ this.selectedRun = undefined;
332
+ return [this.theme.fg("warning", " Selected run is no longer owned by this session")];
333
+ }
334
+ const rows = this.tab === "communications"
335
+ ? this.communicationRows()
336
+ : this.turnRows();
337
+ if (rows.length === 0) {
338
+ this.contentStripeIndices = [0];
339
+ const filtered = this.tab === "communications"
340
+ ? this.activeMessageFilters().length > 0
341
+ : (this.subagents()[this.subagentIndex] ?? "all") !== "all";
342
+ return [
343
+ this.theme.fg("muted", filtered
344
+ ? " No results for the active filters. Enter on the tab to adjust."
345
+ : this.tab === "communications"
346
+ ? " No messages in this run"
347
+ : " No persisted turns in this run"),
348
+ ];
349
+ }
350
+ this.rowIndex = Math.min(this.rowIndex, rows.length - 1);
351
+ const viewportRows = this.contentViewportRows();
352
+ const start = Math.max(0, Math.min(this.rowIndex - Math.floor(viewportRows / 2), Math.max(0, rows.length - viewportRows)));
353
+ const stripeIndices = this.tab === "communications"
354
+ ? this.communicationPreviews().map((preview, index) => Math.max(0, (preview.sequence ?? index + 1) - 1))
355
+ : this.turnItems().map((turn) => Math.max(0, turn.index - 1));
356
+ const visibleRows = rows.slice(start, start + viewportRows);
357
+ this.contentStripeIndices = visibleRows.map((_, offset) => stripeIndices[start + offset] ?? start + offset);
358
+ return visibleRows.map((row, offset) => {
359
+ const index = start + offset;
360
+ const selected = this.focus === "list" && index === this.rowIndex;
361
+ return selected
362
+ ? this.theme.fg("accent", ` ▶ ${row}`)
363
+ : ` ${row}`;
364
+ });
365
+ }
366
+ communicationPreviews() {
367
+ if (!this.selectedRun)
368
+ return [];
369
+ const channels = this.communicationChannel === "all"
370
+ ? undefined
371
+ : [this.communicationChannel];
372
+ return Inspector.readActorInspectorPreviews(this.stateRoot, 100, {
373
+ channels,
374
+ currentRunOnly: false,
375
+ ownerId: this.ownerId,
376
+ readKeys: this.readKeys,
377
+ run: this.selectedRun,
378
+ unreadOnly: this.communicationUnread,
379
+ })
380
+ .filter((preview) => this.communicationFrom === "all" ||
381
+ this.communicationActorLabel(preview.from_display ?? preview.from) ===
382
+ this.communicationFrom)
383
+ .reverse();
384
+ }
385
+ communicationActorLabel(actor) {
386
+ const value = actor ?? "unknown";
387
+ if (value.startsWith("run:"))
388
+ return value.slice("run:".length);
389
+ return value.replace(/^branch:[^/]+\//, "");
390
+ }
391
+ communicationFromOptions() {
392
+ if (!this.selectedRun)
393
+ return ["all"];
394
+ const actors = Inspector.readActorInspectorRoster(this.stateRoot, this.selectedRun).map((member) => this.communicationActorLabel(member.display ?? member.address));
395
+ return ["all", ...new Set(actors)];
396
+ }
397
+ communicationRows() {
398
+ return this.communicationPreviews().map((preview) => {
399
+ const actor = preview.from_display ?? preview.from ?? "unknown";
400
+ const target = preview.channel === "room" ? "#all" : preview.to;
401
+ const text = preview.summary ?? preview.body_preview ?? "(no body)";
402
+ return `#${preview.sequence ?? "?"} ${preview.needs_response ? "! " : ""}${actor} → ${target} ${preview.type} ${text}`;
403
+ });
404
+ }
405
+ renderDetail(width) {
406
+ if (this.tab === "communications") {
407
+ const preview = this.detailCommunication;
408
+ if (!preview)
409
+ return [this.theme.fg("warning", " Communication row is no longer available")];
410
+ const fields = [
411
+ ["channel", preview.channel],
412
+ ["run", preview.run],
413
+ ["from", preview.from_display ?? preview.from],
414
+ ["to", preview.to],
415
+ ["type", preview.type],
416
+ ["summary", preview.summary],
417
+ ["body", preview.body_preview],
418
+ ["attention", preview.needs_response],
419
+ ["inbox", preview.inbox_status],
420
+ ["timestamp", preview.timestamp],
421
+ ];
422
+ return fields
423
+ .filter(([, value]) => value !== undefined)
424
+ .map(([label, value]) => truncateToWidth(` ${label.padEnd(12, " ")} ${typeof value === "string" ? value : JSON.stringify(value)}`, width, "…"));
425
+ }
426
+ const turn = this.detailTurn;
427
+ if (!turn)
428
+ return [this.theme.fg("warning", " Turn row is no longer available")];
429
+ const logicalLines = this.detailView === "transcript"
430
+ ? this.turnTranscriptLines(turn)
431
+ : this.turnEvidenceLines(turn);
432
+ const wrapped = this.wrapDetailLines(logicalLines, width);
433
+ const viewportRows = this.contentViewportRows();
434
+ const maxStart = Math.max(0, wrapped.lines.length - viewportRows);
435
+ this.detailScroll = Math.min(this.detailScroll, maxStart);
436
+ this.contentStripeIndices = wrapped.stripeIndices.slice(this.detailScroll, this.detailScroll + viewportRows);
437
+ return wrapped.lines.slice(this.detailScroll, this.detailScroll + viewportRows);
438
+ }
439
+ wrapDetailLines(lines, width) {
440
+ const wrappedLines = [];
441
+ const stripeIndices = [];
442
+ let sectionIndex = 0;
443
+ for (const line of lines) {
444
+ if (typeof line !== "string") {
445
+ sectionIndex += 1;
446
+ continue;
447
+ }
448
+ const physicalLines = line.replaceAll("\t", " ").split(/\r?\n/);
449
+ for (const physicalLine of physicalLines) {
450
+ if (!physicalLine.trim())
451
+ continue;
452
+ const leading = physicalLine.match(/^ */)?.[0] ?? "";
453
+ const content = physicalLine.slice(leading.length);
454
+ const contentWidth = Math.max(1, width - visibleWidth(leading));
455
+ const wrapped = visibleWidth(physicalLine) <= width
456
+ ? [physicalLine]
457
+ : wrapTextWithAnsi(content, contentWidth).map((visualLine) => `${leading}${visualLine}`);
458
+ for (const visualLine of wrapped) {
459
+ if (!visualLine.trim())
460
+ continue;
461
+ wrappedLines.push(visualLine);
462
+ stripeIndices.push(sectionIndex);
463
+ }
464
+ }
465
+ }
466
+ return { lines: wrappedLines, stripeIndices };
467
+ }
468
+ detailSection(title, content) {
469
+ if (content.length === 0)
470
+ return [];
471
+ return [
472
+ ` ${title}`,
473
+ ...content.flatMap((line) => line
474
+ .split(/\r?\n/)
475
+ .filter((physicalLine) => physicalLine.trim().length > 0)
476
+ .map((physicalLine) => ` ${physicalLine}`)),
477
+ DETAIL_SECTION_BREAK,
478
+ ];
479
+ }
480
+ readableValueLines(value, indent = "", depth = 0) {
481
+ if (depth > 8)
482
+ return [`${indent}[nested value]`];
483
+ if (typeof value === "string") {
484
+ const trimmed = value.trim();
485
+ if (/^[{[]/.test(trimmed)) {
486
+ try {
487
+ return this.readableValueLines(JSON.parse(trimmed), indent, depth + 1);
488
+ }
489
+ catch {
490
+ // Keep non-JSON text as written.
491
+ }
492
+ }
493
+ return value.split(/\r?\n/).map((line) => `${indent}${line}`);
494
+ }
495
+ if (value === null || value === undefined)
496
+ return [`${indent}${String(value)}`];
497
+ if (typeof value !== "object")
498
+ return [`${indent}${String(value)}`];
499
+ if (Array.isArray(value)) {
500
+ if (value.length === 0)
501
+ return [`${indent}(empty)`];
502
+ return value.flatMap((item) => {
503
+ if (item &&
504
+ typeof item === "object" &&
505
+ item.type === "text" &&
506
+ typeof item.text === "string") {
507
+ return this.readableValueLines(item.text, indent, depth + 1);
508
+ }
509
+ if (!item || typeof item !== "object")
510
+ return [`${indent}- ${String(item)}`];
511
+ return [
512
+ `${indent}-`,
513
+ ...this.readableValueLines(item, `${indent} `, depth + 1),
514
+ ];
515
+ });
516
+ }
517
+ const entries = Object.entries(value);
518
+ if (entries.length === 0)
519
+ return [`${indent}(empty)`];
520
+ return entries.flatMap(([key, item]) => {
521
+ if (!item || typeof item !== "object") {
522
+ const scalar = typeof item === "string" ? item : String(item);
523
+ const [first = "", ...rest] = scalar.split(/\r?\n/);
524
+ return [
525
+ `${indent}${key}: ${first}`,
526
+ ...rest.map((line) => `${indent} ${line}`),
527
+ ];
528
+ }
529
+ return [
530
+ `${indent}${key}:`,
531
+ ...this.readableValueLines(item, `${indent} `, depth + 1),
532
+ ];
533
+ });
534
+ }
535
+ turnEvidenceLines(turn) {
536
+ const lines = [];
537
+ const commandNumber = /^command-(\d+)$/.exec(turn.commandId)?.[1];
538
+ const subagent = commandNumber
539
+ ? String(Number.parseInt(commandNumber, 10))
540
+ : turn.commandId;
541
+ const stage = turn.stage && turn.stage !== "subagent" && turn.stage !== "command"
542
+ ? ` (${turn.stage})`
543
+ : "";
544
+ lines.push(` Subagent ${subagent}${stage}`, DETAIL_SECTION_BREAK);
545
+ if (turn.userText)
546
+ lines.push(...this.detailSection("User", [turn.userText]));
547
+ if (turn.thinking)
548
+ lines.push(...this.detailSection("Thinking", [turn.thinking]));
549
+ if (turn.assistantText)
550
+ lines.push(...this.detailSection("Assistant", [turn.assistantText]));
551
+ for (const [index, tool] of turn.toolCalls.entries()) {
552
+ const content = [
553
+ ...(tool.arguments === undefined
554
+ ? []
555
+ : ["Input", ...this.readableValueLines(tool.arguments, " ")]),
556
+ ...(tool.result === undefined
557
+ ? []
558
+ : ["Result", ...this.readableValueLines(tool.result, " ")]),
559
+ `call id: ${tool.id}`,
560
+ ];
561
+ lines.push(...this.detailSection(`Tool ${index + 1} (${tool.name}${tool.resultError ? ", Error" : ""})`, content));
562
+ }
563
+ const execution = [
564
+ ...(!turn.thinking ? ["thinking: not persisted"] : []),
565
+ ...(turn.model ? [`model: ${turn.model}`] : []),
566
+ ...(turn.stopReason ? [`stop reason: ${turn.stopReason}`] : []),
567
+ ...(turn.error ? [`error: ${turn.error}`] : []),
568
+ ...(turn.usage === undefined
569
+ ? []
570
+ : ["Usage", ...this.readableValueLines(turn.usage, " ")]),
571
+ ...(turn.unmatchedToolResults > 0
572
+ ? [`unmatched tool results: ${turn.unmatchedToolResults}`]
573
+ : []),
574
+ ];
575
+ if (execution.length > 0)
576
+ lines.push(...this.detailSection("Execution", execution));
577
+ if (turn.diagnostics.length > 0)
578
+ lines.push(...this.detailSection("Diagnostics", turn.diagnostics));
579
+ const provenance = [
580
+ "Session",
581
+ ` ${turn.sessionFile}`,
582
+ ...(turn.sessionTruncated ? [" turn list truncated"] : []),
583
+ ...(turn.promptFile
584
+ ? [
585
+ "Prompt",
586
+ ` ${turn.promptFile}${turn.promptBytes ? ` (${turn.promptBytes} bytes)` : ""}`,
587
+ ]
588
+ : []),
589
+ ...(turn.recipeContext === undefined
590
+ ? []
591
+ : [
592
+ "Recipe Context",
593
+ ...this.readableValueLines(turn.recipeContext, " "),
594
+ ]),
595
+ ];
596
+ lines.push(...this.detailSection("Provenance", provenance));
597
+ return lines.length > 0 ? lines : [" No persisted turn evidence"];
598
+ }
599
+ readablePromptText(text) {
600
+ const wrapped = /^\s*<file\b[^>]*>\s*([\s\S]*?)\s*<\/file>\s*$/i.exec(text);
601
+ return wrapped?.[1]?.trim() || text;
602
+ }
603
+ turnTranscriptLines(turn) {
604
+ const lines = [];
605
+ if (turn.userText)
606
+ lines.push(...this.detailSection("User", [this.readablePromptText(turn.userText)]));
607
+ if (turn.thinking)
608
+ lines.push(...this.detailSection("Thinking", [turn.thinking]));
609
+ if (turn.assistantText)
610
+ lines.push(...this.detailSection("Assistant", [turn.assistantText]));
611
+ for (const tool of turn.toolCalls) {
612
+ const content = [
613
+ ...(tool.arguments === undefined
614
+ ? []
615
+ : ["Input", ...this.readableValueLines(tool.arguments, " ")]),
616
+ ...(tool.result === undefined
617
+ ? []
618
+ : ["Result", ...this.readableValueLines(tool.result, " ")]),
619
+ ];
620
+ lines.push(...this.detailSection(`Tool (${tool.name}${tool.resultError ? ", Error" : ""})`, content));
621
+ }
622
+ if (turn.error)
623
+ lines.push(...this.detailSection("Error", [turn.error]));
624
+ return lines.length > 0 ? lines : [" No readable turn content"];
625
+ }
626
+ turnItems() {
627
+ if (!this.selectedRun)
628
+ return [];
629
+ const selectedSubagent = this.subagents()[this.subagentIndex];
630
+ return Inspector.readActorInspectorTurns(path.join(this.stateRoot, this.selectedRun))
631
+ .filter((turn) => !selectedSubagent ||
632
+ selectedSubagent === "all" ||
633
+ turn.commandId === selectedSubagent)
634
+ .reverse();
635
+ }
636
+ turnRows() {
637
+ return this.turnItems().map((turn) => {
638
+ const commandNumber = /^command-(\d+)$/.exec(turn.commandId)?.[1];
639
+ const subagent = commandNumber
640
+ ? `Subagent ${Number.parseInt(commandNumber, 10)}`
641
+ : `Subagent ${turn.commandId}`;
642
+ const stage = turn.stage && turn.stage !== "subagent" && turn.stage !== "command"
643
+ ? ` (${turn.stage})`
644
+ : "";
645
+ const toolNames = [...new Set(turn.toolCalls.map((tool) => tool.name))];
646
+ const action = turn.toolCalls.length === 0
647
+ ? ""
648
+ : turn.toolCalls.length === 1
649
+ ? ` (${toolNames[0]})`
650
+ : toolNames.length === 1
651
+ ? ` (${toolNames[0]}, ${turn.toolCalls.length} calls)`
652
+ : turn.toolCalls.length <= 2
653
+ ? ` (${toolNames.join(", ")})`
654
+ : ` (${turn.toolCalls.length} tools)`;
655
+ const failed = turn.error || turn.toolCalls.some((tool) => tool.resultError)
656
+ ? " (error)"
657
+ : "";
658
+ const preview = this.readablePromptText(turn.assistantText ?? turn.userText ?? "(no text)").replaceAll(/\s+/g, " ").trim();
659
+ return `#${turn.index} ${subagent}${stage} ${turn.model ?? "unknown model"}${action}${failed} ${preview}`;
660
+ });
661
+ }
662
+ subagents() {
663
+ if (!this.selectedRun || this.tab !== "turns")
664
+ return ["all"];
665
+ const ids = Inspector.readActorInspectorTurns(path.join(this.stateRoot, this.selectedRun)).map((turn) => turn.commandId);
666
+ return ["all", ...new Set(ids)];
667
+ }
668
+ valueOptions() {
669
+ if (this.tab === "turns")
670
+ return this.subagents();
671
+ if (this.filterControlIndex === 0)
672
+ return ["all", "room", "direct", "broadcast"];
673
+ if (this.filterControlIndex === 1)
674
+ return ["all", "unread"];
675
+ return this.communicationFromOptions();
676
+ }
677
+ filterOptions() {
678
+ return this.tab === "communications"
679
+ ? ["Channel", "State", "From"]
680
+ : ["Subagent"];
681
+ }
682
+ menuOptions() {
683
+ if (this.menuLevel === "run")
684
+ return this.runs().map((run) => run.run);
685
+ if (this.menuLevel === "filter")
686
+ return this.filterOptions();
687
+ return this.valueOptions();
688
+ }
689
+ backMenu() {
690
+ if (this.menuLevel === "value") {
691
+ this.menuLevel = "filter";
692
+ this.selectorIndex = this.filterControlIndex;
693
+ return;
694
+ }
695
+ this.focus = this.menuLevel === "run" ? "runs" : "tabs";
696
+ }
697
+ openRunMenu() {
698
+ const options = this.runs();
699
+ this.selectorIndex = Math.max(0, options.findIndex((run) => run.run === this.selectedRun));
700
+ this.menuLevel = "run";
701
+ this.focus = "select";
702
+ }
703
+ openFilterMenu() {
704
+ this.selectorIndex = this.filterControlIndex;
705
+ this.menuLevel = "filter";
706
+ this.focus = "select";
707
+ }
708
+ applyMenuOption(value) {
709
+ if (!value)
710
+ return;
711
+ if (this.menuLevel === "run") {
712
+ this.selectRun(value, Math.max(0, this.runs().findIndex((run) => run.run === value)));
713
+ this.focus = "runs";
714
+ return;
715
+ }
716
+ if (this.menuLevel === "filter") {
717
+ this.filterControlIndex = this.selectorIndex;
718
+ const options = this.valueOptions();
719
+ const current = this.currentFilterValue();
720
+ this.selectorIndex = Math.max(0, options.indexOf(current));
721
+ this.menuLevel = "value";
722
+ return;
723
+ }
724
+ if (this.tab === "turns")
725
+ this.subagentIndex = Math.max(0, this.subagents().indexOf(value));
726
+ else if (this.filterControlIndex === 0)
727
+ this.communicationChannel = value;
728
+ else if (this.filterControlIndex === 1)
729
+ this.communicationUnread = value === "unread";
730
+ else
731
+ this.communicationFrom = value;
732
+ this.rowIndex = 0;
733
+ this.menuLevel = "filter";
734
+ this.selectorIndex = this.filterControlIndex;
735
+ }
736
+ filterValue(index) {
737
+ if (this.tab === "turns")
738
+ return this.subagents()[this.subagentIndex] ?? "all";
739
+ if (index === 0)
740
+ return this.communicationChannel;
741
+ if (index === 1)
742
+ return this.communicationUnread ? "unread" : "all";
743
+ return this.communicationFrom;
744
+ }
745
+ currentFilterValue() {
746
+ return this.filterValue(this.filterControlIndex);
747
+ }
748
+ renderMenuBox(options, focusedIndex, current, parentIndex, omitLeftBorder = false, omitRightBorder = false, maxVisibleOptions = options.length) {
749
+ const visibleLimit = Math.max(1, Math.min(maxVisibleOptions, options.length));
750
+ const anchorIndex = Math.max(0, Math.min(options.length - 1, focusedIndex >= 0 ? focusedIndex : (parentIndex ?? 0)));
751
+ const maxStart = Math.max(0, options.length - visibleLimit);
752
+ const start = Math.max(0, Math.min(anchorIndex - Math.floor(visibleLimit / 2), maxStart));
753
+ const visibleOptions = options.slice(start, start + visibleLimit);
754
+ const hiddenAbove = start > 0;
755
+ const hiddenBelow = start + visibleOptions.length < options.length;
756
+ const contentWidth = Math.max(4, ...options.map((option) => visibleWidth(option) + 4));
757
+ const border = (left, right, marker = "") => this.theme.fg("border", `${omitLeftBorder ? "" : left}${marker}${"─".repeat(Math.max(0, contentWidth - visibleWidth(marker)))}${omitRightBorder ? "" : right}`);
758
+ return [
759
+ border("╭", "╮", hiddenAbove ? "↑" : ""),
760
+ ...visibleOptions.map((option, localIndex) => {
761
+ const index = start + localIndex;
762
+ const focused = index === focusedIndex;
763
+ const row = this.fit(`${focused ? " ▶ " : " "}${option}`, contentWidth);
764
+ const colored = focused || option === current || index === parentIndex
765
+ ? this.theme.fg("accent", row)
766
+ : row;
767
+ const styled = focused || index === parentIndex
768
+ ? this.theme.bg("selectedBg", colored)
769
+ : colored;
770
+ return `${omitLeftBorder ? "" : this.theme.fg("border", "│")}${styled}${omitRightBorder ? "" : this.theme.fg("border", "│")}`;
771
+ }),
772
+ border("╰", "╯", hiddenBelow ? "↓" : ""),
773
+ ];
774
+ }
775
+ renderFilterMenus(_width) {
776
+ const filters = this.filterOptions().map((filter, index) => `${filter}: ${this.filterValue(index)}`);
777
+ const maxVisibleOptions = Math.max(1, this.contentViewportRows() - 1);
778
+ const parentFocus = this.menuLevel === "filter" ? this.selectorIndex : -1;
779
+ const parent = this.renderMenuBox(filters, parentFocus, undefined, this.menuLevel === "value" ? this.filterControlIndex : undefined, false, false, maxVisibleOptions);
780
+ if (this.menuLevel !== "value")
781
+ return parent;
782
+ const parentShared = this.renderMenuBox(filters, parentFocus, undefined, this.filterControlIndex, false, true, maxVisibleOptions);
783
+ const child = this.renderMenuBox(this.valueOptions(), this.selectorIndex, this.currentFilterValue(), undefined, false, false, maxVisibleOptions);
784
+ return Array.from({ length: Math.max(parent.length, child.length) }, (_, index) => child[index]
785
+ ? `${parentShared[index] ?? " ".repeat(visibleWidth(parentShared[0]))}${child[index]}`
786
+ : (parent[index] ?? ""));
787
+ }
788
+ openDetail() {
789
+ if (this.tab === "communications") {
790
+ const preview = this.communicationPreviews()[this.rowIndex];
791
+ this.detailCommunication = preview;
792
+ if (preview)
793
+ this.readKeys.add(Inspector.inspectorPreviewReadKey(preview));
794
+ }
795
+ else
796
+ this.detailTurn = this.turnItems()[this.rowIndex];
797
+ this.detailScroll = 0;
798
+ this.detailView = "evidence";
799
+ this.detailOpen = Boolean(this.detailCommunication || this.detailTurn);
800
+ if (this.detailOpen)
801
+ this.focus = "detail";
802
+ }
803
+ backDetail() {
804
+ if (this.detailView === "transcript") {
805
+ this.detailView = "evidence";
806
+ this.detailScroll = 0;
807
+ return;
808
+ }
809
+ this.closeDetail();
810
+ }
811
+ closeDetail() {
812
+ this.detailOpen = false;
813
+ this.detailCommunication = undefined;
814
+ this.detailTurn = undefined;
815
+ this.detailScroll = 0;
816
+ this.detailView = "evidence";
817
+ this.focus = "list";
818
+ }
819
+ border(left, title, right, width) {
820
+ const titleText = truncateToWidth(title, width, "");
821
+ const fill = "─".repeat(Math.max(0, width - visibleWidth(titleText)));
822
+ return this.theme.fg("border", `${left}${titleText}${fill}${right}`);
823
+ }
824
+ stripeBackground(content, index) {
825
+ if (index % 2 === 0)
826
+ return content;
827
+ const segments = content.split("…");
828
+ return segments
829
+ .map((segment, segmentIndex) => `${this.theme.bg("customMessageBg", segment)}${segmentIndex < segments.length - 1 ? this.theme.bg("customMessageBg", "…") : ""}`)
830
+ .join("");
831
+ }
832
+ stripedRow(content, width, index) {
833
+ return this.row(this.stripeBackground(this.fit(content, width), index), width, true);
834
+ }
835
+ row(content, width, fitted = false) {
836
+ const body = fitted ? content : this.fit(content, width);
837
+ return `${this.theme.fg("border", "│")}${body}${this.theme.fg("border", "│")}`;
838
+ }
839
+ takeVisiblePrefix(content, width) {
840
+ const plain = stripVTControlCharacters(content);
841
+ let consumed = 0;
842
+ let result = "";
843
+ for (const character of plain) {
844
+ const characterWidth = visibleWidth(character);
845
+ if (consumed + characterWidth > width)
846
+ break;
847
+ result += character;
848
+ consumed += characterWidth;
849
+ }
850
+ return result;
851
+ }
852
+ dropVisiblePrefix(content, width) {
853
+ const plain = stripVTControlCharacters(content);
854
+ let consumed = 0;
855
+ let index = 0;
856
+ for (const character of plain) {
857
+ if (consumed >= width)
858
+ break;
859
+ consumed += visibleWidth(character);
860
+ index += character.length;
861
+ }
862
+ return plain.slice(index);
863
+ }
864
+ fit(content, width) {
865
+ // A Component render entry must remain exactly one terminal row. Persisted
866
+ // prompts, messages, and tool results may contain line breaks or tabs;
867
+ // passing those through lets them escape the overlay compositor and corrupt
868
+ // the underlying TUI during tab changes.
869
+ const singleLine = content.replace(/[\r\n\t]/g, " ");
870
+ const bounded = truncateToWidth(singleLine, width, "…");
871
+ return `${bounded}${" ".repeat(Math.max(0, width - visibleWidth(bounded)))}`;
872
+ }
873
+ }