@llblab/pi-actors 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/AGENTS.md +3 -3
  2. package/BACKLOG.md +22 -1
  3. package/CHANGELOG.md +31 -0
  4. package/README.md +5 -4
  5. package/dist/index.js +30 -51
  6. package/dist/lib/inspector-overlay.d.ts +84 -0
  7. package/dist/lib/inspector-overlay.js +627 -0
  8. package/dist/lib/inspector.d.ts +17 -33
  9. package/dist/lib/inspector.js +98 -151
  10. package/dist/lib/limits.d.ts +3 -0
  11. package/dist/lib/limits.js +3 -0
  12. package/dist/lib/observability.d.ts +1 -1
  13. package/dist/lib/observability.js +2 -2
  14. package/dist/lib/pi.d.ts +1 -1
  15. package/dist/lib/pi.js +2 -2
  16. package/dist/lib/prompts.d.ts +1 -1
  17. package/dist/lib/prompts.js +3 -3
  18. package/dist/lib/recipes-context.d.ts +5 -0
  19. package/dist/lib/recipes-context.js +15 -0
  20. package/dist/lib/session-evidence.d.ts +45 -0
  21. package/dist/lib/session-evidence.js +204 -0
  22. package/dist/lib/tools-response.d.ts +3 -0
  23. package/dist/lib/tools-response.js +31 -0
  24. package/dist/scripts/async-runner.mjs +40 -5
  25. package/dist/scripts/coordinator.mjs +55 -9
  26. package/dist/skills/actors/SKILL.md +10 -9
  27. package/dist/skills/swarm/SKILL.md +4 -2
  28. package/docs/README.md +1 -0
  29. package/docs/actor-inspector.md +72 -0
  30. package/docs/async-runs.md +2 -2
  31. package/index.ts +32 -63
  32. package/lib/inspector-overlay.ts +735 -0
  33. package/lib/inspector.ts +132 -204
  34. package/lib/limits.ts +3 -0
  35. package/lib/observability.ts +3 -3
  36. package/lib/pi.ts +3 -3
  37. package/lib/prompts.ts +3 -3
  38. package/lib/recipes-context.ts +25 -0
  39. package/lib/session-evidence.ts +302 -0
  40. package/lib/tools-response.ts +33 -0
  41. package/package.json +1 -1
  42. package/scripts/async-runner.mjs +40 -5
  43. package/scripts/coordinator.mjs +55 -9
  44. package/skills/actors/SKILL.md +10 -9
  45. package/skills/swarm/SKILL.md +4 -2
@@ -0,0 +1,627 @@
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, } from "@earendil-works/pi-tui";
9
+ import * as Inspector from "./inspector.js";
10
+ export class ActorInspectorOverlay {
11
+ done;
12
+ ownerId;
13
+ stateRoot;
14
+ theme;
15
+ tui;
16
+ refreshTimer;
17
+ runIndex = 0;
18
+ communicationChannel = "all";
19
+ communicationFrom = "all";
20
+ communicationUnread = false;
21
+ contentStripeIndices = [];
22
+ detailCommunication;
23
+ detailOpen = false;
24
+ detailScroll = 0;
25
+ detailTurn;
26
+ readKeys = new Set();
27
+ focus = "tabs";
28
+ filterControlIndex = 0;
29
+ menuLevel = "filter";
30
+ selectorIndex = 0;
31
+ rowIndex = 0;
32
+ selectedRun;
33
+ subagentIndex = 0;
34
+ tab = "communications";
35
+ constructor(options) {
36
+ this.done = options.done;
37
+ this.ownerId = options.ownerId;
38
+ this.stateRoot = options.stateRoot;
39
+ this.theme = options.theme;
40
+ this.tui = options.tui;
41
+ this.refreshTimer = setInterval(() => this.tui.requestRender(), 1000);
42
+ this.refreshTimer.unref?.();
43
+ }
44
+ handleInput(data) {
45
+ const runs = this.runs();
46
+ this.ensureSelectedRun(runs);
47
+ if (matchesKey(data, "ctrl+c")) {
48
+ this.done();
49
+ return;
50
+ }
51
+ if (matchesKey(data, "escape")) {
52
+ if (this.focus === "select") {
53
+ if (this.menuLevel === "value") {
54
+ this.menuLevel = "filter";
55
+ this.selectorIndex = this.filterControlIndex;
56
+ }
57
+ else
58
+ this.focus = this.menuLevel === "run" ? "runs" : "tabs";
59
+ }
60
+ else if (this.focus === "detail")
61
+ this.closeDetail();
62
+ else
63
+ this.done();
64
+ this.tui.requestRender();
65
+ return;
66
+ }
67
+ if (this.focus === "select") {
68
+ const options = this.menuOptions();
69
+ if (matchesKey(data, "up"))
70
+ this.selectorIndex = Math.max(0, this.selectorIndex - 1);
71
+ else if (matchesKey(data, "down"))
72
+ this.selectorIndex = Math.min(options.length - 1, this.selectorIndex + 1);
73
+ else if (matchesKey(data, "left"))
74
+ this.backMenu();
75
+ else if (matchesKey(data, "return") ||
76
+ (matchesKey(data, "right") && this.menuLevel !== "value"))
77
+ this.applyMenuOption(options[this.selectorIndex]);
78
+ this.tui.requestRender();
79
+ return;
80
+ }
81
+ if (this.focus === "detail") {
82
+ if (matchesKey(data, "up"))
83
+ this.detailScroll = Math.max(0, this.detailScroll - 1);
84
+ else if (matchesKey(data, "down"))
85
+ this.detailScroll += 1;
86
+ else if (matchesKey(data, "left"))
87
+ this.closeDetail();
88
+ this.tui.requestRender();
89
+ return;
90
+ }
91
+ if (this.focus === "runs") {
92
+ if (matchesKey(data, "down"))
93
+ this.focus = "tabs";
94
+ else if (matchesKey(data, "return") && runs.length > 0)
95
+ this.openRunMenu();
96
+ }
97
+ else if (this.focus === "tabs") {
98
+ if (matchesKey(data, "left") || matchesKey(data, "right")) {
99
+ this.tab = this.tab === "communications" ? "turns" : "communications";
100
+ this.filterControlIndex = 0;
101
+ this.rowIndex = 0;
102
+ }
103
+ else if (matchesKey(data, "up"))
104
+ this.focus = "runs";
105
+ else if (matchesKey(data, "down") && this.listItemCount() > 0)
106
+ this.focus = "list";
107
+ else if (matchesKey(data, "return"))
108
+ this.openFilterMenu();
109
+ }
110
+ else if (this.focus === "list") {
111
+ const count = this.tab === "communications" ? this.communicationPreviews().length : this.turnItems().length;
112
+ if (matchesKey(data, "up")) {
113
+ if (this.rowIndex === 0)
114
+ this.focus = "tabs";
115
+ else
116
+ this.rowIndex -= 1;
117
+ }
118
+ else if (matchesKey(data, "down"))
119
+ this.rowIndex = Math.min(Math.max(0, count - 1), this.rowIndex + 1);
120
+ else if (matchesKey(data, "return") || matchesKey(data, "right"))
121
+ this.openDetail();
122
+ }
123
+ this.tui.requestRender();
124
+ }
125
+ render(width) {
126
+ this.ensureSelectedRun(this.runs());
127
+ const safeWidth = Math.max(24, width);
128
+ const innerWidth = Math.max(1, safeWidth - 2);
129
+ const lines = [];
130
+ lines.push(this.border("╭", " Actor Inspector ", "╮", innerWidth));
131
+ lines.push(this.row(this.renderRunControl(), innerWidth));
132
+ let selector = this.focus === "select"
133
+ ? this.menuLevel === "run"
134
+ ? this.renderMenuBox(this.runs().map((run) => `${run.run} · ${run.status}`), this.selectorIndex)
135
+ : this.renderFilterMenus(innerWidth)
136
+ : [];
137
+ const selectorAnchor = this.selectorAnchor();
138
+ if (this.menuLevel === "run" && selector.length > 0) {
139
+ const popup = selector[0];
140
+ const popupWidth = visibleWidth(popup);
141
+ const leading = this.fit(this.takeVisiblePrefix(this.renderTabs(), selectorAnchor), selectorAnchor);
142
+ const trailingWidth = Math.max(0, innerWidth - selectorAnchor - popupWidth);
143
+ const trailing = this.fit(this.dropVisiblePrefix(this.renderTabs(), selectorAnchor + popupWidth), trailingWidth);
144
+ lines.push(this.row(`${leading}${popup}${trailing}`, innerWidth, true));
145
+ selector = selector.slice(1);
146
+ }
147
+ else
148
+ lines.push(this.row(this.renderTabs(), innerWidth));
149
+ const selectorTop = selector[0];
150
+ if (selectorTop) {
151
+ const leadingBorder = "─".repeat(selectorAnchor);
152
+ const remainingBorder = "─".repeat(Math.max(0, innerWidth - visibleWidth(selectorTop) - selectorAnchor));
153
+ lines.push(`${this.theme.fg("border", `├${leadingBorder}`)}${selectorTop}${this.theme.fg("border", `${remainingBorder}┤`)}`);
154
+ }
155
+ else
156
+ lines.push(this.border("├", "", "┤", innerWidth));
157
+ this.contentStripeIndices = [];
158
+ const content = this.detailOpen
159
+ ? this.renderDetail(innerWidth)
160
+ : this.selectedRun
161
+ ? this.renderTimeline(innerWidth)
162
+ : this.renderRunSelector(innerWidth);
163
+ const minContentRows = 16;
164
+ const selectorRows = selector.slice(1);
165
+ for (let index = 0; index < Math.max(content.length, minContentRows); index += 1) {
166
+ const base = content[index] ?? "";
167
+ const stripeIndex = this.contentStripeIndices[index] ?? index;
168
+ const popup = selectorRows[index];
169
+ if (!popup) {
170
+ lines.push(this.stripedRow(base, innerWidth, stripeIndex));
171
+ continue;
172
+ }
173
+ const transparentPrefix = popup.match(/^ +/)?.[0].length ?? 0;
174
+ const visiblePopup = popup.slice(transparentPrefix);
175
+ const popupWidth = visibleWidth(visiblePopup);
176
+ const popupAnchor = selectorAnchor + transparentPrefix;
177
+ const baseWidth = Math.max(0, innerWidth - popupWidth - popupAnchor);
178
+ const leadingBase = this.stripeBackground(this.fit(this.takeVisiblePrefix(base, popupAnchor), popupAnchor), stripeIndex);
179
+ const preservedBase = this.stripeBackground(this.fit(this.dropVisiblePrefix(base, popupWidth + popupAnchor), baseWidth), stripeIndex);
180
+ lines.push(this.row(`${leadingBase}${visiblePopup}${preservedBase}`, innerWidth, true));
181
+ }
182
+ lines.push(this.border("├", "", "┤", innerWidth));
183
+ lines.push(this.row(this.renderKeyHints(), innerWidth));
184
+ lines.push(this.border("╰", "", "╯", innerWidth));
185
+ return lines;
186
+ }
187
+ invalidate() { }
188
+ dispose() {
189
+ clearInterval(this.refreshTimer);
190
+ }
191
+ runs() {
192
+ return Inspector.readActorInspectorRuns(this.stateRoot, this.ownerId);
193
+ }
194
+ ensureSelectedRun(runs) {
195
+ if (!this.selectedRun)
196
+ this.selectedRun = runs[0]?.run;
197
+ }
198
+ listItemCount() {
199
+ return this.tab === "communications"
200
+ ? this.communicationPreviews().length
201
+ : this.turnItems().length;
202
+ }
203
+ selectorAnchor() {
204
+ if (this.menuLevel === "run") {
205
+ const runControl = stripVTControlCharacters(this.renderRunControl());
206
+ return Math.max(0, runControl.indexOf("Run:") - 1);
207
+ }
208
+ const label = this.tab === "communications" ? "Messages" : "Turns";
209
+ const tabs = stripVTControlCharacters(this.renderTabs());
210
+ return Math.max(0, tabs.indexOf(label) - 2);
211
+ }
212
+ renderKeyHints() {
213
+ const hint = (keys, description) => `${this.theme.fg("accent", keys)}${this.theme.fg("dim", ` ${description}`)}`;
214
+ if (this.focus === "select")
215
+ return this.menuLevel === "value"
216
+ ? ` ${hint("↑↓", "option")} ${hint("enter", "apply")} ${hint("←/esc", "back")}`
217
+ : ` ${hint("↑↓", "option")} ${hint("→/enter", "open")} ${hint("←/esc", "back")}`;
218
+ if (this.focus === "detail")
219
+ return ` ${hint("↑↓", "scroll")} ${hint("←/esc", "back")}`;
220
+ if (this.focus === "list")
221
+ return ` ${hint("↑↓", "row")} ${hint("→/enter", "open")} ${hint("esc", "close")}`;
222
+ return ` ${hint("←→", "navigate")} ${hint("↑↓", "change row")} ${hint("enter", "select")} ${hint("esc", "close")}`;
223
+ }
224
+ renderRunControl() {
225
+ const run = this.runs().find((item) => item.run === this.selectedRun);
226
+ const focused = this.focus === "runs";
227
+ const active = focused || (this.focus === "select" && this.menuLevel === "run");
228
+ const prefix = active ? this.theme.fg("accent", " [ ") : " ";
229
+ const suffix = active ? this.theme.fg("accent", " ] ") : " ";
230
+ if (!run) {
231
+ const empty = this.theme.fg("muted", `${prefix}Run: none${suffix}`);
232
+ return focused ? this.theme.bg("selectedBg", empty) : empty;
233
+ }
234
+ const statusColor = run.status === "failed"
235
+ ? "error"
236
+ : run.status === "running"
237
+ ? "warning"
238
+ : run.status === "done"
239
+ ? "success"
240
+ : "muted";
241
+ const value = `${prefix}${this.theme.fg("muted", "Run: ")}${this.theme.fg("accent", run.run)}${this.theme.fg("muted", " · ")}${this.theme.fg(statusColor, run.status)}${suffix}`;
242
+ return focused ? this.theme.bg("selectedBg", value) : value;
243
+ }
244
+ activeMessageFilters() {
245
+ return [
246
+ ...(this.communicationChannel === "all" ? [] : [this.communicationChannel]),
247
+ ...(this.communicationUnread ? ["unread"] : []),
248
+ ...(this.communicationFrom === "all" ? [] : [this.communicationFrom]),
249
+ ];
250
+ }
251
+ renderTabs() {
252
+ const tab = (id, label) => {
253
+ const selected = this.tab === id;
254
+ const focused = selected && this.focus === "tabs";
255
+ const runActive = this.focus === "runs" ||
256
+ (this.focus === "select" && this.menuLevel === "run");
257
+ const display = selected && !runActive ? `[ ${label} ]` : ` ${label} `;
258
+ const value = ` ${display} `;
259
+ if (!selected)
260
+ return this.theme.fg("muted", value);
261
+ const colored = this.theme.fg("accent", value);
262
+ return focused ? this.theme.bg("selectedBg", colored) : colored;
263
+ };
264
+ const messageFilters = this.activeMessageFilters();
265
+ const subagent = this.subagents()[this.subagentIndex] ?? "all";
266
+ const messagesLabel = `Messages${messageFilters.length > 0 ? ` · ${messageFilters.join(" · ")}` : ""}`;
267
+ const turnsLabel = `Turns${subagent === "all" ? "" : ` · ${subagent}`}`;
268
+ return `${tab("communications", messagesLabel)} ${tab("turns", turnsLabel)}`;
269
+ }
270
+ renderRunSelector(width) {
271
+ const runs = this.runs();
272
+ if (runs.length === 0)
273
+ return [this.theme.fg("muted", " No owned actor runs")];
274
+ this.runIndex = Math.min(this.runIndex, runs.length - 1);
275
+ return runs.slice(0, 16).map((run, index) => {
276
+ const selected = index === this.runIndex;
277
+ const prefix = selected ? this.theme.fg("accent", " ▶ ") : " ";
278
+ const label = `${prefix}run:${run.run}`;
279
+ const status = ` ${run.status}`;
280
+ return selected
281
+ ? this.theme.fg("accent", `${label}${status}`)
282
+ : `${label}${this.theme.fg("muted", status)}`;
283
+ });
284
+ }
285
+ renderTimeline(width) {
286
+ if (!this.selectedRun)
287
+ return [];
288
+ const run = this.runs().find((item) => item.run === this.selectedRun);
289
+ if (!run) {
290
+ this.selectedRun = undefined;
291
+ return [this.theme.fg("warning", " Selected run is no longer owned by this session")];
292
+ }
293
+ const rows = this.tab === "communications"
294
+ ? this.communicationRows()
295
+ : this.turnRows();
296
+ if (rows.length === 0) {
297
+ this.contentStripeIndices = [0];
298
+ const filtered = this.tab === "communications"
299
+ ? this.activeMessageFilters().length > 0
300
+ : (this.subagents()[this.subagentIndex] ?? "all") !== "all";
301
+ return [
302
+ this.theme.fg("muted", filtered
303
+ ? " No results for the active filters · Enter on the tab to adjust"
304
+ : this.tab === "communications"
305
+ ? " No messages in this run"
306
+ : " No persisted turns in this run"),
307
+ ];
308
+ }
309
+ this.rowIndex = Math.min(this.rowIndex, rows.length - 1);
310
+ const start = Math.max(0, Math.min(this.rowIndex - 7, Math.max(0, rows.length - 15)));
311
+ const visibleRows = rows.slice(start, start + 15);
312
+ this.contentStripeIndices = visibleRows.map((_, offset) => start + offset);
313
+ return visibleRows.map((row, offset) => {
314
+ const index = start + offset;
315
+ const selected = this.focus === "list" && index === this.rowIndex;
316
+ return selected
317
+ ? this.theme.fg("accent", ` ▶ ${row}`)
318
+ : ` ${row}`;
319
+ });
320
+ }
321
+ communicationPreviews() {
322
+ if (!this.selectedRun)
323
+ return [];
324
+ const channels = this.communicationChannel === "all"
325
+ ? undefined
326
+ : [this.communicationChannel];
327
+ return Inspector.readActorInspectorPreviews(this.stateRoot, 100, {
328
+ channels,
329
+ currentRunOnly: false,
330
+ ownerId: this.ownerId,
331
+ readKeys: this.readKeys,
332
+ run: this.selectedRun,
333
+ unreadOnly: this.communicationUnread,
334
+ }).filter((preview) => this.communicationFrom === "all" ||
335
+ this.communicationActorLabel(preview.from_display ?? preview.from) ===
336
+ this.communicationFrom);
337
+ }
338
+ communicationActorLabel(actor) {
339
+ const value = actor ?? "unknown";
340
+ if (value.startsWith("run:"))
341
+ return value.slice("run:".length);
342
+ return value.replace(/^branch:[^/]+\//, "");
343
+ }
344
+ communicationFromOptions() {
345
+ if (!this.selectedRun)
346
+ return ["all"];
347
+ const actors = Inspector.readActorInspectorRoster(this.stateRoot, this.selectedRun).map((member) => this.communicationActorLabel(member.display ?? member.address));
348
+ return ["all", ...new Set(actors)];
349
+ }
350
+ communicationRows() {
351
+ return this.communicationPreviews().map((preview) => {
352
+ const actor = preview.from_display ?? preview.from ?? "unknown";
353
+ const target = preview.channel === "room" ? "#all" : preview.to;
354
+ const text = preview.summary ?? preview.body_preview ?? "(no body)";
355
+ return `${preview.needs_response ? "! " : ""}${actor} → ${target} ${preview.type} ${text}`;
356
+ });
357
+ }
358
+ renderDetail(width) {
359
+ if (this.tab === "communications") {
360
+ const preview = this.detailCommunication;
361
+ if (!preview)
362
+ return [this.theme.fg("warning", " Communication row is no longer available")];
363
+ const fields = [
364
+ ["channel", preview.channel],
365
+ ["run", preview.run],
366
+ ["from", preview.from_display ?? preview.from],
367
+ ["to", preview.to],
368
+ ["type", preview.type],
369
+ ["summary", preview.summary],
370
+ ["body", preview.body_preview],
371
+ ["attention", preview.needs_response],
372
+ ["inbox", preview.inbox_status],
373
+ ["timestamp", preview.timestamp],
374
+ ];
375
+ return fields
376
+ .filter(([, value]) => value !== undefined)
377
+ .map(([label, value]) => truncateToWidth(` ${label.padEnd(12, " ")} ${typeof value === "string" ? value : JSON.stringify(value)}`, width, "…"));
378
+ }
379
+ const turn = this.detailTurn;
380
+ if (!turn)
381
+ return [this.theme.fg("warning", " Turn row is no longer available")];
382
+ const value = (input) => typeof input === "string" ? input : JSON.stringify(input);
383
+ const fields = [
384
+ ` command ${turn.commandId}${turn.stage ? ` · ${turn.stage}` : ""}`,
385
+ ` session ${turn.sessionFile}`,
386
+ ...(turn.promptFile
387
+ ? [` prompt ${turn.promptFile}${turn.promptBytes ? ` · ${turn.promptBytes} bytes` : ""}`]
388
+ : []),
389
+ ...(turn.recipeContext !== undefined
390
+ ? [` recipe context ${value(turn.recipeContext)}`]
391
+ : []),
392
+ ...(turn.userText ? [` user ${turn.userText}`] : []),
393
+ ...(turn.assistantText ? [` assistant ${turn.assistantText}`] : []),
394
+ ` reasoning ${turn.thinking ? "persisted thinking block" : "unavailable"}`,
395
+ ...(turn.thinking ? [` thinking ${turn.thinking}`] : []),
396
+ ...(turn.stopReason ? [` stop reason ${turn.stopReason}`] : []),
397
+ ...(turn.usage !== undefined ? [` usage ${value(turn.usage)}`] : []),
398
+ ...(turn.error ? [` error ${turn.error}`] : []),
399
+ ` tools ${turn.toolCalls.length}`,
400
+ ];
401
+ for (const [index, tool] of turn.toolCalls.entries()) {
402
+ fields.push(` tool ${String(index + 1).padEnd(9, " ")} ${tool.name} · ${tool.id}${tool.resultError ? " · error" : ""}`);
403
+ if (tool.arguments !== undefined)
404
+ fields.push(` arguments ${value(tool.arguments)}`);
405
+ if (tool.result !== undefined)
406
+ fields.push(` result ${value(tool.result)}`);
407
+ }
408
+ if (turn.unmatchedToolResults > 0)
409
+ fields.push(` unmatched ${turn.unmatchedToolResults}`);
410
+ if (turn.sessionTruncated)
411
+ fields.push(" session turn list truncated");
412
+ for (const diagnostic of turn.diagnostics)
413
+ fields.push(` diagnostic ${diagnostic}`);
414
+ const maxStart = Math.max(0, fields.length - 16);
415
+ this.detailScroll = Math.min(this.detailScroll, maxStart);
416
+ return fields
417
+ .slice(this.detailScroll, this.detailScroll + 16)
418
+ .map((field) => truncateToWidth(field, width, "…"));
419
+ }
420
+ turnItems() {
421
+ if (!this.selectedRun)
422
+ return [];
423
+ const selectedSubagent = this.subagents()[this.subagentIndex];
424
+ return Inspector.readActorInspectorTurns(path.join(this.stateRoot, this.selectedRun)).filter((turn) => !selectedSubagent ||
425
+ selectedSubagent === "all" ||
426
+ turn.commandId === selectedSubagent);
427
+ }
428
+ turnRows() {
429
+ return this.turnItems().map((turn) => `${turn.commandId}${turn.stage ? `/${turn.stage}` : ""} ${turn.provider ?? "unknown"}/${turn.model ?? "unknown"} tools:${turn.toolCalls.length} ${turn.assistantText ?? turn.userText ?? "(no text)"}`);
430
+ }
431
+ subagents() {
432
+ if (!this.selectedRun || this.tab !== "turns")
433
+ return ["all"];
434
+ const ids = Inspector.readActorInspectorTurns(path.join(this.stateRoot, this.selectedRun)).map((turn) => turn.commandId);
435
+ return ["all", ...new Set(ids)];
436
+ }
437
+ valueOptions() {
438
+ if (this.tab === "turns")
439
+ return this.subagents();
440
+ if (this.filterControlIndex === 0)
441
+ return ["all", "room", "direct", "broadcast"];
442
+ if (this.filterControlIndex === 1)
443
+ return ["all", "unread"];
444
+ return this.communicationFromOptions();
445
+ }
446
+ filterOptions() {
447
+ return this.tab === "communications"
448
+ ? ["Channel", "State", "From"]
449
+ : ["Subagent"];
450
+ }
451
+ menuOptions() {
452
+ if (this.menuLevel === "run")
453
+ return this.runs().map((run) => run.run);
454
+ if (this.menuLevel === "filter")
455
+ return this.filterOptions();
456
+ return this.valueOptions();
457
+ }
458
+ backMenu() {
459
+ if (this.menuLevel === "value") {
460
+ this.menuLevel = "filter";
461
+ this.selectorIndex = this.filterControlIndex;
462
+ return;
463
+ }
464
+ this.focus = this.menuLevel === "run" ? "runs" : "tabs";
465
+ }
466
+ openRunMenu() {
467
+ const options = this.runs();
468
+ this.selectorIndex = Math.max(0, options.findIndex((run) => run.run === this.selectedRun));
469
+ this.menuLevel = "run";
470
+ this.focus = "select";
471
+ }
472
+ openFilterMenu() {
473
+ this.selectorIndex = this.filterControlIndex;
474
+ this.menuLevel = "filter";
475
+ this.focus = "select";
476
+ }
477
+ applyMenuOption(value) {
478
+ if (!value)
479
+ return;
480
+ if (this.menuLevel === "run") {
481
+ this.selectedRun = value;
482
+ this.runIndex = Math.max(0, this.runs().findIndex((run) => run.run === value));
483
+ this.rowIndex = 0;
484
+ this.subagentIndex = 0;
485
+ this.communicationFrom = "all";
486
+ this.focus = "runs";
487
+ return;
488
+ }
489
+ if (this.menuLevel === "filter") {
490
+ this.filterControlIndex = this.selectorIndex;
491
+ const options = this.valueOptions();
492
+ const current = this.currentFilterValue();
493
+ this.selectorIndex = Math.max(0, options.indexOf(current));
494
+ this.menuLevel = "value";
495
+ return;
496
+ }
497
+ if (this.tab === "turns")
498
+ this.subagentIndex = Math.max(0, this.subagents().indexOf(value));
499
+ else if (this.filterControlIndex === 0)
500
+ this.communicationChannel = value;
501
+ else if (this.filterControlIndex === 1)
502
+ this.communicationUnread = value === "unread";
503
+ else
504
+ this.communicationFrom = value;
505
+ this.rowIndex = 0;
506
+ this.menuLevel = "filter";
507
+ this.selectorIndex = this.filterControlIndex;
508
+ }
509
+ filterValue(index) {
510
+ if (this.tab === "turns")
511
+ return this.subagents()[this.subagentIndex] ?? "all";
512
+ if (index === 0)
513
+ return this.communicationChannel;
514
+ if (index === 1)
515
+ return this.communicationUnread ? "unread" : "all";
516
+ return this.communicationFrom;
517
+ }
518
+ currentFilterValue() {
519
+ return this.filterValue(this.filterControlIndex);
520
+ }
521
+ renderMenuBox(options, focusedIndex, current, parentIndex, omitLeftBorder = false, omitRightBorder = false) {
522
+ const contentWidth = Math.max(...options.map((option) => option.length + 4));
523
+ const border = (left, right) => this.theme.fg("border", `${omitLeftBorder ? "" : left}${"─".repeat(contentWidth)}${omitRightBorder ? "" : right}`);
524
+ return [
525
+ border("╭", "╮"),
526
+ ...options.map((option, index) => {
527
+ const focused = index === focusedIndex;
528
+ const row = this.fit(`${focused ? " ▶ " : " "}${option}`, contentWidth);
529
+ const colored = focused || option === current || index === parentIndex
530
+ ? this.theme.fg("accent", row)
531
+ : row;
532
+ const styled = focused || index === parentIndex
533
+ ? this.theme.bg("selectedBg", colored)
534
+ : colored;
535
+ return `${omitLeftBorder ? "" : this.theme.fg("border", "│")}${styled}${omitRightBorder ? "" : this.theme.fg("border", "│")}`;
536
+ }),
537
+ border("╰", "╯"),
538
+ ];
539
+ }
540
+ renderFilterMenus(_width) {
541
+ const filters = this.filterOptions().map((filter, index) => `${filter}: ${this.filterValue(index)}`);
542
+ const parentFocus = this.menuLevel === "filter" ? this.selectorIndex : -1;
543
+ const parent = this.renderMenuBox(filters, parentFocus, undefined, this.menuLevel === "value" ? this.filterControlIndex : undefined);
544
+ if (this.menuLevel !== "value")
545
+ return parent;
546
+ const parentShared = this.renderMenuBox(filters, parentFocus, undefined, this.filterControlIndex, false, true);
547
+ const child = this.renderMenuBox(this.valueOptions(), this.selectorIndex, this.currentFilterValue());
548
+ return Array.from({ length: Math.max(parent.length, child.length) }, (_, index) => child[index]
549
+ ? `${parentShared[index] ?? " ".repeat(visibleWidth(parentShared[0]))}${child[index]}`
550
+ : (parent[index] ?? ""));
551
+ }
552
+ openDetail() {
553
+ if (this.tab === "communications") {
554
+ const preview = this.communicationPreviews()[this.rowIndex];
555
+ this.detailCommunication = preview;
556
+ if (preview)
557
+ this.readKeys.add(Inspector.inspectorPreviewReadKey(preview));
558
+ }
559
+ else
560
+ this.detailTurn = this.turnItems()[this.rowIndex];
561
+ this.detailScroll = 0;
562
+ this.detailOpen = Boolean(this.detailCommunication || this.detailTurn);
563
+ if (this.detailOpen)
564
+ this.focus = "detail";
565
+ }
566
+ closeDetail() {
567
+ this.detailOpen = false;
568
+ this.detailCommunication = undefined;
569
+ this.detailTurn = undefined;
570
+ this.detailScroll = 0;
571
+ this.focus = "list";
572
+ }
573
+ border(left, title, right, width) {
574
+ const titleText = truncateToWidth(title, width, "");
575
+ const fill = "─".repeat(Math.max(0, width - visibleWidth(titleText)));
576
+ return this.theme.fg("border", `${left}${titleText}${fill}${right}`);
577
+ }
578
+ stripeBackground(content, index) {
579
+ if (index % 2 === 0)
580
+ return content;
581
+ const segments = content.split("…");
582
+ return segments
583
+ .map((segment, segmentIndex) => `${this.theme.bg("customMessageBg", segment)}${segmentIndex < segments.length - 1 ? this.theme.bg("customMessageBg", "…") : ""}`)
584
+ .join("");
585
+ }
586
+ stripedRow(content, width, index) {
587
+ return this.row(this.stripeBackground(this.fit(content, width), index), width, true);
588
+ }
589
+ row(content, width, fitted = false) {
590
+ const body = fitted ? content : this.fit(content, width);
591
+ return `${this.theme.fg("border", "│")}${body}${this.theme.fg("border", "│")}`;
592
+ }
593
+ takeVisiblePrefix(content, width) {
594
+ const plain = stripVTControlCharacters(content);
595
+ let consumed = 0;
596
+ let result = "";
597
+ for (const character of plain) {
598
+ const characterWidth = visibleWidth(character);
599
+ if (consumed + characterWidth > width)
600
+ break;
601
+ result += character;
602
+ consumed += characterWidth;
603
+ }
604
+ return result;
605
+ }
606
+ dropVisiblePrefix(content, width) {
607
+ const plain = stripVTControlCharacters(content);
608
+ let consumed = 0;
609
+ let index = 0;
610
+ for (const character of plain) {
611
+ if (consumed >= width)
612
+ break;
613
+ consumed += visibleWidth(character);
614
+ index += character.length;
615
+ }
616
+ return plain.slice(index);
617
+ }
618
+ fit(content, width) {
619
+ // A Component render entry must remain exactly one terminal row. Persisted
620
+ // prompts, messages, and tool results may contain line breaks or tabs;
621
+ // passing those through lets them escape the overlay compositor and corrupt
622
+ // the underlying TUI during tab changes.
623
+ const singleLine = content.replace(/[\r\n\t]/g, " ");
624
+ const bounded = truncateToWidth(singleLine, width, "…");
625
+ return `${bounded}${" ".repeat(Math.max(0, width - visibleWidth(bounded)))}`;
626
+ }
627
+ }