pi-legion 2.0.2 → 2.0.4
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 +38 -1
- package/docs/legion-v2-contract.md +1 -1
- package/index.ts +83 -15
- package/package.json +1 -1
- package/src/extension/legion-commands.ts +8 -3
- package/src/extension/legion-doctor.ts +1 -0
- package/src/extension/playbook.ts +2 -2
- package/src/extension/timing.ts +391 -0
- package/src/legion/budget.ts +5 -3
- package/src/legion/nodes.ts +7 -0
- package/src/runs/orchestrator.ts +3 -2
- package/src/shared/timing-breakdown.ts +23 -0
- package/src/tui/live-overlay.ts +202 -21
- package/src/tui/viz/layout.ts +50 -6
- package/src/tui/viz/panels.ts +156 -3
- package/src/tui/viz/rate.ts +21 -0
- package/src/tui/viz/view.ts +69 -10
package/src/tui/viz/rate.ts
CHANGED
|
@@ -53,6 +53,8 @@ export class RateSampler {
|
|
|
53
53
|
private readonly series = new Map<string, Series>();
|
|
54
54
|
private readonly retentionMs: number;
|
|
55
55
|
private first: number | undefined;
|
|
56
|
+
/** Wall time of the latest tokensOut/gen change across all series (not flat-collapse moves). */
|
|
57
|
+
private lastChange: number | undefined;
|
|
56
58
|
|
|
57
59
|
constructor(options: { retentionMs?: number } = {}) {
|
|
58
60
|
this.retentionMs = options.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
@@ -62,6 +64,15 @@ export class RateSampler {
|
|
|
62
64
|
return this.first;
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Latest timestamp where any series recorded a substantive tokensOut or
|
|
69
|
+
* generationMs change. Flat-collapse (advancing a flat trailing sample to
|
|
70
|
+
* `now`) does not move this — chart x-axis freezes until real data arrives.
|
|
71
|
+
*/
|
|
72
|
+
get lastChangeAt(): number | undefined {
|
|
73
|
+
return this.lastChange;
|
|
74
|
+
}
|
|
75
|
+
|
|
65
76
|
record(now: number, nodes: readonly VizNode[]): void {
|
|
66
77
|
if (!Number.isFinite(now)) return;
|
|
67
78
|
if (this.first === undefined) this.first = now;
|
|
@@ -85,9 +96,11 @@ export class RateSampler {
|
|
|
85
96
|
// Cumulative counters never decrease; ignore regressions from stale sources.
|
|
86
97
|
const value = last === undefined ? node.tokensOut : Math.max(last, node.tokensOut);
|
|
87
98
|
const gen = lastGen === undefined ? node.generationMs : Math.max(lastGen, node.generationMs);
|
|
99
|
+
const changed = last === undefined || value !== last || gen !== lastGen;
|
|
88
100
|
if (lastTs !== undefined && now <= lastTs) {
|
|
89
101
|
s.value[s.value.length - 1] = value;
|
|
90
102
|
s.gen[s.gen.length - 1] = gen;
|
|
103
|
+
if (changed) this.noteChange(lastTs);
|
|
91
104
|
continue;
|
|
92
105
|
}
|
|
93
106
|
if (
|
|
@@ -99,16 +112,23 @@ export class RateSampler {
|
|
|
99
112
|
s.gen[s.gen.length - 2] === lastGen
|
|
100
113
|
) {
|
|
101
114
|
// Collapse flat runs: move the trailing sample forward.
|
|
115
|
+
// Does NOT advance lastChangeAt — chart x-axis stays frozen.
|
|
102
116
|
s.ts[s.ts.length - 1] = now;
|
|
103
117
|
continue;
|
|
104
118
|
}
|
|
105
119
|
s.ts.push(now);
|
|
106
120
|
s.value.push(value);
|
|
107
121
|
s.gen.push(gen);
|
|
122
|
+
if (changed) this.noteChange(now);
|
|
108
123
|
this.prune(s, now);
|
|
109
124
|
}
|
|
110
125
|
}
|
|
111
126
|
|
|
127
|
+
private noteChange(at: number): void {
|
|
128
|
+
if (!Number.isFinite(at)) return;
|
|
129
|
+
this.lastChange = this.lastChange === undefined ? at : Math.max(this.lastChange, at);
|
|
130
|
+
}
|
|
131
|
+
|
|
112
132
|
private prune(s: Series, now: number): void {
|
|
113
133
|
const cutoff = now - this.retentionMs;
|
|
114
134
|
let drop = 0;
|
|
@@ -215,6 +235,7 @@ export class RateSampler {
|
|
|
215
235
|
clear(): void {
|
|
216
236
|
this.series.clear();
|
|
217
237
|
this.first = undefined;
|
|
238
|
+
this.lastChange = undefined;
|
|
218
239
|
}
|
|
219
240
|
}
|
|
220
241
|
|
package/src/tui/viz/view.ts
CHANGED
|
@@ -1,26 +1,39 @@
|
|
|
1
|
-
/** /legion full view: galaxy + tok/s chart + git-log history with linked selection. */
|
|
1
|
+
/** /legion full view: galaxy + tok/s chart + timing + git-log history with linked selection. */
|
|
2
2
|
|
|
3
3
|
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
4
4
|
import { Canvas, cellWidth, ellipsize } from "./canvas.ts";
|
|
5
5
|
import { computeLayout, type LayoutKind } from "./layout.ts";
|
|
6
6
|
import { formatCount, formatDuration, isAgent, type VizNode } from "./model.ts";
|
|
7
7
|
import { roleColor, type VizTheme } from "./palette.ts";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
buildLog,
|
|
10
|
+
dfsOrder,
|
|
11
|
+
drawChart,
|
|
12
|
+
drawGalaxy,
|
|
13
|
+
drawLog,
|
|
14
|
+
drawTiming,
|
|
15
|
+
frame,
|
|
16
|
+
timingRowsFromNodes,
|
|
17
|
+
type Pane,
|
|
18
|
+
type ViewData,
|
|
19
|
+
} from "./panels.ts";
|
|
9
20
|
|
|
10
21
|
export interface LegionViewState {
|
|
11
22
|
focus: Pane;
|
|
12
23
|
selectedId?: string;
|
|
13
24
|
logOffset: number;
|
|
14
25
|
logCursor: number;
|
|
26
|
+
timingOffset: number;
|
|
27
|
+
timingCursor: number;
|
|
15
28
|
/** `x` pressed: an in-view y/N prompt for stopping this agent (dialogs would be hidden behind the full-screen view). */
|
|
16
29
|
pendingStopId?: string;
|
|
17
30
|
}
|
|
18
31
|
|
|
19
32
|
export function initialViewState(): LegionViewState {
|
|
20
|
-
return { focus: "galaxy", logOffset: 0, logCursor: 0 };
|
|
33
|
+
return { focus: "galaxy", logOffset: 0, logCursor: 0, timingOffset: 0, timingCursor: 0 };
|
|
21
34
|
}
|
|
22
35
|
|
|
23
|
-
const PANES: Pane[] = ["galaxy", "chart", "log"];
|
|
36
|
+
const PANES: Pane[] = ["galaxy", "chart", "timing", "log"];
|
|
24
37
|
|
|
25
38
|
/** 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`. */
|
|
26
39
|
export const MIN_SELECTION_BADGE_W = 5;
|
|
@@ -72,8 +85,26 @@ export function renderLegionView(data: ViewData, state: LegionViewState, cols: n
|
|
|
72
85
|
drawGalaxy(cv, galaxyInner, data, state.selectedId);
|
|
73
86
|
const chartInner = frame(cv, layout.chart, portrait ? "tok/s · by role" : "tok/s · stacked by role", state.focus === "chart");
|
|
74
87
|
drawChart(cv, chartInner, data, selected, !portrait);
|
|
88
|
+
|
|
89
|
+
const timingRows = timingRowsFromNodes(data.nodes, data.now);
|
|
90
|
+
const timingVisible = Math.max(1, layout.timing.h - 3);
|
|
91
|
+
clampList(state, "timing", timingRows.length, timingVisible);
|
|
92
|
+
const timingInner = frame(
|
|
93
|
+
cv,
|
|
94
|
+
layout.timing,
|
|
95
|
+
portrait ? "timing" : "timing · wall/gen/other",
|
|
96
|
+
state.focus === "timing",
|
|
97
|
+
portrait ? undefined : "↑↓ · ⏎ sel · o open",
|
|
98
|
+
);
|
|
99
|
+
drawTiming(cv, timingInner, timingRows, {
|
|
100
|
+
offset: state.timingOffset,
|
|
101
|
+
cursor: state.timingCursor,
|
|
102
|
+
focused: state.focus === "timing",
|
|
103
|
+
...(state.selectedId ? { selectedId: state.selectedId } : {}),
|
|
104
|
+
});
|
|
105
|
+
|
|
75
106
|
const events = buildLog(data.nodes);
|
|
76
|
-
|
|
107
|
+
clampList(state, "log", events.length, Math.max(1, layout.log.h - 3));
|
|
77
108
|
const logInner = frame(cv, layout.log, "log --graph", state.focus === "log", portrait ? undefined : "↑↓ scroll · ⏎ select");
|
|
78
109
|
drawLog(cv, logInner, events, {
|
|
79
110
|
offset: state.logOffset,
|
|
@@ -112,11 +143,23 @@ function statusLabel(node: VizNode): string {
|
|
|
112
143
|
return node.status === "settled" ? "done" : node.status;
|
|
113
144
|
}
|
|
114
145
|
|
|
115
|
-
function
|
|
116
|
-
state
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
146
|
+
function clampList(
|
|
147
|
+
state: LegionViewState,
|
|
148
|
+
which: "log" | "timing",
|
|
149
|
+
count: number,
|
|
150
|
+
visible: number,
|
|
151
|
+
): 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)));
|
|
120
163
|
}
|
|
121
164
|
|
|
122
165
|
export type ViewAction = "none" | "render" | "close" | "open" | "stop";
|
|
@@ -124,6 +167,7 @@ export type ViewAction = "none" | "render" | "close" | "open" | "stop";
|
|
|
124
167
|
/** Pure key handling so it can be unit tested without a terminal. */
|
|
125
168
|
export function handleViewKey(data: string, state: LegionViewState, nodes: readonly VizNode[], page = 10): ViewAction {
|
|
126
169
|
const events = buildLog(nodes);
|
|
170
|
+
const timingRows = timingRowsFromNodes(nodes, Date.now());
|
|
127
171
|
const order = dfsOrder(nodes).filter(isAgent);
|
|
128
172
|
if (state.pendingStopId) {
|
|
129
173
|
const confirmed = matchesKey(data, "y") || data === "Y" || matchesKey(data, Key.enter);
|
|
@@ -171,6 +215,21 @@ export function handleViewKey(data: string, state: LegionViewState, nodes: reado
|
|
|
171
215
|
} else return "none";
|
|
172
216
|
return "render";
|
|
173
217
|
}
|
|
218
|
+
if (state.focus === "timing") {
|
|
219
|
+
if (down) state.timingCursor = Math.min(timingRows.length - 1, state.timingCursor + 1);
|
|
220
|
+
else if (up) state.timingCursor = Math.max(0, state.timingCursor - 1);
|
|
221
|
+
else if (matchesKey(data, Key.pageDown)) state.timingCursor = Math.min(timingRows.length - 1, state.timingCursor + page);
|
|
222
|
+
else if (matchesKey(data, Key.pageUp)) state.timingCursor = Math.max(0, state.timingCursor - page);
|
|
223
|
+
else if (matchesKey(data, Key.home)) state.timingCursor = 0;
|
|
224
|
+
else if (matchesKey(data, Key.end)) state.timingCursor = Math.max(0, timingRows.length - 1);
|
|
225
|
+
else if (matchesKey(data, Key.enter)) {
|
|
226
|
+
const row = timingRows[state.timingCursor];
|
|
227
|
+
if (!row) return "none";
|
|
228
|
+
state.selectedId = state.selectedId === row.node.id ? undefined : row.node.id;
|
|
229
|
+
if (state.selectedId === undefined) delete state.selectedId;
|
|
230
|
+
} else return "none";
|
|
231
|
+
return "render";
|
|
232
|
+
}
|
|
174
233
|
if (down || up || matchesKey(data, Key.right) || matchesKey(data, Key.left)) {
|
|
175
234
|
if (order.length === 0) return "none";
|
|
176
235
|
const step = down || matchesKey(data, Key.right) ? 1 : -1;
|