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.
- package/CHANGELOG.md +263 -2
- package/CONTRIBUTING.md +4 -17
- package/README.md +29 -22
- package/agents/worker.md +1 -1
- package/docs/contracts/display-liveness-v1.md +373 -0
- package/docs/contracts/drop-non-pi-kinds-v4.md +198 -0
- package/docs/contracts/esc-abort-v1.md +4 -4
- package/docs/contracts/esc-no-retire-v3.md +2 -2
- package/docs/contracts/launch-admission-queue-v1.md +839 -0
- package/docs/contracts/status-panel-v1.md +174 -0
- package/docs/inprocess-supervisor.md +37 -4
- package/docs/legion-v2-contract.md +18 -2
- package/docs/performance-2026-10-19.md +199 -0
- package/docs/spawn-mode-visibility-v1.md +206 -0
- package/docs/vexillatio-contract.md +3 -1
- package/index.ts +633 -84
- package/package.json +4 -2
- package/skills/pi-legion/SKILL.md +2 -3
- package/src/agents/agents.ts +17 -10
- package/src/agents/overrides.ts +0 -1
- package/src/agents/presets.ts +14 -40
- package/src/agents/settings.ts +26 -77
- package/src/agents/step-model.ts +7 -22
- package/src/api.ts +1 -35
- package/src/extension/event-bus.ts +80 -8
- package/src/extension/legion-commands.ts +43 -17
- package/src/extension/legion-doctor.ts +1 -1
- package/src/extension/legion-stop.ts +39 -13
- package/src/extension/legion-tools.ts +21 -4
- package/src/extension/liveness.ts +200 -0
- package/src/extension/notify.ts +6 -6
- package/src/extension/playbook.ts +6 -6
- package/src/extension/recycle.ts +6 -2
- package/src/extension/runtime.ts +422 -18
- package/src/extension/slash.ts +0 -21
- package/src/extension/spawn-render.ts +183 -0
- package/src/extension/summary.ts +1 -1
- package/src/legion/db.ts +165 -1
- package/src/legion/events.ts +81 -16
- package/src/legion/nodes.ts +13 -2
- package/src/legion/rebuild.ts +7 -1
- package/src/legion/usage.ts +58 -4
- package/src/runs/orchestrator.ts +1165 -62
- package/src/runs/worktree.ts +190 -21
- package/src/shared/ignored-kind.ts +57 -0
- package/src/shared/progress.ts +11 -0
- package/src/shared/session.ts +199 -19
- package/src/shared/types.ts +50 -66
- package/src/supervisor/admission-queue.ts +516 -0
- package/src/supervisor/inprocess-supervisor.ts +108 -33
- package/src/supervisor/proc-memory.ts +67 -0
- package/src/supervisor/routing-supervisor.ts +58 -1
- package/src/tui/live-overlay.ts +14 -0
- package/src/tui/session-messages-file.ts +100 -0
- package/src/tui/status.ts +103 -21
- package/src/tui/tree-view.ts +36 -3
- package/src/tui/viz/layout.ts +54 -3
- package/src/tui/viz/model.ts +67 -14
- package/src/tui/viz/palette.ts +11 -0
- package/src/tui/viz/panels.ts +222 -8
- package/src/tui/viz/railway.ts +28 -25
- package/src/tui/viz/view.ts +110 -23
- package/src/backends/legacy/client.ts +0 -638
- package/src/backends/legacy/cursor-chat.ts +0 -348
- package/src/backends/legacy/runner.ts +0 -101
- package/src/backends/legacy/types.ts +0 -138
- package/src/runs/kind.ts +0 -260
package/src/tui/viz/panels.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
+
}
|
package/src/tui/viz/railway.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
185
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
package/src/tui/viz/view.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
state
|
|
160
|
-
|
|
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(
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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(
|
|
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) {
|