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.
@@ -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
 
@@ -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 { buildLog, dfsOrder, drawChart, drawGalaxy, drawLog, frame, type Pane, type ViewData } from "./panels.ts";
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
- clampLog(state, events.length, Math.max(1, layout.log.h - 3));
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 clampLog(state: LegionViewState, count: number, visible: number): void {
116
- state.logCursor = Math.max(0, Math.min(state.logCursor, count - 1));
117
- if (state.logCursor < state.logOffset) state.logOffset = state.logCursor;
118
- if (state.logCursor >= state.logOffset + visible) state.logOffset = state.logCursor - visible + 1;
119
- state.logOffset = Math.max(0, Math.min(state.logOffset, Math.max(0, count - visible)));
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;