pi-legion 2.0.3 → 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.
@@ -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;