pi-legion 2.0.3 → 3.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 +93 -1
- package/CONTRIBUTING.md +27 -24
- package/README.md +103 -19
- package/docs/contracts/osc-busy-plugin.md +72 -0
- package/docs/contracts/spawn-default-inprocess-v3.md +322 -0
- package/docs/contracts/wait-not-stop.md +30 -0
- package/docs/design.md +2 -2
- package/docs/headless-child-contract.md +83 -0
- package/docs/inprocess-supervisor.md +140 -0
- package/docs/qa-hunting-round.md +150 -0
- package/docs/subagents-teams.md +55 -18
- package/docs/tui-qa-playbook.md +284 -0
- package/index.ts +396 -62
- package/package.json +15 -8
- package/skills/pi-legion/SKILL.md +5 -0
- package/src/agents/agents.ts +34 -6
- package/src/agents/context.ts +83 -1
- package/src/agents/overrides.ts +1 -0
- package/src/agents/settings.ts +122 -1
- package/src/api.ts +23 -0
- package/src/extension/child-guard.ts +42 -3
- package/src/extension/legion-commands.ts +15 -3
- package/src/extension/legion-doctor.ts +55 -0
- package/src/extension/legion-stop.ts +6 -2
- package/src/extension/playbook.ts +3 -3
- package/src/extension/runtime.ts +57 -8
- package/src/extension/scout-guard.ts +39 -1
- package/src/extension/session-errors.ts +191 -0
- package/src/extension/timing.ts +391 -0
- package/src/runs/args.ts +8 -0
- package/src/runs/orchestrator.ts +125 -16
- package/src/shared/output-cap.ts +2 -2
- package/src/shared/session.ts +2 -1
- package/src/shared/timing-breakdown.ts +23 -0
- package/src/shared/types.ts +74 -0
- package/src/supervisor/child-context.ts +90 -0
- package/src/supervisor/child-memory.ts +27 -3
- package/src/supervisor/inprocess-depth.ts +71 -0
- package/src/supervisor/inprocess-supervisor.ts +1011 -0
- package/src/supervisor/routing-supervisor.ts +179 -0
- package/src/supervisor/rpc-supervisor.ts +14 -0
- package/src/supervisor/spawn-mode.ts +35 -0
- package/src/supervisor/types.ts +13 -0
- package/src/supervisor/ui-proxy.ts +78 -0
- package/src/tui/live-overlay.ts +297 -24
- package/src/tui/status.ts +71 -35
- package/src/tui/viz/layout.ts +50 -6
- package/src/tui/viz/model.ts +6 -0
- package/src/tui/viz/panels.ts +164 -12
- package/src/tui/viz/railway.ts +154 -21
- package/src/tui/viz/rate.ts +36 -14
- package/src/tui/viz/view.ts +69 -10
package/src/tui/viz/layout.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface LegionLayout {
|
|
|
14
14
|
header: Rect;
|
|
15
15
|
galaxy: Rect;
|
|
16
16
|
chart: Rect;
|
|
17
|
+
/** Subagent wall/gen/other table (#151). */
|
|
18
|
+
timing: Rect;
|
|
17
19
|
log: Rect;
|
|
18
20
|
footer: Rect;
|
|
19
21
|
}
|
|
@@ -28,6 +30,11 @@ export const PORTRAIT_CHART_MAX = 18;
|
|
|
28
30
|
/** Side-by-side layouts never stretch panels beyond this many rows. */
|
|
29
31
|
export const STACKED_TOP_MAX = 24;
|
|
30
32
|
export const WIDE_CHART_MAX = 24;
|
|
33
|
+
/** Compact timing band in portrait / stacked-strip. */
|
|
34
|
+
export const TIMING_BAND_MIN = 5;
|
|
35
|
+
export const TIMING_BAND_MAX = 10;
|
|
36
|
+
/** Below this bottom height, stacked prefers a timing strip over side-by-side. */
|
|
37
|
+
export const STACKED_TIMING_SIDE_MIN = 8;
|
|
31
38
|
|
|
32
39
|
/** Terminal cells are ~2:1 (tall), so rows*2 >= cols means the window looks taller than wide. */
|
|
33
40
|
export function isTall(cols: number, rows: number): boolean {
|
|
@@ -60,35 +67,65 @@ export function computeLayout(cols: number, rows: number, hints: LayoutHints = {
|
|
|
60
67
|
// Chart is capped; on very tall wide terminals the galaxy absorbs the rest.
|
|
61
68
|
const chartH = Math.min(WIDE_CHART_MAX, Math.max(3, Math.round(bodyH * 0.45)));
|
|
62
69
|
const galaxyH = bodyH - chartH;
|
|
70
|
+
const rightW = W - leftW;
|
|
71
|
+
// Right column: timing on top, log below (#151).
|
|
72
|
+
const timingH = Math.min(
|
|
73
|
+
Math.max(TIMING_BAND_MIN, Math.round(bodyH * 0.42)),
|
|
74
|
+
Math.max(TIMING_BAND_MIN, bodyH - 6),
|
|
75
|
+
);
|
|
63
76
|
return {
|
|
64
77
|
kind,
|
|
65
78
|
header,
|
|
66
79
|
footer,
|
|
67
80
|
galaxy: { x: 0, y: bodyY, w: leftW, h: galaxyH },
|
|
68
81
|
chart: { x: 0, y: bodyY + galaxyH, w: leftW, h: chartH },
|
|
69
|
-
|
|
82
|
+
timing: { x: leftW, y: bodyY, w: rightW, h: timingH },
|
|
83
|
+
log: { x: leftW, y: bodyY + timingH, w: rightW, h: Math.max(0, bodyH - timingH) },
|
|
70
84
|
};
|
|
71
85
|
}
|
|
72
86
|
if (kind === "stacked") {
|
|
73
87
|
const topH = Math.min(STACKED_TOP_MAX, Math.max(3, Math.round(bodyH * 0.58)));
|
|
74
88
|
const galaxyW = Math.floor(W * 0.5);
|
|
89
|
+
const bottomH = bodyH - topH;
|
|
90
|
+
const bottomY = bodyY + topH;
|
|
91
|
+
// Prefer timing|log side-by-side; strip when the bottom band is too short (#151).
|
|
92
|
+
if (bottomH >= STACKED_TIMING_SIDE_MIN) {
|
|
93
|
+
const timingW = Math.floor(W * 0.45);
|
|
94
|
+
return {
|
|
95
|
+
kind,
|
|
96
|
+
header,
|
|
97
|
+
footer,
|
|
98
|
+
galaxy: { x: 0, y: bodyY, w: galaxyW, h: topH },
|
|
99
|
+
chart: { x: galaxyW, y: bodyY, w: W - galaxyW, h: topH },
|
|
100
|
+
timing: { x: 0, y: bottomY, w: timingW, h: bottomH },
|
|
101
|
+
log: { x: timingW, y: bottomY, w: W - timingW, h: bottomH },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const timingH = Math.min(TIMING_BAND_MAX, Math.max(TIMING_BAND_MIN, Math.round(bottomH * 0.4)), Math.max(3, bottomH - 3));
|
|
75
105
|
return {
|
|
76
106
|
kind,
|
|
77
107
|
header,
|
|
78
108
|
footer,
|
|
79
109
|
galaxy: { x: 0, y: bodyY, w: galaxyW, h: topH },
|
|
80
110
|
chart: { x: galaxyW, y: bodyY, w: W - galaxyW, h: topH },
|
|
81
|
-
|
|
111
|
+
timing: { x: 0, y: bottomY, w: W, h: timingH },
|
|
112
|
+
log: { x: 0, y: bottomY + timingH, w: W, h: Math.max(0, bottomH - timingH) },
|
|
82
113
|
};
|
|
83
114
|
}
|
|
84
|
-
// Portrait: galaxy (aspect-limited), chart (fixed band), log gets the rest.
|
|
115
|
+
// Portrait: galaxy (aspect-limited), chart (fixed band), timing (compact), log gets the rest.
|
|
85
116
|
const chartH = Math.min(PORTRAIT_CHART_MAX, Math.max(PORTRAIT_CHART_MIN, Math.round(bodyH * 0.3)), Math.max(3, bodyH - 9));
|
|
86
117
|
// A visually round galaxy is ~W/2 rows tall (cells are 2:1); allow it up to ~W*0.6.
|
|
87
118
|
const galaxyCap = Math.max(8, Math.round(W * 0.5));
|
|
88
119
|
const galaxyGrowCap = Math.max(galaxyCap, Math.round(W * 0.65));
|
|
89
120
|
let galaxyH = Math.max(3, Math.min(galaxyCap, Math.round(bodyH * 0.4), bodyH - chartH - 6));
|
|
90
|
-
|
|
91
|
-
|
|
121
|
+
// Reserve a compact timing band between chart and log (#151).
|
|
122
|
+
let timingH = Math.min(
|
|
123
|
+
TIMING_BAND_MAX,
|
|
124
|
+
Math.max(TIMING_BAND_MIN, Math.round(bodyH * 0.18)),
|
|
125
|
+
Math.max(3, bodyH - galaxyH - chartH - 4),
|
|
126
|
+
);
|
|
127
|
+
let logH = bodyH - galaxyH - chartH - timingH;
|
|
128
|
+
// Few events: give some of the empty log area back to the galaxy (keep timing).
|
|
92
129
|
if (hints.logEvents !== undefined) {
|
|
93
130
|
const logNeeded = Math.max(8, hints.logEvents + 4);
|
|
94
131
|
const spare = logH - logNeeded;
|
|
@@ -100,12 +137,19 @@ export function computeLayout(cols: number, rows: number, hints: LayoutHints = {
|
|
|
100
137
|
}
|
|
101
138
|
}
|
|
102
139
|
}
|
|
140
|
+
// If body is too short, shrink timing before wiping log.
|
|
141
|
+
if (logH < 3 && timingH > 3) {
|
|
142
|
+
const give = Math.min(timingH - 3, 3 - logH);
|
|
143
|
+
timingH -= give;
|
|
144
|
+
logH += give;
|
|
145
|
+
}
|
|
103
146
|
return {
|
|
104
147
|
kind,
|
|
105
148
|
header,
|
|
106
149
|
footer,
|
|
107
150
|
galaxy: { x: 0, y: bodyY, w: W, h: galaxyH },
|
|
108
151
|
chart: { x: 0, y: bodyY + galaxyH, w: W, h: chartH },
|
|
109
|
-
|
|
152
|
+
timing: { x: 0, y: bodyY + galaxyH + chartH, w: W, h: Math.max(0, timingH) },
|
|
153
|
+
log: { x: 0, y: bodyY + galaxyH + chartH + Math.max(0, timingH), w: W, h: Math.max(0, logH) },
|
|
110
154
|
};
|
|
111
155
|
}
|
package/src/tui/viz/model.ts
CHANGED
|
@@ -33,6 +33,10 @@ export interface VizNode {
|
|
|
33
33
|
currentTurnStartedAt?: number;
|
|
34
34
|
/** Unix ms the current turn settled. Absent while it is still in flight. */
|
|
35
35
|
currentTurnEndedAt?: number;
|
|
36
|
+
/** Completed turn count (1-based latest); used to gate round clocks on the strip. */
|
|
37
|
+
turns?: number;
|
|
38
|
+
/** Unix ms of last visible activity for this seat (not run wall-clock start). */
|
|
39
|
+
lastActivityAt?: number;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
/** Same set the footer/busy label treat as active, so live counts agree everywhere. */
|
|
@@ -71,6 +75,8 @@ export function nodesFromEntries(entries: readonly StatusEntry[]): VizNode[] {
|
|
|
71
75
|
generationMs: Math.max(0, entry.generationMs ?? 0),
|
|
72
76
|
...(entry.currentTurnStartedAt !== undefined ? { currentTurnStartedAt: entry.currentTurnStartedAt } : {}),
|
|
73
77
|
...(entry.currentTurnEndedAt !== undefined ? { currentTurnEndedAt: entry.currentTurnEndedAt } : {}),
|
|
78
|
+
...(entry.turns !== undefined && entry.turns > 0 ? { turns: entry.turns } : {}),
|
|
79
|
+
...(entry.lastActivityAt !== undefined ? { lastActivityAt: entry.lastActivityAt } : {}),
|
|
74
80
|
});
|
|
75
81
|
}
|
|
76
82
|
// Parents that are missing from the snapshot re-attach to the nearest known ancestor.
|
package/src/tui/viz/panels.ts
CHANGED
|
@@ -5,14 +5,15 @@ import type { Rect } from "./layout.ts";
|
|
|
5
5
|
import { formatCount, formatDuration, isAgent, type VizNode } from "./model.ts";
|
|
6
6
|
import { nodeToken, roleColor } from "./palette.ts";
|
|
7
7
|
import type { RateSampler } from "./rate.ts";
|
|
8
|
+
import { breakdown } from "../../shared/timing-breakdown.ts";
|
|
8
9
|
|
|
9
|
-
export type Pane = "galaxy" | "chart" | "log";
|
|
10
|
+
export type Pane = "galaxy" | "chart" | "timing" | "log";
|
|
10
11
|
|
|
11
12
|
export interface ViewData {
|
|
12
13
|
nodes: readonly VizNode[];
|
|
13
14
|
sampler: RateSampler;
|
|
14
15
|
now: number;
|
|
15
|
-
/** Header
|
|
16
|
+
/** Header tok/s (live agents only; 0 when none running). Chart avg/buckets keep full-series windows. */
|
|
16
17
|
rate: number;
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -279,10 +280,16 @@ export interface ChartStats {
|
|
|
279
280
|
byRole: Array<{ role: string; tokens: number }>;
|
|
280
281
|
}
|
|
281
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Chart x-domain. `to` advances only on substantive RateSampler changes
|
|
285
|
+
* (tokensOut / generationMs); wall-clock `now` alone does not scroll the axis.
|
|
286
|
+
* `from` stays relative to that frozen `to` (earliest start, or to−60s).
|
|
287
|
+
*/
|
|
282
288
|
export function chartWindow(data: ViewData): { from: number; to: number } {
|
|
283
289
|
const starts = data.nodes.filter(isAgent).map((node) => node.startedAt);
|
|
284
|
-
const
|
|
285
|
-
|
|
290
|
+
const earliest = Math.min(data.sampler.startedAt ?? data.now, ...(starts.length ? starts : [data.now]));
|
|
291
|
+
const to = data.sampler.lastChangeAt ?? data.sampler.startedAt ?? data.now;
|
|
292
|
+
return { from: Math.min(earliest, to - 60_000), to };
|
|
286
293
|
}
|
|
287
294
|
|
|
288
295
|
export function computeStats(data: ViewData, buckets: number): ChartStats {
|
|
@@ -296,7 +303,7 @@ export function computeStats(data: ViewData, buckets: number): ChartStats {
|
|
|
296
303
|
peakIndex = i;
|
|
297
304
|
}
|
|
298
305
|
});
|
|
299
|
-
//
|
|
306
|
+
// The header tok/s is the shared definition, never the last bucket.
|
|
300
307
|
peak = Math.max(peak, data.rate);
|
|
301
308
|
const peakAt = peakIndex >= 0 && peak > data.rate ? series.times[peakIndex]! : to;
|
|
302
309
|
const roleTotals = new Map<string, number>();
|
|
@@ -308,7 +315,7 @@ export function computeStats(data: ViewData, buckets: number): ChartStats {
|
|
|
308
315
|
}
|
|
309
316
|
return {
|
|
310
317
|
now: data.rate,
|
|
311
|
-
// Full-series window average (includes settled); differs from header
|
|
318
|
+
// Full-series window average (includes settled); differs from the header tok/s on purpose.
|
|
312
319
|
avg: data.sampler.totalRate(to, Math.max(1, to - from)),
|
|
313
320
|
peak,
|
|
314
321
|
peakAt,
|
|
@@ -326,7 +333,7 @@ export function drawChart(cv: Canvas, r: Rect, data: ViewData, selected: VizNode
|
|
|
326
333
|
const statsBelow = statsW === 0 ? Math.min(selected ? 4 : 3, Math.max(0, r.h - 6)) : 0;
|
|
327
334
|
const axisW = 6;
|
|
328
335
|
const plotX = r.x + axisW;
|
|
329
|
-
// With side stats, keep room for the "◂
|
|
336
|
+
// With side stats, keep room for the "◂ N" label so it never runs into the stats column.
|
|
330
337
|
const plotW = Math.max(4, r.w - axisW - statsW - (statsW ? 9 : 0) - 1);
|
|
331
338
|
const plotTop = r.y + 1;
|
|
332
339
|
const plotH = Math.max(1, r.h - 2 - statsBelow);
|
|
@@ -387,14 +394,13 @@ export function drawChart(cv: Canvas, r: Rect, data: ViewData, selected: VizNode
|
|
|
387
394
|
if (stats.peak > 0) yLabel(max, plotTop);
|
|
388
395
|
if (plotH >= 5 && max >= 20) yLabel(max / 2, axisY - 1 - Math.floor(plotH / 2));
|
|
389
396
|
yLabel(0, axisY - 1);
|
|
390
|
-
// X axis
|
|
397
|
+
// X axis: elapsed time since the window start; the right edge is the present.
|
|
391
398
|
cv.text(plotX - 1, axisY, "└" + "─".repeat(plotW), "borderMuted", { maxX: plotX + plotW });
|
|
392
399
|
cv.text(plotX, axisY, "0m", "dim");
|
|
393
400
|
const mid = formatDuration((to - from) / 2);
|
|
394
401
|
if (plotW > 24) cv.text(plotX + Math.floor(plotW / 2) - Math.floor(mid.length / 2), axisY, mid, "dim");
|
|
395
|
-
cv.text(plotX + plotW - 3, axisY, "now", "text", { bold: true });
|
|
396
402
|
const nowLabel = `◂ ${formatCount(stats.now)}`;
|
|
397
|
-
if (statsW === 0) cv.text(r.x, r.y,
|
|
403
|
+
if (statsW === 0) cv.text(r.x, r.y, `${formatCount(stats.now)} tok/s`, "accent", { bold: true, maxX: Math.max(r.x, plotX + plotW - 22) });
|
|
398
404
|
else cv.text(plotX + plotW + 1, axisY - 1 - Math.min(plotH - 1, Math.floor(rowsFor(stats.now))), nowLabel, "text", { maxX: r.x + r.w - statsW - 1 });
|
|
399
405
|
|
|
400
406
|
const selLines = (): Array<[string, string]> => {
|
|
@@ -403,14 +409,14 @@ export function drawChart(cv: Canvas, r: Rect, data: ViewData, selected: VizNode
|
|
|
403
409
|
const share = stats.total > 0 ? (selected.tokens / stats.total) * 100 : 0;
|
|
404
410
|
return [
|
|
405
411
|
[`▸ ${selected.name}`, roleColor(selected.role)],
|
|
406
|
-
[`${formatCount(rate)} tok/s
|
|
412
|
+
[`${formatCount(rate)} tok/s · ${formatCount(selected.tokens)} tok`, "text"],
|
|
407
413
|
[`${share.toFixed(1)}% of run · ━ bright line`, "dim"],
|
|
408
414
|
];
|
|
409
415
|
};
|
|
410
416
|
if (statsW) {
|
|
411
417
|
const sx = r.x + r.w - statsW;
|
|
412
418
|
const rows: Array<[string, string, string]> = [
|
|
413
|
-
["
|
|
419
|
+
["rate", `${formatCount(stats.now)} tok/s`, "text"],
|
|
414
420
|
["avg", `${formatCount(stats.avg)} tok/s`, "text"],
|
|
415
421
|
["peak", stats.peak > 0 ? `${formatCount(stats.peak)} @ ${formatDuration(stats.peakAt - from)}` : "—", "text"],
|
|
416
422
|
["total", `${formatCount(stats.total)} tok`, "text"],
|
|
@@ -536,3 +542,149 @@ export function drawLog(
|
|
|
536
542
|
for (let i = 0; i < rows; i++) cv.set(r.x + r.w - 1, r.y + 1 + i, i >= pos && i < pos + thumb ? "┃" : "│", i >= pos && i < pos + thumb ? "scrollbarThumb" : "scrollbarTrack");
|
|
537
543
|
}
|
|
538
544
|
}
|
|
545
|
+
|
|
546
|
+
// ---------------------------------------------------------------- timing (#151)
|
|
547
|
+
// TUI scope = current view VizNodes; CLI `/legion timing` = cwd RunStore children.
|
|
548
|
+
// Formula matches #150 (wall / gen / other); the node set can differ.
|
|
549
|
+
|
|
550
|
+
export interface TimingRow {
|
|
551
|
+
node: VizNode;
|
|
552
|
+
wallMs: number;
|
|
553
|
+
generationMs: number | null;
|
|
554
|
+
otherMs: number | null;
|
|
555
|
+
liveWall: boolean;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Build timing rows from viz nodes (agents only).
|
|
560
|
+
* VizNode has no turn count: generationMs === 0 → gen/other shown as — (no turn data).
|
|
561
|
+
*/
|
|
562
|
+
export function timingRowsFromNodes(nodes: readonly VizNode[], now: number): TimingRow[] {
|
|
563
|
+
const rows: TimingRow[] = [];
|
|
564
|
+
for (const node of nodes) {
|
|
565
|
+
if (!isAgent(node)) continue;
|
|
566
|
+
const end = node.endedAt ?? now;
|
|
567
|
+
const wallMs = Math.max(0, end - node.startedAt);
|
|
568
|
+
const liveWall = node.status === "running" && node.endedAt === undefined;
|
|
569
|
+
// No turn data on VizNode → treat 0 generationMs as absent (align "无 turn → —").
|
|
570
|
+
const generationMs = node.generationMs > 0 ? node.generationMs : null;
|
|
571
|
+
const parts = breakdown(wallMs, generationMs);
|
|
572
|
+
rows.push({
|
|
573
|
+
node,
|
|
574
|
+
wallMs: parts.wall_ms,
|
|
575
|
+
generationMs: parts.generation_ms,
|
|
576
|
+
otherMs: parts.other_ms,
|
|
577
|
+
liveWall,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
rows.sort((a, b) => a.node.startedAt - b.node.startedAt || a.node.name.localeCompare(b.node.name));
|
|
581
|
+
return rows;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function formatTimingCell(ms: number | null, opts: { live?: boolean } = {}): string {
|
|
585
|
+
if (ms === null) return "—";
|
|
586
|
+
const rendered = formatDuration(ms);
|
|
587
|
+
return opts.live ? `${rendered}+` : rendered;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function roleAbbrev(role: string): string {
|
|
591
|
+
const t = role.trim();
|
|
592
|
+
if (!t) return "";
|
|
593
|
+
if (t.length <= 4) return t;
|
|
594
|
+
return t.slice(0, 3);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export interface TimingDrawState {
|
|
598
|
+
offset: number;
|
|
599
|
+
cursor: number;
|
|
600
|
+
focused: boolean;
|
|
601
|
+
selectedId?: string;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export function drawTiming(
|
|
605
|
+
cv: Canvas,
|
|
606
|
+
r: Rect,
|
|
607
|
+
rows: readonly TimingRow[],
|
|
608
|
+
state: TimingDrawState,
|
|
609
|
+
): void {
|
|
610
|
+
if (r.w < 8 || r.h < 2) return;
|
|
611
|
+
const showExtra = r.w >= 36;
|
|
612
|
+
const showRole = r.w >= 28;
|
|
613
|
+
const header = showExtra
|
|
614
|
+
? "name" + (showRole ? " · role" : "") + " wall gen other"
|
|
615
|
+
: "name" + (showRole ? " · role" : "") + " wall";
|
|
616
|
+
cv.text(r.x, r.y, ellipsize(header, r.w), "dim");
|
|
617
|
+
|
|
618
|
+
const footerH = rows.length > 0 && r.h >= 4 ? 1 : 0;
|
|
619
|
+
const rowsAvail = Math.max(0, r.h - 1 - footerH);
|
|
620
|
+
for (let i = 0; i < rowsAvail; i++) {
|
|
621
|
+
const index = state.offset + i;
|
|
622
|
+
const row = rows[index];
|
|
623
|
+
if (!row) break;
|
|
624
|
+
const y = r.y + 1 + i;
|
|
625
|
+
const isCursor = state.focused && index === state.cursor;
|
|
626
|
+
const highlight = state.selectedId === row.node.id;
|
|
627
|
+
const dim = state.selectedId !== undefined && !highlight;
|
|
628
|
+
|
|
629
|
+
const wall = formatTimingCell(row.wallMs, { live: row.liveWall });
|
|
630
|
+
const gen = formatTimingCell(row.generationMs);
|
|
631
|
+
const other = formatTimingCell(row.otherMs);
|
|
632
|
+
|
|
633
|
+
const wallW = showExtra ? 8 : Math.max(6, Math.min(10, r.w - 12));
|
|
634
|
+
const genW = 7;
|
|
635
|
+
const otherW = 7;
|
|
636
|
+
const metaW = showExtra ? wallW + 1 + genW + 1 + otherW : wallW;
|
|
637
|
+
const nameBudget = Math.max(4, r.w - metaW - (showRole ? 5 : 1));
|
|
638
|
+
|
|
639
|
+
let name = ellipsize(row.node.name, nameBudget);
|
|
640
|
+
if (showRole) {
|
|
641
|
+
const abbr = roleAbbrev(row.node.role);
|
|
642
|
+
const room = nameBudget - cellWidth(name) - 1;
|
|
643
|
+
if (abbr && room >= cellWidth(abbr)) {
|
|
644
|
+
name = `${name} ${abbr}`;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
cv.text(r.x, y, ellipsize(name, nameBudget), dim ? "dim" : roleColor(row.node.role), {
|
|
649
|
+
bold: !dim,
|
|
650
|
+
inverse: isCursor,
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
const metaX = r.x + r.w - metaW;
|
|
654
|
+
if (showExtra) {
|
|
655
|
+
cv.text(metaX, y, wall.padStart(wallW), highlight ? "text" : "dim", { maxX: r.x + r.w });
|
|
656
|
+
cv.text(metaX + wallW + 1, y, gen.padStart(genW), "dim", { maxX: r.x + r.w });
|
|
657
|
+
cv.text(metaX + wallW + 1 + genW + 1, y, other.padStart(otherW), "dim", { maxX: r.x + r.w });
|
|
658
|
+
} else {
|
|
659
|
+
cv.text(metaX, y, wall.padStart(wallW), highlight ? "text" : "dim", { maxX: r.x + r.w });
|
|
660
|
+
}
|
|
661
|
+
if (isCursor && highlight) cv.set(r.x + r.w - 1, y, "◂", "accent", { bold: true });
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (footerH && rows.length > 0) {
|
|
665
|
+
const started = rows.map((row) => row.node.startedAt);
|
|
666
|
+
const ended = rows.map((row) => (row.liveWall ? null : row.node.endedAt ?? row.node.startedAt + row.wallMs));
|
|
667
|
+
const ends = ended.filter((v): v is number => v !== null);
|
|
668
|
+
const span =
|
|
669
|
+
started.length && ends.length
|
|
670
|
+
? Math.max(0, Math.max(...ends) - Math.min(...started))
|
|
671
|
+
: null;
|
|
672
|
+
const sumWall = rows.reduce((sum, row) => sum + row.wallMs, 0);
|
|
673
|
+
const fy = r.y + r.h - 1;
|
|
674
|
+
const spanText = span === null ? "—" : formatDuration(span);
|
|
675
|
+
cv.text(r.x, fy, ellipsize(`span ${spanText} · sum ${formatDuration(sumWall)}`, r.w), "dim");
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (rows.length > rowsAvail && rowsAvail > 0) {
|
|
679
|
+
const thumb = Math.max(1, Math.round((rowsAvail / rows.length) * rowsAvail));
|
|
680
|
+
const pos = Math.round((state.offset / Math.max(1, rows.length - rowsAvail)) * (rowsAvail - thumb));
|
|
681
|
+
for (let i = 0; i < rowsAvail; i++) {
|
|
682
|
+
cv.set(
|
|
683
|
+
r.x + r.w - 1,
|
|
684
|
+
r.y + 1 + i,
|
|
685
|
+
i >= pos && i < pos + thumb ? "┃" : "│",
|
|
686
|
+
i >= pos && i < pos + thumb ? "scrollbarThumb" : "scrollbarTrack",
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
package/src/tui/viz/railway.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* vertical remains traceable.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { formatAgentElapsed } from "../status.ts";
|
|
12
|
+
import { formatAgentElapsed, formatActivityAgo } from "../status.ts";
|
|
13
13
|
import { Canvas, ellipsize } from "./canvas.ts";
|
|
14
14
|
import { formatCount, isAgent, type VizNode } from "./model.ts";
|
|
15
15
|
import { roleColor, type VizTheme } from "./palette.ts";
|
|
@@ -18,6 +18,19 @@ export const RAIL_MAX_LANES = 4;
|
|
|
18
18
|
export const RAIL_MAX_LANES_NARROW = 3;
|
|
19
19
|
export const RAIL_NARROW_COLS = 80;
|
|
20
20
|
export const RAIL_WINDOW_MS = 6 * 60 * 1000;
|
|
21
|
+
/**
|
|
22
|
+
* Floor for the time axis. The right-hand column is measured from what the
|
|
23
|
+
* lanes actually need and the axis takes whatever is left, but never less
|
|
24
|
+
* than this — otherwise a long detail would push the graph off the line.
|
|
25
|
+
*/
|
|
26
|
+
export const RAIL_AXIS_MIN_COLS = 10;
|
|
27
|
+
/** Widest a lane name column may grow (it is content-driven, not reserved). */
|
|
28
|
+
export const RAIL_NAME_MAX_COLS = 16;
|
|
29
|
+
/**
|
|
30
|
+
* Narrowest the lane-name column may shrink. Short names would otherwise
|
|
31
|
+
* collapse the column to 1 and leave the label cramped against the ▶ marker.
|
|
32
|
+
*/
|
|
33
|
+
export const RAIL_NAME_MIN_COLS = 6;
|
|
21
34
|
|
|
22
35
|
/**
|
|
23
36
|
* Map a timestamp onto the shared railway axis.
|
|
@@ -39,7 +52,7 @@ export interface RailInput {
|
|
|
39
52
|
nodes: readonly VizNode[];
|
|
40
53
|
now: number;
|
|
41
54
|
width: number;
|
|
42
|
-
/** Header
|
|
55
|
+
/** Header tok/s (live agents only; 0 when none running). */
|
|
43
56
|
rate: number;
|
|
44
57
|
windowMs?: number;
|
|
45
58
|
/** Lane scroll position when more agents are live than lanes fit. */
|
|
@@ -141,6 +154,128 @@ export function formatRate(rate: number): string {
|
|
|
141
154
|
return `${formatCount(rate)} tok/s`;
|
|
142
155
|
}
|
|
143
156
|
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Wide-lane detail after the name: role, turn/lifetime clocks, and
|
|
160
|
+
* last-activity age.
|
|
161
|
+
*
|
|
162
|
+
* The leading clock is "time since the last task started": it keeps ticking
|
|
163
|
+
* through the quiet gap after a turn settles (the old behavior froze at that
|
|
164
|
+
* turn's end) and stops when the agent itself ends.
|
|
165
|
+
*
|
|
166
|
+
* Clocks show from round 1. #159 gated them behind `turns >= 2`, but a child
|
|
167
|
+
* is usually dispatched with a single user message, so in practice that gate
|
|
168
|
+
* hid the clocks almost always and left the lane with a bare `role · 1s ago`
|
|
169
|
+
* (~13 cols) — the axis then absorbed the rest of the line. A round-1 agent
|
|
170
|
+
* has a real start time and a real lifetime; show both.
|
|
171
|
+
*/
|
|
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(" · ");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Lane detail candidates, richest first (role → clocks → ago). */
|
|
185
|
+
export function laneDetailCandidates(node: VizNode, now: number): string[] {
|
|
186
|
+
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
|
+
return [
|
|
194
|
+
`${node.role} · ${clocks} · ${ago}`,
|
|
195
|
+
`${clocks} · ${ago}`,
|
|
196
|
+
clocks,
|
|
197
|
+
ago,
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Width of the lane-name column: as wide as the longest name, clamped to
|
|
203
|
+
* `RAIL_NAME_MAX_COLS` and never below `RAIL_NAME_MIN_COLS`.
|
|
204
|
+
*
|
|
205
|
+
* Planning and painting must agree on this, otherwise the axis is sized for
|
|
206
|
+
* one width while the row is drawn at another and the tail gets clipped.
|
|
207
|
+
*/
|
|
208
|
+
export function railNameWidth(shown: readonly VizNode[]): number {
|
|
209
|
+
const longest = shown.length ? Math.max(...shown.map((n) => Array.from(n.name).length)) : 0;
|
|
210
|
+
return Math.max(RAIL_NAME_MIN_COLS, Math.min(RAIL_NAME_MAX_COLS, longest));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Pick the richest lane detail that fits `room` (role → clocks → ago).
|
|
215
|
+
*
|
|
216
|
+
* Callers pass the room shared by every lane, so a field that does not fit
|
|
217
|
+
* one lane is dropped from all of them instead of stripping the role from a
|
|
218
|
+
* single long-named row.
|
|
219
|
+
*/
|
|
220
|
+
export function fitRailLaneDetail(node: VizNode, now: number, room: number): string {
|
|
221
|
+
if (room <= 0) return "";
|
|
222
|
+
const candidates = laneDetailCandidates(node, now);
|
|
223
|
+
for (const candidate of candidates) {
|
|
224
|
+
if (Array.from(candidate).length <= room) return candidate;
|
|
225
|
+
}
|
|
226
|
+
return ellipsize(candidates[candidates.length - 1]!, room);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface RailColumnPlan {
|
|
230
|
+
/** Width reserved for the lane name. */
|
|
231
|
+
nameW: number;
|
|
232
|
+
/** Index into each lane's candidate list (0 = richest). */
|
|
233
|
+
grade: number;
|
|
234
|
+
/** Columns the right-hand block needs, including the ▶ marker. */
|
|
235
|
+
needed: number;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Measure the right-hand column from the lanes themselves.
|
|
240
|
+
*
|
|
241
|
+
* The name/detail block is content-driven: the axis takes whatever the
|
|
242
|
+
* terminal has left, down to `RAIL_AXIS_MIN_COLS`. Short labels hand the slack
|
|
243
|
+
* back to the graph; long ones shrink it — so a row always fills the line
|
|
244
|
+
* instead of trailing blank space after the detail text.
|
|
245
|
+
*/
|
|
246
|
+
export function railColumnPlan(
|
|
247
|
+
shown: readonly VizNode[],
|
|
248
|
+
now: number,
|
|
249
|
+
portrait: boolean,
|
|
250
|
+
width: number,
|
|
251
|
+
reserve = 0,
|
|
252
|
+
): RailColumnPlan {
|
|
253
|
+
if (shown.length === 0) return { nameW: 0, grade: 0, needed: 0 };
|
|
254
|
+
const nameW = railNameWidth(shown);
|
|
255
|
+
if (portrait) {
|
|
256
|
+
const label = Math.max(...shown.map((n) => {
|
|
257
|
+
const ago = formatActivityAgo(n.lastActivityAt, now);
|
|
258
|
+
return Array.from(ago === "—" ? n.name : `${n.name} · ${ago}`).length;
|
|
259
|
+
}));
|
|
260
|
+
return { nameW, grade: 0, needed: label + 2 + reserve };
|
|
261
|
+
}
|
|
262
|
+
const candidates = shown.map((n) => laneDetailCandidates(n, now));
|
|
263
|
+
const grades = Array.from(
|
|
264
|
+
{ length: Math.max(...candidates.map((c) => c.length)) },
|
|
265
|
+
(_, g) => Math.max(...candidates.map((c) => Array.from(c[Math.min(g, c.length - 1)]!).length)),
|
|
266
|
+
);
|
|
267
|
+
// Richest grade that still leaves the axis its floor.
|
|
268
|
+
const budget = Math.max(0, width - 1 - reserve - RAIL_AXIS_MIN_COLS - nameW - 3);
|
|
269
|
+
let grade = grades.length - 1;
|
|
270
|
+
for (let g = 0; g < grades.length; g++) {
|
|
271
|
+
if (grades[g]! <= budget) {
|
|
272
|
+
grade = g;
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return { nameW, grade, needed: nameW + grades[grade]! + 3 + reserve };
|
|
277
|
+
}
|
|
278
|
+
|
|
144
279
|
export function renderRailway(input: RailInput): RailRender | undefined {
|
|
145
280
|
const width = Math.max(0, Math.floor(input.width));
|
|
146
281
|
if (width < 20) return undefined;
|
|
@@ -151,9 +286,14 @@ export function renderRailway(input: RailInput): RailRender | undefined {
|
|
|
151
286
|
if (rows === 0) return undefined;
|
|
152
287
|
const portrait = width < RAIL_NARROW_COLS;
|
|
153
288
|
const windowMs = input.windowMs ?? RAIL_WINDOW_MS;
|
|
154
|
-
const
|
|
289
|
+
const live = current.total;
|
|
290
|
+
const above = current.offset > 0;
|
|
291
|
+
const below = current.offset + rows < live;
|
|
292
|
+
// Column widths are measured before the canvas exists: the axis gets the
|
|
293
|
+
// slack, the lanes get what they need.
|
|
294
|
+
const plan = railColumnPlan(current.shown, now, portrait, width, above || below ? 2 : 0);
|
|
155
295
|
const LX = 1;
|
|
156
|
-
const AW = Math.max(
|
|
296
|
+
const AW = Math.max(RAIL_AXIS_MIN_COLS, Math.min(width - LX - 1, width - LX - plan.needed));
|
|
157
297
|
const canvas = new Canvas(width, rows + 1);
|
|
158
298
|
const hit: Array<Array<string | undefined>> = Array.from({ length: rows + 1 }, () => new Array<string | undefined>(width).fill(undefined));
|
|
159
299
|
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
@@ -227,9 +367,6 @@ export function renderRailway(input: RailInput): RailRender | undefined {
|
|
|
227
367
|
hit[y0 + r]![LX + c] = cell.owner;
|
|
228
368
|
}
|
|
229
369
|
|
|
230
|
-
const live = current.total;
|
|
231
|
-
const above = current.offset > 0;
|
|
232
|
-
const below = current.offset + rows < live;
|
|
233
370
|
const rateText = formatRate(input.rate);
|
|
234
371
|
const winLabel = ` −${Math.max(1, Math.round(windowMs / 60000))}m `;
|
|
235
372
|
canvas.text(LX + 1, 0, winLabel, "dim");
|
|
@@ -285,22 +422,18 @@ export function renderRailway(input: RailInput): RailRender | undefined {
|
|
|
285
422
|
const overflow = i === 0 && above ? " ▲" : i === rows - 1 && below ? " ▼" : "";
|
|
286
423
|
const avail = width - (nameX + 2) - overflow.length;
|
|
287
424
|
if (portrait) {
|
|
288
|
-
|
|
425
|
+
const ago = formatActivityAgo(node.lastActivityAt, now);
|
|
426
|
+
const label = ago === "—" ? node.name : `${node.name} · ${ago}`;
|
|
427
|
+
canvas.text(nameX + 2, y, ellipsize(label, avail), col, { bold: true });
|
|
289
428
|
} else {
|
|
290
|
-
|
|
291
|
-
|
|
429
|
+
// Paint with the same width the plan measured (clamped to what is
|
|
430
|
+
// actually available): mismatched widths sized the axis for one layout
|
|
431
|
+
// and drew another, clipping the tail of the detail text.
|
|
432
|
+
const nameW = Math.min(plan.nameW, Math.max(1, avail));
|
|
292
433
|
canvas.text(nameX + 2, y, ellipsize(node.name, nameW), col, { bold: true });
|
|
293
|
-
const
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
startedAt: node.startedAt,
|
|
297
|
-
...(node.endedAt !== undefined ? { finishedAt: node.endedAt } : {}),
|
|
298
|
-
...(node.currentTurnStartedAt !== undefined ? { currentTurnStartedAt: node.currentTurnStartedAt } : {}),
|
|
299
|
-
...(node.currentTurnEndedAt !== undefined ? { currentTurnEndedAt: node.currentTurnEndedAt } : {}),
|
|
300
|
-
}, now);
|
|
301
|
-
const full = `${node.role} · ${elapsed}`;
|
|
302
|
-
const detail = ellipsize(Array.from(full).length <= room ? full : elapsed, room);
|
|
303
|
-
canvas.text(nameX + 3 + nameW, y, detail, "dim");
|
|
434
|
+
const candidates = laneDetailCandidates(node, now);
|
|
435
|
+
const detail = candidates[Math.min(plan.grade, candidates.length - 1)]!;
|
|
436
|
+
canvas.text(nameX + 3 + nameW, y, detail, "dim", { maxX: width - overflow.length });
|
|
304
437
|
}
|
|
305
438
|
if (overflow) canvas.text(width - overflow.length, y, overflow, "text", { bold: true });
|
|
306
439
|
});
|