@llblab/pi-actors 0.41.0 → 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.
@@ -12,6 +12,7 @@ import {
12
12
  matchesKey,
13
13
  truncateToWidth,
14
14
  visibleWidth,
15
+ wrapTextWithAnsi,
15
16
  type TUI,
16
17
  } from "@earendil-works/pi-tui";
17
18
 
@@ -19,6 +20,10 @@ import * as Inspector from "./inspector.ts";
19
20
 
20
21
  export type ActorInspectorOverlayTab = "communications" | "turns";
21
22
 
23
+ type DetailLogicalLine = string | { sectionBreak: true };
24
+
25
+ const DETAIL_SECTION_BREAK: DetailLogicalLine = { sectionBreak: true };
26
+
22
27
  export interface ActorInspectorOverlayOptions {
23
28
  done: () => void;
24
29
  ownerId: string;
@@ -43,6 +48,7 @@ export class ActorInspectorOverlay {
43
48
  private detailOpen = false;
44
49
  private detailScroll = 0;
45
50
  private detailTurn?: Inspector.ActorInspectorTurnItem;
51
+ private detailView: "evidence" | "transcript" = "evidence";
46
52
  private readonly readKeys = new Set<string>();
47
53
  private focus: "runs" | "tabs" | "list" | "detail" | "select" = "tabs";
48
54
  private filterControlIndex = 0;
@@ -76,7 +82,7 @@ export class ActorInspectorOverlay {
76
82
  this.menuLevel = "filter";
77
83
  this.selectorIndex = this.filterControlIndex;
78
84
  } else this.focus = this.menuLevel === "run" ? "runs" : "tabs";
79
- } else if (this.focus === "detail") this.closeDetail();
85
+ } else if (this.focus === "detail") this.backDetail();
80
86
  else this.done();
81
87
  this.tui.requestRender();
82
88
  return;
@@ -99,12 +105,22 @@ export class ActorInspectorOverlay {
99
105
  if (this.focus === "detail") {
100
106
  if (matchesKey(data, "up")) this.detailScroll = Math.max(0, this.detailScroll - 1);
101
107
  else if (matchesKey(data, "down")) this.detailScroll += 1;
102
- else if (matchesKey(data, "left")) this.closeDetail();
108
+ else if (matchesKey(data, "left")) this.backDetail();
109
+ else if (
110
+ (matchesKey(data, "right") || matchesKey(data, "return")) &&
111
+ this.detailView === "evidence" &&
112
+ this.tab === "turns"
113
+ ) {
114
+ this.detailView = "transcript";
115
+ this.detailScroll = 0;
116
+ }
103
117
  this.tui.requestRender();
104
118
  return;
105
119
  }
106
120
  if (this.focus === "runs") {
107
- if (matchesKey(data, "down")) this.focus = "tabs";
121
+ if (matchesKey(data, "left")) this.cycleRun(runs, -1);
122
+ else if (matchesKey(data, "right")) this.cycleRun(runs, 1);
123
+ else if (matchesKey(data, "down")) this.focus = "tabs";
108
124
  else if (matchesKey(data, "return") && runs.length > 0) this.openRunMenu();
109
125
  } else if (this.focus === "tabs") {
110
126
  if (matchesKey(data, "left") || matchesKey(data, "right")) {
@@ -138,8 +154,16 @@ export class ActorInspectorOverlay {
138
154
  let selector = this.focus === "select"
139
155
  ? this.menuLevel === "run"
140
156
  ? this.renderMenuBox(
141
- this.runs().map((run) => `${run.run} · ${run.status}`),
157
+ this.runs().map(
158
+ (run, index, runs) =>
159
+ `#${runs.length - index} ${run.run} ${run.status}`,
160
+ ),
142
161
  this.selectorIndex,
162
+ undefined,
163
+ undefined,
164
+ false,
165
+ false,
166
+ this.contentViewportRows(),
143
167
  )
144
168
  : this.renderFilterMenus(innerWidth)
145
169
  : [];
@@ -175,11 +199,13 @@ export class ActorInspectorOverlay {
175
199
  : this.selectedRun
176
200
  ? this.renderTimeline(innerWidth)
177
201
  : this.renderRunSelector(innerWidth);
178
- const minContentRows = 16;
202
+ const contentViewportRows = this.contentViewportRows();
179
203
  const selectorRows = selector.slice(1);
180
- for (let index = 0; index < Math.max(content.length, minContentRows); index += 1) {
204
+ for (let index = 0; index < Math.max(content.length, contentViewportRows); index += 1) {
181
205
  const base = content[index] ?? "";
182
- const stripeIndex = this.contentStripeIndices[index] ?? index;
206
+ const stripeIndex = index < content.length
207
+ ? this.contentStripeIndices[index] ?? index
208
+ : 0;
183
209
  const popup = selectorRows[index];
184
210
  if (!popup) {
185
211
  lines.push(this.stripedRow(base, innerWidth, stripeIndex));
@@ -223,6 +249,33 @@ export class ActorInspectorOverlay {
223
249
  if (!this.selectedRun) this.selectedRun = runs[0]?.run;
224
250
  }
225
251
 
252
+ private contentViewportRows(): number {
253
+ const overlayRows = Math.floor(this.tui.terminal.rows * 0.94);
254
+ return Math.max(4, Math.min(16, overlayRows - 7));
255
+ }
256
+
257
+ private selectRun(run: string, index: number): void {
258
+ this.selectedRun = run;
259
+ this.runIndex = index;
260
+ this.rowIndex = 0;
261
+ this.subagentIndex = 0;
262
+ this.communicationFrom = "all";
263
+ }
264
+
265
+ private cycleRun(
266
+ runs: Inspector.ActorInspectorRunItem[],
267
+ direction: -1 | 1,
268
+ ): void {
269
+ if (runs.length === 0) return;
270
+ const currentIndex = Math.max(
271
+ 0,
272
+ runs.findIndex((run) => run.run === this.selectedRun),
273
+ );
274
+ const nextIndex = (currentIndex + direction + runs.length) % runs.length;
275
+ const next = runs[nextIndex];
276
+ if (next) this.selectRun(next.run, nextIndex);
277
+ }
278
+
226
279
  private listItemCount(): number {
227
280
  return this.tab === "communications"
228
281
  ? this.communicationPreviews().length
@@ -232,7 +285,7 @@ export class ActorInspectorOverlay {
232
285
  private selectorAnchor(): number {
233
286
  if (this.menuLevel === "run") {
234
287
  const runControl = stripVTControlCharacters(this.renderRunControl());
235
- return Math.max(0, runControl.indexOf("Run:") - 1);
288
+ return Math.max(0, runControl.indexOf("Run:") - 2);
236
289
  }
237
290
  const label = this.tab === "communications" ? "Messages" : "Turns";
238
291
  const tabs = stripVTControlCharacters(this.renderTabs());
@@ -247,18 +300,24 @@ export class ActorInspectorOverlay {
247
300
  ? ` ${hint("↑↓", "option")} ${hint("enter", "apply")} ${hint("←/esc", "back")}`
248
301
  : ` ${hint("↑↓", "option")} ${hint("→/enter", "open")} ${hint("←/esc", "back")}`;
249
302
  if (this.focus === "detail")
250
- return ` ${hint("↑↓", "scroll")} ${hint("←/esc", "back")}`;
303
+ return this.tab === "turns" && this.detailView === "evidence"
304
+ ? ` ${hint("↑↓", "scroll")} ${hint("→/enter", "readable")} ${hint("←/esc", "back")}`
305
+ : ` ${hint("↑↓", "scroll")} ${hint("←/esc", this.detailView === "transcript" ? "evidence" : "back")}`;
251
306
  if (this.focus === "list")
252
307
  return ` ${hint("↑↓", "row")} ${hint("→/enter", "open")} ${hint("esc", "close")}`;
308
+ if (this.focus === "runs")
309
+ return ` ${hint("←→", "run")} ${hint("↓", "tabs")} ${hint("enter", "list")} ${hint("esc", "close")}`;
253
310
  return ` ${hint("←→", "navigate")} ${hint("↑↓", "change row")} ${hint("enter", "select")} ${hint("esc", "close")}`;
254
311
  }
255
312
 
256
313
  private renderRunControl(): string {
257
- const run = this.runs().find((item) => item.run === this.selectedRun);
314
+ const runs = this.runs();
315
+ const runIndex = runs.findIndex((item) => item.run === this.selectedRun);
316
+ const run = runs[runIndex];
258
317
  const focused = this.focus === "runs";
259
318
  const active = focused || (this.focus === "select" && this.menuLevel === "run");
260
- const prefix = active ? this.theme.fg("accent", " [ ") : " ";
261
- const suffix = active ? this.theme.fg("accent", " ] ") : " ";
319
+ const prefix = active ? this.theme.fg("accent", " ← ") : " ";
320
+ const suffix = active ? this.theme.fg("accent", " → ") : " ";
262
321
  if (!run) {
263
322
  const empty = this.theme.fg("muted", `${prefix}Run: none${suffix}`);
264
323
  return focused ? this.theme.bg("selectedBg", empty) : empty;
@@ -270,7 +329,8 @@ export class ActorInspectorOverlay {
270
329
  : run.status === "done"
271
330
  ? "success"
272
331
  : "muted";
273
- 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}`;
332
+ const sequence = runs.length - runIndex;
333
+ 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}`;
274
334
  return focused ? this.theme.bg("selectedBg", value) : value;
275
335
  }
276
336
 
@@ -297,8 +357,8 @@ export class ActorInspectorOverlay {
297
357
  };
298
358
  const messageFilters = this.activeMessageFilters();
299
359
  const subagent = this.subagents()[this.subagentIndex] ?? "all";
300
- const messagesLabel = `Messages${messageFilters.length > 0 ? ` · ${messageFilters.join(" · ")}` : ""}`;
301
- const turnsLabel = `Turns${subagent === "all" ? "" : ` · ${subagent}`}`;
360
+ const messagesLabel = `Messages${messageFilters.length > 0 ? ` (${messageFilters.join(", ")})` : ""}`;
361
+ const turnsLabel = `Turns${subagent === "all" ? "" : ` (${subagent})`}`;
302
362
  return `${tab("communications", messagesLabel)} ${tab("turns", turnsLabel)}`;
303
363
  }
304
364
 
@@ -306,10 +366,10 @@ export class ActorInspectorOverlay {
306
366
  const runs = this.runs();
307
367
  if (runs.length === 0) return [this.theme.fg("muted", " No owned actor runs")];
308
368
  this.runIndex = Math.min(this.runIndex, runs.length - 1);
309
- return runs.slice(0, 16).map((run, index) => {
369
+ return runs.slice(0, this.contentViewportRows()).map((run, index) => {
310
370
  const selected = index === this.runIndex;
311
371
  const prefix = selected ? this.theme.fg("accent", " ▶ ") : " ";
312
- const label = `${prefix}run:${run.run}`;
372
+ const label = `${prefix}#${runs.length - index} run:${run.run}`;
313
373
  const status = ` ${run.status}`;
314
374
  return selected
315
375
  ? this.theme.fg("accent", `${label}${status}`)
@@ -337,7 +397,7 @@ export class ActorInspectorOverlay {
337
397
  this.theme.fg(
338
398
  "muted",
339
399
  filtered
340
- ? " No results for the active filters · Enter on the tab to adjust"
400
+ ? " No results for the active filters. Enter on the tab to adjust."
341
401
  : this.tab === "communications"
342
402
  ? " No messages in this run"
343
403
  : " No persisted turns in this run",
@@ -345,12 +405,23 @@ export class ActorInspectorOverlay {
345
405
  ];
346
406
  }
347
407
  this.rowIndex = Math.min(this.rowIndex, rows.length - 1);
408
+ const viewportRows = this.contentViewportRows();
348
409
  const start = Math.max(
349
410
  0,
350
- Math.min(this.rowIndex - 7, Math.max(0, rows.length - 15)),
411
+ Math.min(
412
+ this.rowIndex - Math.floor(viewportRows / 2),
413
+ Math.max(0, rows.length - viewportRows),
414
+ ),
415
+ );
416
+ const stripeIndices = this.tab === "communications"
417
+ ? this.communicationPreviews().map((preview, index) =>
418
+ Math.max(0, (preview.sequence ?? index + 1) - 1),
419
+ )
420
+ : this.turnItems().map((turn) => Math.max(0, turn.index - 1));
421
+ const visibleRows = rows.slice(start, start + viewportRows);
422
+ this.contentStripeIndices = visibleRows.map(
423
+ (_, offset) => stripeIndices[start + offset] ?? start + offset,
351
424
  );
352
- const visibleRows = rows.slice(start, start + 15);
353
- this.contentStripeIndices = visibleRows.map((_, offset) => start + offset);
354
425
  return visibleRows.map((row, offset) => {
355
426
  const index = start + offset;
356
427
  const selected = this.focus === "list" && index === this.rowIndex;
@@ -373,12 +444,14 @@ export class ActorInspectorOverlay {
373
444
  readKeys: this.readKeys,
374
445
  run: this.selectedRun,
375
446
  unreadOnly: this.communicationUnread,
376
- }).filter(
377
- (preview) =>
378
- this.communicationFrom === "all" ||
379
- this.communicationActorLabel(preview.from_display ?? preview.from) ===
380
- this.communicationFrom,
381
- );
447
+ })
448
+ .filter(
449
+ (preview) =>
450
+ this.communicationFrom === "all" ||
451
+ this.communicationActorLabel(preview.from_display ?? preview.from) ===
452
+ this.communicationFrom,
453
+ )
454
+ .reverse();
382
455
  }
383
456
 
384
457
  private communicationActorLabel(actor?: string): string {
@@ -403,7 +476,7 @@ export class ActorInspectorOverlay {
403
476
  const actor = preview.from_display ?? preview.from ?? "unknown";
404
477
  const target = preview.channel === "room" ? "#all" : preview.to;
405
478
  const text = preview.summary ?? preview.body_preview ?? "(no body)";
406
- return `${preview.needs_response ? "! " : ""}${actor} → ${target} ${preview.type} ${text}`;
479
+ return `#${preview.sequence ?? "?"} ${preview.needs_response ? "! " : ""}${actor} → ${target} ${preview.type} ${text}`;
407
480
  });
408
481
  }
409
482
 
@@ -435,44 +508,242 @@ export class ActorInspectorOverlay {
435
508
  }
436
509
  const turn = this.detailTurn;
437
510
  if (!turn) return [this.theme.fg("warning", " Turn row is no longer available")];
438
- const value = (input: unknown) =>
439
- typeof input === "string" ? input : JSON.stringify(input);
440
- const fields: string[] = [
441
- ` command ${turn.commandId}${turn.stage ? ` · ${turn.stage}` : ""}`,
442
- ` session ${turn.sessionFile}`,
443
- ...(turn.promptFile
444
- ? [` prompt ${turn.promptFile}${turn.promptBytes ? ` · ${turn.promptBytes} bytes` : ""}`]
511
+ const logicalLines = this.detailView === "transcript"
512
+ ? this.turnTranscriptLines(turn)
513
+ : this.turnEvidenceLines(turn);
514
+ const wrapped = this.wrapDetailLines(logicalLines, width);
515
+ const viewportRows = this.contentViewportRows();
516
+ const maxStart = Math.max(0, wrapped.lines.length - viewportRows);
517
+ this.detailScroll = Math.min(this.detailScroll, maxStart);
518
+ this.contentStripeIndices = wrapped.stripeIndices.slice(
519
+ this.detailScroll,
520
+ this.detailScroll + viewportRows,
521
+ );
522
+ return wrapped.lines.slice(
523
+ this.detailScroll,
524
+ this.detailScroll + viewportRows,
525
+ );
526
+ }
527
+
528
+ private wrapDetailLines(
529
+ lines: DetailLogicalLine[],
530
+ width: number,
531
+ ): { lines: string[]; stripeIndices: number[] } {
532
+ const wrappedLines: string[] = [];
533
+ const stripeIndices: number[] = [];
534
+ let sectionIndex = 0;
535
+ for (const line of lines) {
536
+ if (typeof line !== "string") {
537
+ sectionIndex += 1;
538
+ continue;
539
+ }
540
+ const physicalLines = line.replaceAll("\t", " ").split(/\r?\n/);
541
+ for (const physicalLine of physicalLines) {
542
+ if (!physicalLine.trim()) continue;
543
+ const leading = physicalLine.match(/^ */)?.[0] ?? "";
544
+ const content = physicalLine.slice(leading.length);
545
+ const contentWidth = Math.max(1, width - visibleWidth(leading));
546
+ const wrapped = visibleWidth(physicalLine) <= width
547
+ ? [physicalLine]
548
+ : wrapTextWithAnsi(content, contentWidth).map(
549
+ (visualLine) => `${leading}${visualLine}`,
550
+ );
551
+ for (const visualLine of wrapped) {
552
+ if (!visualLine.trim()) continue;
553
+ wrappedLines.push(visualLine);
554
+ stripeIndices.push(sectionIndex);
555
+ }
556
+ }
557
+ }
558
+ return { lines: wrappedLines, stripeIndices };
559
+ }
560
+
561
+ private detailSection(
562
+ title: string,
563
+ content: string[],
564
+ ): DetailLogicalLine[] {
565
+ if (content.length === 0) return [];
566
+ return [
567
+ ` ${title}`,
568
+ ...content.flatMap((line) =>
569
+ line
570
+ .split(/\r?\n/)
571
+ .filter((physicalLine) => physicalLine.trim().length > 0)
572
+ .map((physicalLine) => ` ${physicalLine}`),
573
+ ),
574
+ DETAIL_SECTION_BREAK,
575
+ ];
576
+ }
577
+
578
+ private readableValueLines(
579
+ value: unknown,
580
+ indent = "",
581
+ depth = 0,
582
+ ): string[] {
583
+ if (depth > 8) return [`${indent}[nested value]`];
584
+ if (typeof value === "string") {
585
+ const trimmed = value.trim();
586
+ if (/^[{[]/.test(trimmed)) {
587
+ try {
588
+ return this.readableValueLines(JSON.parse(trimmed), indent, depth + 1);
589
+ } catch {
590
+ // Keep non-JSON text as written.
591
+ }
592
+ }
593
+ return value.split(/\r?\n/).map((line) => `${indent}${line}`);
594
+ }
595
+ if (value === null || value === undefined)
596
+ return [`${indent}${String(value)}`];
597
+ if (typeof value !== "object") return [`${indent}${String(value)}`];
598
+ if (Array.isArray(value)) {
599
+ if (value.length === 0) return [`${indent}(empty)`];
600
+ return value.flatMap((item) => {
601
+ if (
602
+ item &&
603
+ typeof item === "object" &&
604
+ (item as { type?: unknown }).type === "text" &&
605
+ typeof (item as { text?: unknown }).text === "string"
606
+ ) {
607
+ return this.readableValueLines(
608
+ (item as { text: string }).text,
609
+ indent,
610
+ depth + 1,
611
+ );
612
+ }
613
+ if (!item || typeof item !== "object")
614
+ return [`${indent}- ${String(item)}`];
615
+ return [
616
+ `${indent}-`,
617
+ ...this.readableValueLines(item, `${indent} `, depth + 1),
618
+ ];
619
+ });
620
+ }
621
+ const entries = Object.entries(value as Record<string, unknown>);
622
+ if (entries.length === 0) return [`${indent}(empty)`];
623
+ return entries.flatMap(([key, item]) => {
624
+ if (!item || typeof item !== "object") {
625
+ const scalar = typeof item === "string" ? item : String(item);
626
+ const [first = "", ...rest] = scalar.split(/\r?\n/);
627
+ return [
628
+ `${indent}${key}: ${first}`,
629
+ ...rest.map((line) => `${indent} ${line}`),
630
+ ];
631
+ }
632
+ return [
633
+ `${indent}${key}:`,
634
+ ...this.readableValueLines(item, `${indent} `, depth + 1),
635
+ ];
636
+ });
637
+ }
638
+
639
+ private turnEvidenceLines(
640
+ turn: Inspector.ActorInspectorTurnItem,
641
+ ): DetailLogicalLine[] {
642
+ const lines: DetailLogicalLine[] = [];
643
+ const commandNumber = /^command-(\d+)$/.exec(turn.commandId)?.[1];
644
+ const subagent = commandNumber
645
+ ? String(Number.parseInt(commandNumber, 10))
646
+ : turn.commandId;
647
+ const stage =
648
+ turn.stage && turn.stage !== "subagent" && turn.stage !== "command"
649
+ ? ` (${turn.stage})`
650
+ : "";
651
+ lines.push(` Subagent ${subagent}${stage}`, DETAIL_SECTION_BREAK);
652
+ if (turn.userText)
653
+ lines.push(...this.detailSection("User", [turn.userText]));
654
+ if (turn.thinking)
655
+ lines.push(...this.detailSection("Thinking", [turn.thinking]));
656
+ if (turn.assistantText)
657
+ lines.push(...this.detailSection("Assistant", [turn.assistantText]));
658
+ for (const [index, tool] of turn.toolCalls.entries()) {
659
+ const content = [
660
+ ...(tool.arguments === undefined
661
+ ? []
662
+ : ["Input", ...this.readableValueLines(tool.arguments, " ")]),
663
+ ...(tool.result === undefined
664
+ ? []
665
+ : ["Result", ...this.readableValueLines(tool.result, " ")]),
666
+ `call id: ${tool.id}`,
667
+ ];
668
+ lines.push(
669
+ ...this.detailSection(
670
+ `Tool ${index + 1} (${tool.name}${tool.resultError ? ", Error" : ""})`,
671
+ content,
672
+ ),
673
+ );
674
+ }
675
+ const execution = [
676
+ ...(!turn.thinking ? ["thinking: not persisted"] : []),
677
+ ...(turn.model ? [`model: ${turn.model}`] : []),
678
+ ...(turn.stopReason ? [`stop reason: ${turn.stopReason}`] : []),
679
+ ...(turn.error ? [`error: ${turn.error}`] : []),
680
+ ...(turn.usage === undefined
681
+ ? []
682
+ : ["Usage", ...this.readableValueLines(turn.usage, " ")]),
683
+ ...(turn.unmatchedToolResults > 0
684
+ ? [`unmatched tool results: ${turn.unmatchedToolResults}`]
445
685
  : []),
446
- ...(turn.recipeContext !== undefined
447
- ? [` recipe context ${value(turn.recipeContext)}`]
686
+ ];
687
+ if (execution.length > 0)
688
+ lines.push(...this.detailSection("Execution", execution));
689
+ if (turn.diagnostics.length > 0)
690
+ lines.push(...this.detailSection("Diagnostics", turn.diagnostics));
691
+ const provenance = [
692
+ "Session",
693
+ ` ${turn.sessionFile}`,
694
+ ...(turn.sessionTruncated ? [" turn list truncated"] : []),
695
+ ...(turn.promptFile
696
+ ? [
697
+ "Prompt",
698
+ ` ${turn.promptFile}${turn.promptBytes ? ` (${turn.promptBytes} bytes)` : ""}`,
699
+ ]
448
700
  : []),
449
- ...(turn.userText ? [` user ${turn.userText}`] : []),
450
- ...(turn.assistantText ? [` assistant ${turn.assistantText}`] : []),
451
- ` reasoning ${turn.thinking ? "persisted thinking block" : "unavailable"}`,
452
- ...(turn.thinking ? [` thinking ${turn.thinking}`] : []),
453
- ...(turn.stopReason ? [` stop reason ${turn.stopReason}`] : []),
454
- ...(turn.usage !== undefined ? [` usage ${value(turn.usage)}`] : []),
455
- ...(turn.error ? [` error ${turn.error}`] : []),
456
- ` tools ${turn.toolCalls.length}`,
701
+ ...(turn.recipeContext === undefined
702
+ ? []
703
+ : [
704
+ "Recipe Context",
705
+ ...this.readableValueLines(turn.recipeContext, " "),
706
+ ]),
457
707
  ];
458
- for (const [index, tool] of turn.toolCalls.entries()) {
459
- fields.push(
460
- ` tool ${String(index + 1).padEnd(9, " ")} ${tool.name} · ${tool.id}${tool.resultError ? " · error" : ""}`,
708
+ lines.push(...this.detailSection("Provenance", provenance));
709
+ return lines.length > 0 ? lines : [" No persisted turn evidence"];
710
+ }
711
+
712
+ private readablePromptText(text: string): string {
713
+ const wrapped = /^\s*<file\b[^>]*>\s*([\s\S]*?)\s*<\/file>\s*$/i.exec(text);
714
+ return wrapped?.[1]?.trim() || text;
715
+ }
716
+
717
+ private turnTranscriptLines(
718
+ turn: Inspector.ActorInspectorTurnItem,
719
+ ): DetailLogicalLine[] {
720
+ const lines: DetailLogicalLine[] = [];
721
+ if (turn.userText)
722
+ lines.push(
723
+ ...this.detailSection("User", [this.readablePromptText(turn.userText)]),
724
+ );
725
+ if (turn.thinking)
726
+ lines.push(...this.detailSection("Thinking", [turn.thinking]));
727
+ if (turn.assistantText)
728
+ lines.push(...this.detailSection("Assistant", [turn.assistantText]));
729
+ for (const tool of turn.toolCalls) {
730
+ const content = [
731
+ ...(tool.arguments === undefined
732
+ ? []
733
+ : ["Input", ...this.readableValueLines(tool.arguments, " ")]),
734
+ ...(tool.result === undefined
735
+ ? []
736
+ : ["Result", ...this.readableValueLines(tool.result, " ")]),
737
+ ];
738
+ lines.push(
739
+ ...this.detailSection(
740
+ `Tool (${tool.name}${tool.resultError ? ", Error" : ""})`,
741
+ content,
742
+ ),
461
743
  );
462
- if (tool.arguments !== undefined)
463
- fields.push(` arguments ${value(tool.arguments)}`);
464
- if (tool.result !== undefined) fields.push(` result ${value(tool.result)}`);
465
744
  }
466
- if (turn.unmatchedToolResults > 0)
467
- fields.push(` unmatched ${turn.unmatchedToolResults}`);
468
- if (turn.sessionTruncated) fields.push(" session turn list truncated");
469
- for (const diagnostic of turn.diagnostics)
470
- fields.push(` diagnostic ${diagnostic}`);
471
- const maxStart = Math.max(0, fields.length - 16);
472
- this.detailScroll = Math.min(this.detailScroll, maxStart);
473
- return fields
474
- .slice(this.detailScroll, this.detailScroll + 16)
475
- .map((field) => truncateToWidth(field, width, "…"));
745
+ if (turn.error) lines.push(...this.detailSection("Error", [turn.error]));
746
+ return lines.length > 0 ? lines : [" No readable turn content"];
476
747
  }
477
748
 
478
749
  private turnItems(): Inspector.ActorInspectorTurnItem[] {
@@ -480,19 +751,44 @@ export class ActorInspectorOverlay {
480
751
  const selectedSubagent = this.subagents()[this.subagentIndex];
481
752
  return Inspector.readActorInspectorTurns(
482
753
  path.join(this.stateRoot, this.selectedRun),
483
- ).filter(
484
- (turn) =>
485
- !selectedSubagent ||
486
- selectedSubagent === "all" ||
487
- turn.commandId === selectedSubagent,
488
- );
754
+ )
755
+ .filter(
756
+ (turn) =>
757
+ !selectedSubagent ||
758
+ selectedSubagent === "all" ||
759
+ turn.commandId === selectedSubagent,
760
+ )
761
+ .reverse();
489
762
  }
490
763
 
491
764
  private turnRows(): string[] {
492
- return this.turnItems().map(
493
- (turn) =>
494
- `${turn.commandId}${turn.stage ? `/${turn.stage}` : ""} ${turn.provider ?? "unknown"}/${turn.model ?? "unknown"} tools:${turn.toolCalls.length} ${turn.assistantText ?? turn.userText ?? "(no text)"}`,
495
- );
765
+ return this.turnItems().map((turn) => {
766
+ const commandNumber = /^command-(\d+)$/.exec(turn.commandId)?.[1];
767
+ const subagent = commandNumber
768
+ ? `Subagent ${Number.parseInt(commandNumber, 10)}`
769
+ : `Subagent ${turn.commandId}`;
770
+ const stage =
771
+ turn.stage && turn.stage !== "subagent" && turn.stage !== "command"
772
+ ? ` (${turn.stage})`
773
+ : "";
774
+ const toolNames = [...new Set(turn.toolCalls.map((tool) => tool.name))];
775
+ const action = turn.toolCalls.length === 0
776
+ ? ""
777
+ : turn.toolCalls.length === 1
778
+ ? ` (${toolNames[0]})`
779
+ : toolNames.length === 1
780
+ ? ` (${toolNames[0]}, ${turn.toolCalls.length} calls)`
781
+ : turn.toolCalls.length <= 2
782
+ ? ` (${toolNames.join(", ")})`
783
+ : ` (${turn.toolCalls.length} tools)`;
784
+ const failed = turn.error || turn.toolCalls.some((tool) => tool.resultError)
785
+ ? " (error)"
786
+ : "";
787
+ const preview = this.readablePromptText(
788
+ turn.assistantText ?? turn.userText ?? "(no text)",
789
+ ).replaceAll(/\s+/g, " ").trim();
790
+ return `#${turn.index} ${subagent}${stage} ${turn.model ?? "unknown model"}${action}${failed} ${preview}`;
791
+ });
496
792
  }
497
793
 
498
794
  private subagents(): string[] {
@@ -548,11 +844,10 @@ export class ActorInspectorOverlay {
548
844
  private applyMenuOption(value?: string): void {
549
845
  if (!value) return;
550
846
  if (this.menuLevel === "run") {
551
- this.selectedRun = value;
552
- this.runIndex = Math.max(0, this.runs().findIndex((run) => run.run === value));
553
- this.rowIndex = 0;
554
- this.subagentIndex = 0;
555
- this.communicationFrom = "all";
847
+ this.selectRun(
848
+ value,
849
+ Math.max(0, this.runs().findIndex((run) => run.run === value)),
850
+ );
556
851
  this.focus = "runs";
557
852
  return;
558
853
  }
@@ -594,16 +889,37 @@ export class ActorInspectorOverlay {
594
889
  parentIndex?: number,
595
890
  omitLeftBorder = false,
596
891
  omitRightBorder = false,
892
+ maxVisibleOptions = options.length,
597
893
  ): string[] {
598
- const contentWidth = Math.max(...options.map((option) => option.length + 4));
599
- const border = (left: string, right: string) =>
894
+ const visibleLimit = Math.max(1, Math.min(maxVisibleOptions, options.length));
895
+ const anchorIndex = Math.max(
896
+ 0,
897
+ Math.min(
898
+ options.length - 1,
899
+ focusedIndex >= 0 ? focusedIndex : (parentIndex ?? 0),
900
+ ),
901
+ );
902
+ const maxStart = Math.max(0, options.length - visibleLimit);
903
+ const start = Math.max(
904
+ 0,
905
+ Math.min(anchorIndex - Math.floor(visibleLimit / 2), maxStart),
906
+ );
907
+ const visibleOptions = options.slice(start, start + visibleLimit);
908
+ const hiddenAbove = start > 0;
909
+ const hiddenBelow = start + visibleOptions.length < options.length;
910
+ const contentWidth = Math.max(
911
+ 4,
912
+ ...options.map((option) => visibleWidth(option) + 4),
913
+ );
914
+ const border = (left: string, right: string, marker = "") =>
600
915
  this.theme.fg(
601
916
  "border",
602
- `${omitLeftBorder ? "" : left}${"─".repeat(contentWidth)}${omitRightBorder ? "" : right}`,
917
+ `${omitLeftBorder ? "" : left}${marker}${"─".repeat(Math.max(0, contentWidth - visibleWidth(marker)))}${omitRightBorder ? "" : right}`,
603
918
  );
604
919
  return [
605
- border("╭", "╮"),
606
- ...options.map((option, index) => {
920
+ border("╭", "╮", hiddenAbove ? "↑" : ""),
921
+ ...visibleOptions.map((option, localIndex) => {
922
+ const index = start + localIndex;
607
923
  const focused = index === focusedIndex;
608
924
  const row = this.fit(`${focused ? " ▶ " : " "}${option}`, contentWidth);
609
925
  const colored = focused || option === current || index === parentIndex
@@ -614,7 +930,7 @@ export class ActorInspectorOverlay {
614
930
  : colored;
615
931
  return `${omitLeftBorder ? "" : this.theme.fg("border", "│")}${styled}${omitRightBorder ? "" : this.theme.fg("border", "│")}`;
616
932
  }),
617
- border("╰", "╯"),
933
+ border("╰", "╯", hiddenBelow ? "↓" : ""),
618
934
  ];
619
935
  }
620
936
 
@@ -622,12 +938,16 @@ export class ActorInspectorOverlay {
622
938
  const filters = this.filterOptions().map(
623
939
  (filter, index) => `${filter}: ${this.filterValue(index)}`,
624
940
  );
941
+ const maxVisibleOptions = Math.max(1, this.contentViewportRows() - 1);
625
942
  const parentFocus = this.menuLevel === "filter" ? this.selectorIndex : -1;
626
943
  const parent = this.renderMenuBox(
627
944
  filters,
628
945
  parentFocus,
629
946
  undefined,
630
947
  this.menuLevel === "value" ? this.filterControlIndex : undefined,
948
+ false,
949
+ false,
950
+ maxVisibleOptions,
631
951
  );
632
952
  if (this.menuLevel !== "value") return parent;
633
953
  const parentShared = this.renderMenuBox(
@@ -637,11 +957,16 @@ export class ActorInspectorOverlay {
637
957
  this.filterControlIndex,
638
958
  false,
639
959
  true,
960
+ maxVisibleOptions,
640
961
  );
641
962
  const child = this.renderMenuBox(
642
963
  this.valueOptions(),
643
964
  this.selectorIndex,
644
965
  this.currentFilterValue(),
966
+ undefined,
967
+ false,
968
+ false,
969
+ maxVisibleOptions,
645
970
  );
646
971
  return Array.from({ length: Math.max(parent.length, child.length) }, (_, index) =>
647
972
  child[index]
@@ -657,15 +982,26 @@ export class ActorInspectorOverlay {
657
982
  if (preview) this.readKeys.add(Inspector.inspectorPreviewReadKey(preview));
658
983
  } else this.detailTurn = this.turnItems()[this.rowIndex];
659
984
  this.detailScroll = 0;
985
+ this.detailView = "evidence";
660
986
  this.detailOpen = Boolean(this.detailCommunication || this.detailTurn);
661
987
  if (this.detailOpen) this.focus = "detail";
662
988
  }
663
989
 
990
+ private backDetail(): void {
991
+ if (this.detailView === "transcript") {
992
+ this.detailView = "evidence";
993
+ this.detailScroll = 0;
994
+ return;
995
+ }
996
+ this.closeDetail();
997
+ }
998
+
664
999
  private closeDetail(): void {
665
1000
  this.detailOpen = false;
666
1001
  this.detailCommunication = undefined;
667
1002
  this.detailTurn = undefined;
668
1003
  this.detailScroll = 0;
1004
+ this.detailView = "evidence";
669
1005
  this.focus = "list";
670
1006
  }
671
1007