pi-legion 3.0.3 → 4.0.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 (67) hide show
  1. package/CHANGELOG.md +263 -2
  2. package/CONTRIBUTING.md +4 -17
  3. package/README.md +29 -22
  4. package/agents/worker.md +1 -1
  5. package/docs/contracts/display-liveness-v1.md +373 -0
  6. package/docs/contracts/drop-non-pi-kinds-v4.md +198 -0
  7. package/docs/contracts/esc-abort-v1.md +4 -4
  8. package/docs/contracts/esc-no-retire-v3.md +2 -2
  9. package/docs/contracts/launch-admission-queue-v1.md +839 -0
  10. package/docs/contracts/status-panel-v1.md +174 -0
  11. package/docs/inprocess-supervisor.md +37 -4
  12. package/docs/legion-v2-contract.md +18 -2
  13. package/docs/performance-2026-10-19.md +199 -0
  14. package/docs/spawn-mode-visibility-v1.md +206 -0
  15. package/docs/vexillatio-contract.md +3 -1
  16. package/index.ts +633 -84
  17. package/package.json +4 -2
  18. package/skills/pi-legion/SKILL.md +2 -3
  19. package/src/agents/agents.ts +17 -10
  20. package/src/agents/overrides.ts +0 -1
  21. package/src/agents/presets.ts +14 -40
  22. package/src/agents/settings.ts +26 -77
  23. package/src/agents/step-model.ts +7 -22
  24. package/src/api.ts +1 -35
  25. package/src/extension/event-bus.ts +80 -8
  26. package/src/extension/legion-commands.ts +43 -17
  27. package/src/extension/legion-doctor.ts +1 -1
  28. package/src/extension/legion-stop.ts +39 -13
  29. package/src/extension/legion-tools.ts +21 -4
  30. package/src/extension/liveness.ts +200 -0
  31. package/src/extension/notify.ts +6 -6
  32. package/src/extension/playbook.ts +6 -6
  33. package/src/extension/recycle.ts +6 -2
  34. package/src/extension/runtime.ts +422 -18
  35. package/src/extension/slash.ts +0 -21
  36. package/src/extension/spawn-render.ts +183 -0
  37. package/src/extension/summary.ts +1 -1
  38. package/src/legion/db.ts +165 -1
  39. package/src/legion/events.ts +81 -16
  40. package/src/legion/nodes.ts +13 -2
  41. package/src/legion/rebuild.ts +7 -1
  42. package/src/legion/usage.ts +58 -4
  43. package/src/runs/orchestrator.ts +1165 -62
  44. package/src/runs/worktree.ts +190 -21
  45. package/src/shared/ignored-kind.ts +57 -0
  46. package/src/shared/progress.ts +11 -0
  47. package/src/shared/session.ts +199 -19
  48. package/src/shared/types.ts +50 -66
  49. package/src/supervisor/admission-queue.ts +516 -0
  50. package/src/supervisor/inprocess-supervisor.ts +108 -33
  51. package/src/supervisor/proc-memory.ts +67 -0
  52. package/src/supervisor/routing-supervisor.ts +58 -1
  53. package/src/tui/live-overlay.ts +14 -0
  54. package/src/tui/session-messages-file.ts +100 -0
  55. package/src/tui/status.ts +103 -21
  56. package/src/tui/tree-view.ts +36 -3
  57. package/src/tui/viz/layout.ts +54 -3
  58. package/src/tui/viz/model.ts +67 -14
  59. package/src/tui/viz/palette.ts +11 -0
  60. package/src/tui/viz/panels.ts +222 -8
  61. package/src/tui/viz/railway.ts +28 -25
  62. package/src/tui/viz/view.ts +110 -23
  63. package/src/backends/legacy/client.ts +0 -638
  64. package/src/backends/legacy/cursor-chat.ts +0 -348
  65. package/src/backends/legacy/runner.ts +0 -101
  66. package/src/backends/legacy/types.ts +0 -138
  67. package/src/runs/kind.ts +0 -260
@@ -2,12 +2,12 @@
2
2
 
3
3
  import { Canvas, cellWidth, ellipsize } from "./canvas.ts";
4
4
  import type { Rect } from "./layout.ts";
5
- import { formatCount, formatDuration, isAgent, type VizNode } from "./model.ts";
5
+ import { formatCount, formatDuration, isAgent, type VizNode, type VizStatus } from "./model.ts";
6
6
  import { nodeToken, roleColor } from "./palette.ts";
7
7
  import type { RateSampler } from "./rate.ts";
8
8
  import { breakdown } from "../../shared/timing-breakdown.ts";
9
9
 
10
- export type Pane = "galaxy" | "chart" | "timing" | "log";
10
+ export type Pane = "galaxy" | "chart" | "timing" | "log" | "status";
11
11
 
12
12
  export interface ViewData {
13
13
  nodes: readonly VizNode[];
@@ -477,6 +477,13 @@ export interface LogEvent {
477
477
  parentName?: string;
478
478
  }
479
479
 
480
+ /** Display states that describe a *finished* lifecycle: the only ones the log knows. */
481
+ const LOG_END_KINDS: ReadonlySet<VizStatus> = new Set<VizStatus>(["settled", "failed", "retired"]);
482
+
483
+ function isLogEndKind(status: VizStatus): status is Exclude<LogEvent["kind"], "spawn"> {
484
+ return LOG_END_KINDS.has(status);
485
+ }
486
+
480
487
  export function buildLog(nodes: readonly VizNode[]): LogEvent[] {
481
488
  const byId = new Map(nodes.map((node) => [node.id, node]));
482
489
  const out: LogEvent[] = [];
@@ -484,7 +491,7 @@ export function buildLog(nodes: readonly VizNode[]): LogEvent[] {
484
491
  if (!isAgent(node)) continue;
485
492
  const parentName = node.parentId ? byId.get(node.parentId)?.name : undefined;
486
493
  out.push({ ts: node.startedAt, kind: "spawn", node, ...(parentName ? { parentName } : {}) });
487
- if (node.status !== "running" && node.endedAt !== undefined) out.push({ ts: node.endedAt, kind: node.status, node });
494
+ if (node.endedAt !== undefined && isLogEndKind(node.status)) out.push({ ts: node.endedAt, kind: node.status, node });
488
495
  }
489
496
  return out.sort((a, b) => b.ts - a.ts || (a.kind === "spawn" ? 1 : -1));
490
497
  }
@@ -503,8 +510,14 @@ export function drawLog(
503
510
  state: { offset: number; cursor: number; focused: boolean; selectedId?: string; origin: number },
504
511
  ): void {
505
512
  if (r.w < 8 || r.h < 2) return;
513
+ // Belt and braces (#251): the border column is `r.x + r.w` (this is the INNER rect
514
+ // `frame()` hands back), and `Canvas.text`'s `maxX` is exclusive, so `r.x + r.w` is
515
+ // exactly "the last inner cell, never the border" — the same value `frame()` and
516
+ // `drawStatus` use. The budgets below already keep every write inside it; this is
517
+ // what stops a future edit to those budgets from silently running onto the border.
518
+ const maxX = r.x + r.w;
506
519
  const filter = state.selectedId ? events.find((e) => e.node.id === state.selectedId)?.node.name : undefined;
507
- cv.text(r.x, r.y, ellipsize(`${events.length} events${filter ? ` · highlight: ${filter}` : ""}`, r.w), "dim");
520
+ cv.text(r.x, r.y, ellipsize(`${events.length} events${filter ? ` · highlight: ${filter}` : ""}`, r.w), "dim", { maxX });
508
521
  const rows = r.h - 1;
509
522
  const maxDepth = Math.max(1, ...events.map((e) => e.node.depth));
510
523
  const graphW = Math.min(8, maxDepth * 2);
@@ -517,7 +530,7 @@ export function drawLog(
517
530
  const isCursor = state.focused && index === state.cursor;
518
531
  const highlight = state.selectedId === event.node.id;
519
532
  const dim = state.selectedId !== undefined && !highlight;
520
- cv.text(r.x, y, formatDuration(event.ts - state.origin).padStart(timeW - 1), "dim");
533
+ cv.text(r.x, y, formatDuration(event.ts - state.origin).padStart(timeW - 1), "dim", { maxX });
521
534
  const gx = r.x + timeW;
522
535
  if (r.w > timeW + graphW + 10) {
523
536
  for (let d = 1; d < Math.min(event.node.depth, graphW / 2 + 1); d++) cv.set(gx + (d - 1) * 2, y, "│", "borderMuted");
@@ -527,13 +540,21 @@ export function drawLog(
527
540
  const tx = r.w > timeW + graphW + 10 ? gx + graphW + 1 : gx;
528
541
  const [glyph, glyphCol] = KIND_GLYPH[event.kind];
529
542
  cv.set(tx, y, glyph, glyphCol, { bold: true });
530
- const nameW = Math.min(16, Math.max(6, Math.floor((r.x + r.w - tx - 2) * 0.45)));
543
+ // Room between the glyph and the border: `r.x + r.w` is the border column, so the
544
+ // last cell the name may use is `r.x + r.w - 1`. The old `Math.max(6, …)` floor
545
+ // ignored this room, so on a narrow pane `nameW` stayed 6 with 1–2 cells left and
546
+ // the name ran through the border (#251). `nameW <= nameRoom` also bounds the detail
547
+ // below: its budget is `r.x + r.w - (tx + 3 + nameW)` cells, so the detail's last
548
+ // cell is `tx + 3 + nameW + budget - 1 <= r.x + r.w - 1`. `nameRoom <= 0` degrades
549
+ // to "" (ellipsize(_, 0) === "") instead of printing a truncated name past the rect.
550
+ const nameRoom = r.x + r.w - (tx + 2);
551
+ const nameW = Math.min(16, Math.max(0, Math.floor(nameRoom * 0.45)));
531
552
  const name = ellipsize(event.node.name, nameW);
532
- cv.text(tx + 2, y, name, dim ? "dim" : roleColor(event.node.role), { bold: !dim, inverse: isCursor });
553
+ cv.text(tx + 2, y, name, dim ? "dim" : roleColor(event.node.role), { bold: !dim, inverse: isCursor, maxX });
533
554
  const detail = event.kind === "spawn"
534
555
  ? `${event.parentName ?? "root"} → ${event.node.role}`
535
556
  : `${event.kind === "settled" ? "done" : event.kind} · ${formatCount(event.node.tokens)} tok`;
536
- cv.text(tx + 3 + nameW, y, ellipsize(detail, Math.max(0, r.x + r.w - (tx + 3 + nameW))), highlight ? "text" : "dim");
557
+ cv.text(tx + 3 + nameW, y, ellipsize(detail, Math.max(0, r.x + r.w - (tx + 3 + nameW))), highlight ? "text" : "dim", { maxX });
537
558
  if (isCursor && highlight) cv.set(r.x + r.w - 1, y, "◂", "accent", { bold: true });
538
559
  }
539
560
  if (events.length > rows) {
@@ -688,3 +709,196 @@ export function drawTiming(
688
709
  }
689
710
  }
690
711
  }
712
+
713
+ // ---------------------------------------------------------------- status (D1)
714
+ // The `/legion status` table, on the board. Every new field is optional on
715
+ // VizNode (D6), so a missing `spawnMode` prints `unknown` — never a guessed
716
+ // `rpc` — and an `inprocess` child prints `—` for rss instead of a share of the
717
+ // host figure it does not own (D2).
718
+
719
+ export interface StatusDrawState {
720
+ offset: number;
721
+ cursor: number;
722
+ focused: boolean;
723
+ selectedId?: string;
724
+ }
725
+
726
+ export interface StatusColumn {
727
+ key: "name" | "role" | "state" | "supervisor" | "ctx" | "rss" | "turns";
728
+ label: string;
729
+ width: number;
730
+ align: "left" | "right";
731
+ }
732
+
733
+ /** Placeholder for "there is no honest value here" — never a fabricated number. */
734
+ const NO_VALUE = "\u2014";
735
+
736
+ /** Agents in the status table, oldest first. The root row is the run, not an agent. */
737
+ export function statusRowsFromNodes(nodes: readonly VizNode[]): VizNode[] {
738
+ return nodes
739
+ .filter(isAgent)
740
+ .sort((a, b) => a.startedAt - b.startedAt || a.name.localeCompare(b.name));
741
+ }
742
+
743
+ /**
744
+ * D7 width grading in **absolute columns**, never a ratio (the `drawTiming`
745
+ * convention): the row always keeps `name · state`, and the optional fields come
746
+ * back one at a time as the rect grows. `undefined` = below the 20-column floor,
747
+ * so the pane paints nothing instead of a column of stubs.
748
+ *
749
+ * The widths sum exactly to `width`, so a row can never spill past its rect.
750
+ */
751
+ export function statusColumns(width: number): StatusColumn[] | undefined {
752
+ if (width < 20) return undefined;
753
+ const state: StatusColumn = { key: "state", label: "state", width: 7, align: "left" };
754
+ if (width < 36) {
755
+ return [{ key: "name", label: "name", width: width - 8, align: "left" }, state];
756
+ }
757
+ if (width < 52) {
758
+ return [
759
+ { key: "name", label: "name", width: width - 15, align: "left" },
760
+ state,
761
+ { key: "ctx", label: "ctx", width: 6, align: "right" },
762
+ ];
763
+ }
764
+ if (width < 72) {
765
+ return [
766
+ { key: "name", label: "name", width: width - 25, align: "left" },
767
+ state,
768
+ { key: "supervisor", label: "sup", width: 9, align: "left" },
769
+ { key: "ctx", label: "ctx", width: 6, align: "right" },
770
+ ];
771
+ }
772
+ return [
773
+ { key: "name", label: "name", width: width - 43, align: "left" },
774
+ { key: "role", label: "role", width: 4, align: "left" },
775
+ state,
776
+ { key: "supervisor", label: "sup", width: 9, align: "left" },
777
+ { key: "ctx", label: "ctx", width: 6, align: "right" },
778
+ { key: "rss", label: "rss", width: 6, align: "right" },
779
+ { key: "turns", label: "turns", width: 5, align: "right" },
780
+ ];
781
+ }
782
+
783
+ /**
784
+ * Rows `drawStatus` can paint for a pane whose **outer** (framed) height is
785
+ * `outerH`. Exported so the view clamps the scroll offset with the draw layer's
786
+ * own arithmetic instead of the off-by-one `layout.h - 3` it uses for timing.
787
+ */
788
+ export function statusVisibleRows(outerH: number, total: number): number {
789
+ const innerH = Math.max(0, outerH - 2);
790
+ const footerH = total > 0 && innerH >= 4 ? 1 : 0;
791
+ return Math.max(0, innerH - 1 - footerH);
792
+ }
793
+
794
+ /** `unknown` for a missing mode (D6): a legacy row is not evidence of `rpc`. */
795
+ function statusSupervisor(node: VizNode): string {
796
+ return node.spawnMode ?? "unknown";
797
+ }
798
+
799
+ /**
800
+ * D2: only an `rpc` child owns a process, so only it has an honest RSS. An
801
+ * `inprocess` child shares this host process — apportioning the host figure
802
+ * across children would be fabricated data the user then decides on, so it
803
+ * prints `—`. Lowercase `m` keeps the panel inside the plain glyph set.
804
+ */
805
+ function statusRss(node: VizNode): string {
806
+ if (node.spawnMode === "inprocess") return NO_VALUE;
807
+ if (node.rss === undefined || !Number.isFinite(node.rss) || node.rss < 0) return NO_VALUE;
808
+ return `${Math.round(node.rss)}m`;
809
+ }
810
+
811
+ function statusValue(node: VizNode, key: StatusColumn["key"]): string {
812
+ switch (key) {
813
+ case "name":
814
+ return node.name;
815
+ case "role":
816
+ return roleAbbrev(node.role);
817
+ case "state":
818
+ return node.status === "settled" ? "done" : node.status;
819
+ case "supervisor":
820
+ return statusSupervisor(node);
821
+ case "ctx":
822
+ return node.contextTokens !== undefined && node.contextTokens > 0 ? formatCount(node.contextTokens) : NO_VALUE;
823
+ case "rss":
824
+ return statusRss(node);
825
+ case "turns":
826
+ return node.turns !== undefined && node.turns > 0 ? String(node.turns) : NO_VALUE;
827
+ }
828
+ }
829
+
830
+ function statusColor(node: VizNode, key: StatusColumn["key"], dim: boolean): string {
831
+ if (dim) return "dim";
832
+ if (key === "name") return roleColor(node.role);
833
+ if (key === "state") {
834
+ if (node.status === "failed") return "error";
835
+ if (node.status === "settled") return "success";
836
+ if (node.status === "running") return "accent";
837
+ return "dim";
838
+ }
839
+ return "dim";
840
+ }
841
+
842
+ /** Ellipsize then pad to exactly `width` cells (grapheme-safe, never overflows). */
843
+ function padCell(value: string, width: number, align: "left" | "right"): string {
844
+ const clipped = ellipsize(value, width);
845
+ const pad = " ".repeat(Math.max(0, width - cellWidth(clipped)));
846
+ return align === "right" ? pad + clipped : clipped + pad;
847
+ }
848
+
849
+ /**
850
+ * Status table: header row, one row per agent, optional footer.
851
+ *
852
+ * `r` is the **inner** rect `frame()` hands back, so the header line sits on the
853
+ * first row inside the border. Every `cv.text` is ellipsized to its column *and*
854
+ * carries `maxX` — the pane never writes past its own rect the way `drawLog`
855
+ * can on a very narrow terminal.
856
+ */
857
+ export function drawStatus(
858
+ cv: Canvas,
859
+ r: Rect,
860
+ nodes: readonly VizNode[],
861
+ state: StatusDrawState,
862
+ ): void {
863
+ if (r.w < 20 || r.h < 2) return;
864
+ const columns = statusColumns(r.w);
865
+ if (!columns) return;
866
+ const maxX = r.x + r.w;
867
+ const rows = statusRowsFromNodes(nodes);
868
+ const footerH = rows.length > 0 && r.h >= 4 ? 1 : 0;
869
+ const rowsAvail = Math.max(0, r.h - 1 - footerH);
870
+
871
+ // Header: labels only. Panel content never lands on the title rule.
872
+ let x = r.x;
873
+ columns.forEach((column, index) => {
874
+ cv.text(x, r.y, padCell(column.label, column.width, column.align), "dim", { maxX });
875
+ x += column.width + (index < columns.length - 1 ? 1 : 0);
876
+ });
877
+
878
+ for (let i = 0; i < rowsAvail; i++) {
879
+ const index = state.offset + i;
880
+ const node = rows[index];
881
+ if (!node) break;
882
+ const y = r.y + 1 + i;
883
+ const isCursor = state.focused && index === state.cursor;
884
+ const highlight = state.selectedId === node.id;
885
+ const dim = state.selectedId !== undefined && !highlight;
886
+ let cx = r.x;
887
+ columns.forEach((column, ci) => {
888
+ cv.text(cx, y, padCell(statusValue(node, column.key), column.width, column.align), statusColor(node, column.key, dim), {
889
+ ...(column.key === "name" && !dim ? { bold: true } : {}),
890
+ ...(column.key === "name" && isCursor ? { inverse: true } : {}),
891
+ maxX,
892
+ });
893
+ cx += column.width + (ci < columns.length - 1 ? 1 : 0);
894
+ });
895
+ }
896
+
897
+ if (footerH) {
898
+ const live = rows.filter((node) => node.status === "running").length;
899
+ const hidden = Math.max(0, rows.length - rowsAvail);
900
+ const parts = [`${rows.length} agents`, `${live} live`];
901
+ if (hidden > 0) parts.push(`+${hidden} more`);
902
+ cv.text(r.x, r.y + r.h - 1, ellipsize(parts.join(" · "), r.w), "dim", { maxX });
903
+ }
904
+ }
@@ -156,8 +156,8 @@ export function formatRate(rate: number): string {
156
156
 
157
157
 
158
158
  /**
159
- * Wide-lane detail after the name: role, turn/lifetime clocks, and
160
- * last-activity age.
159
+ * Wide-lane detail after the name: role, turn/lifetime clocks, and — once it
160
+ * is at least a minute old — the last-activity age.
161
161
  *
162
162
  * The leading clock is "time since the last task started": it keeps ticking
163
163
  * through the quiet gap after a turn settles (the old behavior froze at that
@@ -170,34 +170,37 @@ export function formatRate(rate: number): string {
170
170
  * has a real start time and a real lifetime; show both.
171
171
  */
172
172
  export function formatRailLaneDetail(node: VizNode, now: number): string {
173
- const parts: string[] = [node.role];
174
- parts.push(formatAgentElapsed({
175
- state: node.status,
176
- startedAt: node.startedAt,
177
- ...(node.endedAt !== undefined ? { finishedAt: node.endedAt } : {}),
178
- ...(node.currentTurnStartedAt !== undefined ? { currentTurnStartedAt: node.currentTurnStartedAt } : {}),
179
- }, now));
180
- parts.push(formatActivityAgo(node.lastActivityAt, now));
181
- return parts.join(" · ");
173
+ return laneDetailParts(node, now).join(" · ");
182
174
  }
183
175
 
184
- /** Lane detail candidates, richest first (role → clocks → ago). */
185
- export function laneDetailCandidates(node: VizNode, now: number): string[] {
176
+ /**
177
+ * Lane detail fields, richest first: role, clocks, then last-activity age —
178
+ * the age is omitted entirely while it is under a minute, because a lane that
179
+ * just saw activity is already visibly live and `1s ago` was pure noise.
180
+ */
181
+ function laneDetailParts(node: VizNode, now: number): string[] {
186
182
  const ago = formatActivityAgo(node.lastActivityAt, now);
187
- const clocks = formatAgentElapsed({
188
- state: node.status,
189
- startedAt: node.startedAt,
190
- ...(node.endedAt !== undefined ? { finishedAt: node.endedAt } : {}),
191
- ...(node.currentTurnStartedAt !== undefined ? { currentTurnStartedAt: node.currentTurnStartedAt } : {}),
192
- }, now);
193
183
  return [
194
- `${node.role} · ${clocks} · ${ago}`,
195
- `${clocks} · ${ago}`,
196
- clocks,
197
- ago,
184
+ node.role,
185
+ formatAgentElapsed({
186
+ state: node.status,
187
+ startedAt: node.startedAt,
188
+ ...(node.endedAt !== undefined ? { finishedAt: node.endedAt } : {}),
189
+ ...(node.currentTurnStartedAt !== undefined ? { currentTurnStartedAt: node.currentTurnStartedAt } : {}),
190
+ }, now),
191
+ ...(ago ? [ago] : []),
198
192
  ];
199
193
  }
200
194
 
195
+ /** Lane detail candidates, richest first (role → clocks → ago). */
196
+ export function laneDetailCandidates(node: VizNode, now: number): string[] {
197
+ const parts = laneDetailParts(node, now);
198
+ // Drop fields from the left: the role goes first, the clocks are the last
199
+ // thing to give up. A fresh lane has no age field, so it ends at the clocks
200
+ // instead of paying for a `1s ago` suffix it never shows.
201
+ return parts.map((_, start) => parts.slice(start).join(" · "));
202
+ }
203
+
201
204
  /**
202
205
  * Width of the lane-name column: as wide as the longest name, clamped to
203
206
  * `RAIL_NAME_MAX_COLS` and never below `RAIL_NAME_MIN_COLS`.
@@ -255,7 +258,7 @@ export function railColumnPlan(
255
258
  if (portrait) {
256
259
  const label = Math.max(...shown.map((n) => {
257
260
  const ago = formatActivityAgo(n.lastActivityAt, now);
258
- return Array.from(ago === "—" ? n.name : `${n.name} · ${ago}`).length;
261
+ return Array.from(ago === "" || ago === "—" ? n.name : `${n.name} · ${ago}`).length;
259
262
  }));
260
263
  return { nameW, grade: 0, needed: label + 2 + reserve };
261
264
  }
@@ -423,7 +426,7 @@ export function renderRailway(input: RailInput): RailRender | undefined {
423
426
  const avail = width - (nameX + 2) - overflow.length;
424
427
  if (portrait) {
425
428
  const ago = formatActivityAgo(node.lastActivityAt, now);
426
- const label = ago === "—" ? node.name : `${node.name} · ${ago}`;
429
+ const label = ago === "" || ago === "—" ? node.name : `${node.name} · ${ago}`;
427
430
  canvas.text(nameX + 2, y, ellipsize(label, avail), col, { bold: true });
428
431
  } else {
429
432
  // Paint with the same width the plan measured (clamped to what is
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { Key, matchesKey } from "@earendil-works/pi-tui";
4
4
  import { Canvas, cellWidth, ellipsize } from "./canvas.ts";
5
- import { computeLayout, type LayoutKind } from "./layout.ts";
5
+ import { computeLayout, type LayoutKind, type LegionLayout } from "./layout.ts";
6
6
  import { formatCount, formatDuration, isAgent, type VizNode } from "./model.ts";
7
7
  import { roleColor, type VizTheme } from "./palette.ts";
8
8
  import {
@@ -11,8 +11,11 @@ import {
11
11
  drawChart,
12
12
  drawGalaxy,
13
13
  drawLog,
14
+ drawStatus,
14
15
  drawTiming,
15
16
  frame,
17
+ statusRowsFromNodes,
18
+ statusVisibleRows,
16
19
  timingRowsFromNodes,
17
20
  type Pane,
18
21
  type ViewData,
@@ -25,15 +28,35 @@ export interface LegionViewState {
25
28
  logCursor: number;
26
29
  timingOffset: number;
27
30
  timingCursor: number;
31
+ statusOffset: number;
32
+ statusCursor: number;
28
33
  /** `x` pressed: an in-view y/N prompt for stopping this agent (dialogs would be hidden behind the full-screen view). */
29
34
  pendingStopId?: string;
30
35
  }
31
36
 
32
37
  export function initialViewState(): LegionViewState {
33
- return { focus: "galaxy", logOffset: 0, logCursor: 0, timingOffset: 0, timingCursor: 0 };
38
+ return {
39
+ focus: "galaxy",
40
+ logOffset: 0,
41
+ logCursor: 0,
42
+ timingOffset: 0,
43
+ timingCursor: 0,
44
+ statusOffset: 0,
45
+ statusCursor: 0,
46
+ };
34
47
  }
35
48
 
36
- const PANES: Pane[] = ["galaxy", "chart", "timing", "log"];
49
+ /** Tab order (D8): `status` last, so the existing galaxy→chart→timing→log walk is unchanged. */
50
+ const PANES: readonly Pane[] = ["galaxy", "chart", "timing", "log", "status"];
51
+
52
+ /**
53
+ * Panes this layout actually placed. Portrait leaves no room for a fifth pane
54
+ * and returns a zero-height `status` rect (D1); Tab must skip what is not drawn
55
+ * rather than park the focus on an invisible box.
56
+ */
57
+ export function availablePanes(layout: LegionLayout): Pane[] {
58
+ return PANES.filter((pane) => layout[pane].h > 0);
59
+ }
37
60
 
38
61
  /** Min grapheme cells before painting a selection badge. Shared by portrait + landscape so a truncated stub never paints (#128). Was portrait `>4` vs landscape `>0`. */
39
62
  export const MIN_SELECTION_BADGE_W = 5;
@@ -114,6 +137,25 @@ export function renderLegionView(data: ViewData, state: LegionViewState, cols: n
114
137
  origin,
115
138
  });
116
139
 
140
+ // Status table (D1/D7). Portrait hands back a zero-area rect, so `frame()`
141
+ // paints nothing and `drawStatus` bails on `r.w < 20` — no special case here.
142
+ const statusRows = statusRowsFromNodes(data.nodes);
143
+ const statusVisible = Math.max(1, statusVisibleRows(layout.status.h, statusRows.length));
144
+ clampList(state, "status", statusRows.length, statusVisible);
145
+ const statusInner = frame(
146
+ cv,
147
+ layout.status,
148
+ portrait ? "status" : "status · agents",
149
+ state.focus === "status",
150
+ statusInnerHint(layout.kind),
151
+ );
152
+ drawStatus(cv, statusInner, statusRows, {
153
+ offset: state.statusOffset,
154
+ cursor: state.statusCursor,
155
+ focused: state.focus === "status",
156
+ ...(state.selectedId ? { selectedId: state.selectedId } : {}),
157
+ });
158
+
117
159
  // Footer.
118
160
  const keys: Array<[string, string]> = portrait
119
161
  ? [["Tab", "pane"], ["↑↓", ""], ["⏎", "sel"], ["o", "open"], ["x", "stop"], ["esc", ""], ["q", ""]]
@@ -143,31 +185,44 @@ function statusLabel(node: VizNode): string {
143
185
  return node.status === "settled" ? "done" : node.status;
144
186
  }
145
187
 
188
+ /**
189
+ * Key hint on the status title rule. Suppressed in portrait, where the pane is
190
+ * zero-area and never drawn at all.
191
+ */
192
+ function statusInnerHint(kind: LayoutKind): string | undefined {
193
+ return kind === "portrait" ? undefined : "↑↓ scroll · ⏎ select";
194
+ }
195
+
146
196
  function clampList(
147
197
  state: LegionViewState,
148
- which: "log" | "timing",
198
+ which: "log" | "timing" | "status",
149
199
  count: number,
150
200
  visible: number,
151
201
  ): void {
152
- if (which === "log") {
153
- state.logCursor = Math.max(0, Math.min(state.logCursor, count - 1));
154
- if (state.logCursor < state.logOffset) state.logOffset = state.logCursor;
155
- if (state.logCursor >= state.logOffset + visible) state.logOffset = state.logCursor - visible + 1;
156
- state.logOffset = Math.max(0, Math.min(state.logOffset, Math.max(0, count - visible)));
157
- return;
158
- }
159
- state.timingCursor = Math.max(0, Math.min(state.timingCursor, count - 1));
160
- if (state.timingCursor < state.timingOffset) state.timingOffset = state.timingCursor;
161
- if (state.timingCursor >= state.timingOffset + visible) state.timingOffset = state.timingCursor - visible + 1;
162
- state.timingOffset = Math.max(0, Math.min(state.timingOffset, Math.max(0, count - visible)));
202
+ const [cursorKey, offsetKey] = which === "log"
203
+ ? (["logCursor", "logOffset"] as const)
204
+ : which === "status"
205
+ ? (["statusCursor", "statusOffset"] as const)
206
+ : (["timingCursor", "timingOffset"] as const);
207
+ state[cursorKey] = Math.max(0, Math.min(state[cursorKey], count - 1));
208
+ if (state[cursorKey] < state[offsetKey]) state[offsetKey] = state[cursorKey];
209
+ if (state[cursorKey] >= state[offsetKey] + visible) state[offsetKey] = state[cursorKey] - visible + 1;
210
+ state[offsetKey] = Math.max(0, Math.min(state[offsetKey], Math.max(0, count - visible)));
163
211
  }
164
212
 
165
213
  export type ViewAction = "none" | "render" | "close" | "open" | "stop";
166
214
 
167
215
  /** Pure key handling so it can be unit tested without a terminal. */
168
- export function handleViewKey(data: string, state: LegionViewState, nodes: readonly VizNode[], page = 10): ViewAction {
216
+ export function handleViewKey(
217
+ data: string,
218
+ state: LegionViewState,
219
+ nodes: readonly VizNode[],
220
+ page = 10,
221
+ panes: readonly Pane[] = PANES,
222
+ ): ViewAction {
169
223
  const events = buildLog(nodes);
170
224
  const timingRows = timingRowsFromNodes(nodes, Date.now());
225
+ const statusRows = statusRowsFromNodes(nodes);
171
226
  const order = dfsOrder(nodes).filter(isAgent);
172
227
  if (state.pendingStopId) {
173
228
  const confirmed = matchesKey(data, "y") || data === "Y" || matchesKey(data, Key.enter);
@@ -183,12 +238,16 @@ export function handleViewKey(data: string, state: LegionViewState, nodes: reado
183
238
  }
184
239
  return "close";
185
240
  }
186
- if (matchesKey(data, Key.tab)) {
187
- state.focus = PANES[(PANES.indexOf(state.focus) + 1) % PANES.length]!;
188
- return "render";
189
- }
190
- if (matchesKey(data, Key.shift("tab"))) {
191
- state.focus = PANES[(PANES.indexOf(state.focus) + PANES.length - 1) % PANES.length]!;
241
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab"))) {
242
+ // `panes` defaults to every pane so existing callers keep the full walk;
243
+ // the host passes `availablePanes(layout)` so Tab skips the zero-area
244
+ // status pane in portrait (D1).
245
+ const ring = panes.length > 0 ? panes : PANES;
246
+ const step = matchesKey(data, Key.tab) ? 1 : ring.length - 1;
247
+ const at = ring.indexOf(state.focus);
248
+ // A focus outside `ring` (e.g. status after a resize to portrait) restarts
249
+ // the walk at a pane that is actually drawn.
250
+ state.focus = ring[(at < 0 ? (step > 0 ? 0 : ring.length - 1) : (at + step) % ring.length)]!;
192
251
  return "render";
193
252
  }
194
253
  if (matchesKey(data, "o")) return state.selectedId ? "open" : "none";
@@ -230,6 +289,21 @@ export function handleViewKey(data: string, state: LegionViewState, nodes: reado
230
289
  } else return "none";
231
290
  return "render";
232
291
  }
292
+ if (state.focus === "status") {
293
+ if (down) state.statusCursor = Math.min(statusRows.length - 1, state.statusCursor + 1);
294
+ else if (up) state.statusCursor = Math.max(0, state.statusCursor - 1);
295
+ else if (matchesKey(data, Key.pageDown)) state.statusCursor = Math.min(statusRows.length - 1, state.statusCursor + page);
296
+ else if (matchesKey(data, Key.pageUp)) state.statusCursor = Math.max(0, state.statusCursor - page);
297
+ else if (matchesKey(data, Key.home)) state.statusCursor = 0;
298
+ else if (matchesKey(data, Key.end)) state.statusCursor = Math.max(0, statusRows.length - 1);
299
+ else if (matchesKey(data, Key.enter)) {
300
+ const row = statusRows[state.statusCursor];
301
+ if (!row) return "none";
302
+ state.selectedId = state.selectedId === row.id ? undefined : row.id;
303
+ if (state.selectedId === undefined) delete state.selectedId;
304
+ } else return "none";
305
+ return "render";
306
+ }
233
307
  if (down || up || matchesKey(data, Key.right) || matchesKey(data, Key.left)) {
234
308
  if (order.length === 0) return "none";
235
309
  const step = down || matchesKey(data, Key.right) ? 1 : -1;
@@ -261,6 +335,12 @@ export function createLegionViewFactory(options: LegionViewOptions) {
261
335
  const state = initialViewState();
262
336
  const timer = setInterval(() => tui.requestRender(), options.refreshMs ?? 1000);
263
337
  (timer as { unref?: () => void }).unref?.();
338
+ /**
339
+ * Panes the last painted frame actually placed, so Tab walks what the user
340
+ * can see: portrait leaves no room for a fifth pane (D1). Before the first
341
+ * paint this stays at the full ring, which is also `handleViewKey`'s default.
342
+ */
343
+ let panes: Pane[] = [...PANES];
264
344
  let closed = false;
265
345
  const close = (): void => {
266
346
  if (closed) return;
@@ -272,10 +352,17 @@ export function createLegionViewFactory(options: LegionViewOptions) {
272
352
  render(width: number): string[] {
273
353
  // Rendered as a full-screen overlay: own the whole terminal height.
274
354
  const rows = Math.max(6, tui.terminal.rows ?? 24);
355
+ panes = availablePanes(computeLayout(width, rows));
275
356
  return renderLegionView(options.getData(), state, width, rows).canvas.toLines(theme);
276
357
  },
277
358
  handleInput(data: string): void {
278
- const action = handleViewKey(data, state, options.getData().nodes, Math.max(1, (tui.terminal.rows ?? 24) - 8));
359
+ const action = handleViewKey(
360
+ data,
361
+ state,
362
+ options.getData().nodes,
363
+ Math.max(1, (tui.terminal.rows ?? 24) - 8),
364
+ panes,
365
+ );
279
366
  if (action === "close") return close();
280
367
  if (action === "open" && state.selectedId) options.onOpenNode?.(state.selectedId);
281
368
  if (action === "stop" && state.pendingStopId) {