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.
@@ -21,6 +21,9 @@ import {
21
21
  } from "./chat-items.ts";
22
22
 
23
23
  export interface LiveOverlayNode extends Pick<LegionNode, "id" | "name" | "role" | "model" | "status" | "createdAt" | "updatedAt"> {
24
+ /** Null parent = root/主 agent; set for 子 agents so the modal title can mark 主/子. */
25
+ parentId?: string | null;
26
+ depth?: number;
24
27
  turn?: number;
25
28
  turns?: number;
26
29
  sessionFile?: string | null;
@@ -147,6 +150,11 @@ const DEFAULT_BACKFILL_TURNS = 3;
147
150
  const DEFAULT_TERMINAL_ROWS = 24;
148
151
  const TOOL_COLLAPSED_PREVIEW_LINES = 10;
149
152
  const BACKFILL_POLL_INTERVAL_MS = 250;
153
+ /** Outer dim mask rows when the terminal is tall enough for a clear floating modal. */
154
+ const MODAL_MARGIN_Y_COMFORTABLE = 1;
155
+ /** Outer dim mask columns (left/right) outside the modal border. */
156
+ const MODAL_MARGIN_X_COMFORTABLE = 2;
157
+ const MODAL_CLOSE_HINT = "Esc";
150
158
 
151
159
  /**
152
160
  * Build an on-demand context-viewer controller. Calling `open` subscribes to
@@ -287,7 +295,13 @@ function createSession(
287
295
  return value;
288
296
  };
289
297
 
290
- const bodyPageSize = (): number => Math.max(0, currentTerminalRows() - 1);
298
+ /** Full terminal width last passed to render/handleMouse (modal chrome uses this). */
299
+ let terminalWidth = 80;
300
+
301
+ const layoutFor = (width = terminalWidth): ModalLayout =>
302
+ resolveModalLayout(currentTerminalRows(), Math.max(1, Math.floor(width)));
303
+
304
+ const bodyPageSize = (): number => layoutFor().bodyRows;
291
305
 
292
306
  const mountNewItems = (): void => {
293
307
  for (const item of mountedItems) {
@@ -661,13 +675,10 @@ function createSession(
661
675
  };
662
676
 
663
677
  let stopPending = false;
664
- const headerLine = (width: number): string => {
665
- const safeWidth = Math.max(1, Math.floor(width));
678
+
679
+ const titlePlain = (): string => {
666
680
  const node = sourceNode();
667
- if (!node) {
668
- const missing = `● ${nodeId} · node unavailable`;
669
- return style(options.theme, "dim", fitLine(missing, safeWidth));
670
- }
681
+ if (!node) return `● ${nodeId} · node unavailable`;
671
682
  const status = node.status;
672
683
  const completed = status === "settled" || status === "failed" || status === "retired";
673
684
  const elapsedEnd = completed ? node.updatedAt : now();
@@ -677,14 +688,42 @@ function createSession(
677
688
  ? `${Math.max(0, Math.round((now() - turnStartedAt) / 1_000))}s`
678
689
  : undefined;
679
690
  const turnLabel = `turn ${turnNumber}${turnElapsed !== undefined ? ` (${turnElapsed})` : ""}`;
680
- const details = [
691
+ const kind = contextKindLabel(node, nodeId);
692
+ return [
681
693
  `● ${safeText(node.name)} (${safeText(node.role)})`,
694
+ kind,
682
695
  ...(node.model ? [safeText(node.model)] : []),
683
696
  turnLabel,
684
697
  elapsed,
685
698
  status,
686
- ];
687
- return style(options.theme, completed ? "dim" : "accent", fitLine(details.join(" · "), safeWidth));
699
+ ].join(" · ");
700
+ };
701
+
702
+ const titleBarInner = (innerWidth: number): string => {
703
+ const safeWidth = Math.max(1, Math.floor(innerWidth));
704
+ const node = sourceNode();
705
+ const completed = node !== undefined &&
706
+ (node.status === "settled" || node.status === "failed" || node.status === "retired");
707
+ if (stopPending) {
708
+ return style(
709
+ options.theme,
710
+ "accent",
711
+ fitLine(
712
+ `Stop ${safeText(node?.name ?? nodeId)}? Its own subagents are retired with it. y stop · any other key cancel`,
713
+ safeWidth,
714
+ ),
715
+ );
716
+ }
717
+ const closeHint = ` ${MODAL_CLOSE_HINT} `;
718
+ const closeWidth = visibleWidth(closeHint);
719
+ const titleBudget = Math.max(1, safeWidth - closeWidth);
720
+ const coloredTitle = style(
721
+ options.theme,
722
+ !node || completed ? "dim" : "accent",
723
+ fitLine(titlePlain(), titleBudget),
724
+ );
725
+ const pad = Math.max(0, titleBudget - visibleWidth(stripTerminalSequences(coloredTitle)));
726
+ return coloredTitle + " ".repeat(pad) + style(options.theme, "dim", closeHint);
688
727
  };
689
728
 
690
729
  const close = (): void => {
@@ -741,15 +780,22 @@ function createSession(
741
780
  const component: LiveOverlayComponent = {
742
781
  nodeId,
743
782
  render(width) {
744
- contentWidth = Math.max(1, Math.floor(width));
783
+ terminalWidth = Math.max(1, Math.floor(width));
784
+ const layout = layoutFor(terminalWidth);
785
+ contentWidth = layout.innerWidth;
745
786
  lastBodyLines = renderBody(contentWidth);
746
787
  syncScroll(lastBodyLines);
747
788
  const start = Math.max(0, scrollTop);
748
- const end = start + bodyPageSize();
749
- const header = stopPending
750
- ? style(options.theme, "accent", fitLine(`Stop ${safeText(sourceNode()?.name ?? nodeId)}? Its own subagents are retired with it. y stop · any other key cancel`, contentWidth))
751
- : headerLine(contentWidth);
752
- return [header, ...lastBodyLines.slice(start, end)];
789
+ const end = start + layout.bodyRows;
790
+ const visible = lastBodyLines.slice(start, end);
791
+ while (visible.length < layout.bodyRows) visible.push("");
792
+ return paintModalFrame({
793
+ layout,
794
+ theme: options.theme,
795
+ titleInner: titleBarInner(layout.innerWidth),
796
+ bodyLines: visible,
797
+ footerHint: "↑↓ scroll · Esc close · click mask to dismiss",
798
+ });
753
799
  },
754
800
  handleInput(data) {
755
801
  if (!open) return;
@@ -794,20 +840,33 @@ function createSession(
794
840
  },
795
841
  handleMouse(event: TuiMouseEvent) {
796
842
  if (!open) return undefined;
843
+ terminalWidth = Math.max(1, Math.floor(event.width));
844
+ const layout = layoutFor(terminalWidth);
845
+ contentWidth = layout.innerWidth;
797
846
  if (event.type === "wheel" && event.wheelDelta) {
847
+ // Wheel anywhere over the overlay scrolls the modal body (mask included).
798
848
  scrollBy(event.wheelDelta);
799
849
  return { handled: true };
800
850
  }
801
- contentWidth = Math.max(1, Math.floor(event.width));
802
851
  lastBodyLines = renderBody(contentWidth);
803
852
  clampScroll(lastBodyLines);
804
- const viewportY = event.y - 1;
805
- if (viewportY < 0 || viewportY >= bodyPageSize()) return undefined;
806
- const transcriptY = viewportY + scrollTop;
807
- if (transcriptY >= lastBodyLines.length) return undefined;
853
+ const hit = hitTestModal(layout, event.x, event.y);
854
+ if (hit.zone === "mask" || hit.zone === "close") {
855
+ if (event.type === "click" && event.button === "left") {
856
+ // Esc / mask / close: dismiss only — never retire or stop (esc-no-retire-v3).
857
+ close();
858
+ return { handled: true };
859
+ }
860
+ return { handled: true };
861
+ }
862
+ if (hit.zone !== "body" || hit.bodyY === undefined) return { handled: true };
863
+ const transcriptY = hit.bodyY + scrollTop;
864
+ if (transcriptY >= lastBodyLines.length) return { handled: true };
808
865
  return transcript.handleMouse({
809
866
  ...event,
867
+ x: hit.bodyX ?? 0,
810
868
  y: transcriptY,
869
+ width: layout.innerWidth,
811
870
  height: lastBodyLines.length,
812
871
  });
813
872
  },
@@ -872,3 +931,125 @@ function style(
872
931
  ): string {
873
932
  return theme ? theme.fg(color, line) : line;
874
933
  }
934
+
935
+ /** Label the viewed agent as 主 (root) or 子 (child) for the modal title bar. */
936
+ export function contextKindLabel(node: LiveOverlayNode | undefined, nodeId: string): "主" | "子" {
937
+ if (node?.parentId === null || node?.depth === 0 || nodeId === "root") return "主";
938
+ if (node?.parentId !== undefined && node.parentId !== null) return "子";
939
+ if (node?.depth !== undefined && node.depth > 0) return "子";
940
+ // Infer from Legion id shape: root has no dots; children are parent.child…
941
+ return nodeId === "root" || !nodeId.includes(".") ? "主" : "子";
942
+ }
943
+
944
+ export interface ModalLayout {
945
+ rows: number;
946
+ cols: number;
947
+ marginY: number;
948
+ marginX: number;
949
+ titleRow: number;
950
+ bodyStartRow: number;
951
+ footerRow: number;
952
+ bodyRows: number;
953
+ innerWidth: number;
954
+ /** Column where modal border (│/┏) starts. */
955
+ frameCol: number;
956
+ /** Absolute screen x range of the Esc close hint in the title bar. */
957
+ closeColStart: number;
958
+ closeColEnd: number;
959
+ }
960
+
961
+ export function resolveModalLayout(rows: number, cols: number): ModalLayout {
962
+ const safeRows = Math.max(1, Math.floor(rows));
963
+ const safeCols = Math.max(1, Math.floor(cols));
964
+ // Prefer a 1-row dim mask; collapse it on short terminals so body still has room.
965
+ const marginY = safeRows >= 6 ? MODAL_MARGIN_Y_COMFORTABLE : 0;
966
+ const marginX = safeCols >= 24 ? MODAL_MARGIN_X_COMFORTABLE : safeCols >= 12 ? 1 : 0;
967
+ const chromeY = 2 * marginY + 2; // masks + title + footer
968
+ const bodyRows = Math.max(1, safeRows - chromeY);
969
+ const frameCol = marginX;
970
+ const innerWidth = Math.max(1, safeCols - 2 * marginX - 2);
971
+ const titleRow = marginY;
972
+ const bodyStartRow = marginY + 1;
973
+ const footerRow = marginY + 1 + bodyRows;
974
+ const closeWidth = visibleWidth(` ${MODAL_CLOSE_HINT} `);
975
+ const closeColEnd = frameCol + 1 + innerWidth;
976
+ const closeColStart = Math.max(frameCol + 1, closeColEnd - closeWidth);
977
+ return {
978
+ rows: safeRows,
979
+ cols: safeCols,
980
+ marginY,
981
+ marginX,
982
+ titleRow,
983
+ bodyStartRow,
984
+ footerRow,
985
+ bodyRows,
986
+ innerWidth,
987
+ frameCol,
988
+ closeColStart,
989
+ closeColEnd,
990
+ };
991
+ }
992
+
993
+ type ModalHitZone = "mask" | "close" | "title" | "body" | "footer";
994
+
995
+ function hitTestModal(
996
+ layout: ModalLayout,
997
+ x: number,
998
+ y: number,
999
+ ): { zone: ModalHitZone; bodyX?: number; bodyY?: number } {
1000
+ if (y < layout.marginY || y >= layout.rows - layout.marginY) return { zone: "mask" };
1001
+ if (x < layout.frameCol || x >= layout.frameCol + layout.innerWidth + 2) return { zone: "mask" };
1002
+ if (y === layout.titleRow) {
1003
+ if (x >= layout.closeColStart && x < layout.closeColEnd) return { zone: "close" };
1004
+ return { zone: "title" };
1005
+ }
1006
+ if (y === layout.footerRow) return { zone: "footer" };
1007
+ if (y >= layout.bodyStartRow && y < layout.bodyStartRow + layout.bodyRows) {
1008
+ return {
1009
+ zone: "body",
1010
+ bodyX: Math.max(0, x - (layout.frameCol + 1)),
1011
+ bodyY: y - layout.bodyStartRow,
1012
+ };
1013
+ }
1014
+ return { zone: "mask" };
1015
+ }
1016
+
1017
+ function padToInner(line: string, innerWidth: number): string {
1018
+ const clipped = fitLine(line, innerWidth);
1019
+ const pad = Math.max(0, innerWidth - visibleWidth(stripTerminalSequences(clipped)));
1020
+ return clipped + " ".repeat(pad);
1021
+ }
1022
+
1023
+ function paintModalFrame(input: {
1024
+ layout: ModalLayout;
1025
+ theme: LiveOverlayTheme | undefined;
1026
+ titleInner: string;
1027
+ bodyLines: readonly string[];
1028
+ footerHint: string;
1029
+ }): string[] {
1030
+ const { layout, theme, titleInner, bodyLines, footerHint } = input;
1031
+ const dim = (s: string) => style(theme, "dim", s);
1032
+ const accent = (s: string) => style(theme, "accent", s);
1033
+ const lines: string[] = [];
1034
+ const maskLine = dim(" ".repeat(layout.cols));
1035
+ const leftMask = dim(" ".repeat(layout.marginX));
1036
+ const rightMaskWidth = Math.max(0, layout.cols - layout.marginX - layout.innerWidth - 2);
1037
+ const rightMask = dim(" ".repeat(rightMaskWidth));
1038
+ const top = leftMask + accent("┏") + padToInner(titleInner, layout.innerWidth) + accent("┓") + rightMask;
1039
+ const bottom = leftMask +
1040
+ accent("┗") +
1041
+ padToInner(dim(` ${footerHint} `), layout.innerWidth) +
1042
+ accent("┛") +
1043
+ rightMask;
1044
+
1045
+ for (let i = 0; i < layout.marginY; i++) lines.push(maskLine);
1046
+ lines.push(top);
1047
+ for (let i = 0; i < layout.bodyRows; i++) {
1048
+ const body = padToInner(bodyLines[i] ?? "", layout.innerWidth);
1049
+ lines.push(leftMask + accent("┃") + body + accent("┃") + rightMask);
1050
+ }
1051
+ lines.push(bottom);
1052
+ for (let i = 0; i < layout.marginY; i++) lines.push(maskLine);
1053
+ while (lines.length < layout.rows) lines.push(maskLine);
1054
+ return lines.slice(0, layout.rows);
1055
+ }
@@ -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
- log: { x: leftW, y: bodyY, w: W - leftW, h: bodyH },
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
- log: { x: 0, y: bodyY + topH, w: W, h: bodyH - topH },
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
- let logH = bodyH - galaxyH - chartH;
91
- // Few events: give some of the empty log area back to the galaxy.
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
- log: { x: 0, y: bodyY + galaxyH + chartH, w: W, h: Math.max(0, logH) },
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
  }
@@ -5,8 +5,9 @@ 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[];
@@ -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 from = Math.min(data.sampler.startedAt ?? data.now, ...(starts.length ? starts : [data.now]));
285
- return { from: Math.min(from, data.now - 60_000), to: data.now };
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 {
@@ -536,3 +543,149 @@ export function drawLog(
536
543
  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
544
  }
538
545
  }
546
+
547
+ // ---------------------------------------------------------------- timing (#151)
548
+ // TUI scope = current view VizNodes; CLI `/legion timing` = cwd RunStore children.
549
+ // Formula matches #150 (wall / gen / other); the node set can differ.
550
+
551
+ export interface TimingRow {
552
+ node: VizNode;
553
+ wallMs: number;
554
+ generationMs: number | null;
555
+ otherMs: number | null;
556
+ liveWall: boolean;
557
+ }
558
+
559
+ /**
560
+ * Build timing rows from viz nodes (agents only).
561
+ * VizNode has no turn count: generationMs === 0 → gen/other shown as — (no turn data).
562
+ */
563
+ export function timingRowsFromNodes(nodes: readonly VizNode[], now: number): TimingRow[] {
564
+ const rows: TimingRow[] = [];
565
+ for (const node of nodes) {
566
+ if (!isAgent(node)) continue;
567
+ const end = node.endedAt ?? now;
568
+ const wallMs = Math.max(0, end - node.startedAt);
569
+ const liveWall = node.status === "running" && node.endedAt === undefined;
570
+ // No turn data on VizNode → treat 0 generationMs as absent (align "无 turn → —").
571
+ const generationMs = node.generationMs > 0 ? node.generationMs : null;
572
+ const parts = breakdown(wallMs, generationMs);
573
+ rows.push({
574
+ node,
575
+ wallMs: parts.wall_ms,
576
+ generationMs: parts.generation_ms,
577
+ otherMs: parts.other_ms,
578
+ liveWall,
579
+ });
580
+ }
581
+ rows.sort((a, b) => a.node.startedAt - b.node.startedAt || a.node.name.localeCompare(b.node.name));
582
+ return rows;
583
+ }
584
+
585
+ function formatTimingCell(ms: number | null, opts: { live?: boolean } = {}): string {
586
+ if (ms === null) return "—";
587
+ const rendered = formatDuration(ms);
588
+ return opts.live ? `${rendered}+` : rendered;
589
+ }
590
+
591
+ function roleAbbrev(role: string): string {
592
+ const t = role.trim();
593
+ if (!t) return "";
594
+ if (t.length <= 4) return t;
595
+ return t.slice(0, 3);
596
+ }
597
+
598
+ export interface TimingDrawState {
599
+ offset: number;
600
+ cursor: number;
601
+ focused: boolean;
602
+ selectedId?: string;
603
+ }
604
+
605
+ export function drawTiming(
606
+ cv: Canvas,
607
+ r: Rect,
608
+ rows: readonly TimingRow[],
609
+ state: TimingDrawState,
610
+ ): void {
611
+ if (r.w < 8 || r.h < 2) return;
612
+ const showExtra = r.w >= 36;
613
+ const showRole = r.w >= 28;
614
+ const header = showExtra
615
+ ? "name" + (showRole ? " · role" : "") + " wall gen other"
616
+ : "name" + (showRole ? " · role" : "") + " wall";
617
+ cv.text(r.x, r.y, ellipsize(header, r.w), "dim");
618
+
619
+ const footerH = rows.length > 0 && r.h >= 4 ? 1 : 0;
620
+ const rowsAvail = Math.max(0, r.h - 1 - footerH);
621
+ for (let i = 0; i < rowsAvail; i++) {
622
+ const index = state.offset + i;
623
+ const row = rows[index];
624
+ if (!row) break;
625
+ const y = r.y + 1 + i;
626
+ const isCursor = state.focused && index === state.cursor;
627
+ const highlight = state.selectedId === row.node.id;
628
+ const dim = state.selectedId !== undefined && !highlight;
629
+
630
+ const wall = formatTimingCell(row.wallMs, { live: row.liveWall });
631
+ const gen = formatTimingCell(row.generationMs);
632
+ const other = formatTimingCell(row.otherMs);
633
+
634
+ const wallW = showExtra ? 8 : Math.max(6, Math.min(10, r.w - 12));
635
+ const genW = 7;
636
+ const otherW = 7;
637
+ const metaW = showExtra ? wallW + 1 + genW + 1 + otherW : wallW;
638
+ const nameBudget = Math.max(4, r.w - metaW - (showRole ? 5 : 1));
639
+
640
+ let name = ellipsize(row.node.name, nameBudget);
641
+ if (showRole) {
642
+ const abbr = roleAbbrev(row.node.role);
643
+ const room = nameBudget - cellWidth(name) - 1;
644
+ if (abbr && room >= cellWidth(abbr)) {
645
+ name = `${name} ${abbr}`;
646
+ }
647
+ }
648
+
649
+ cv.text(r.x, y, ellipsize(name, nameBudget), dim ? "dim" : roleColor(row.node.role), {
650
+ bold: !dim,
651
+ inverse: isCursor,
652
+ });
653
+
654
+ const metaX = r.x + r.w - metaW;
655
+ if (showExtra) {
656
+ cv.text(metaX, y, wall.padStart(wallW), highlight ? "text" : "dim", { maxX: r.x + r.w });
657
+ cv.text(metaX + wallW + 1, y, gen.padStart(genW), "dim", { maxX: r.x + r.w });
658
+ cv.text(metaX + wallW + 1 + genW + 1, y, other.padStart(otherW), "dim", { maxX: r.x + r.w });
659
+ } else {
660
+ cv.text(metaX, y, wall.padStart(wallW), highlight ? "text" : "dim", { maxX: r.x + r.w });
661
+ }
662
+ if (isCursor && highlight) cv.set(r.x + r.w - 1, y, "◂", "accent", { bold: true });
663
+ }
664
+
665
+ if (footerH && rows.length > 0) {
666
+ const started = rows.map((row) => row.node.startedAt);
667
+ const ended = rows.map((row) => (row.liveWall ? null : row.node.endedAt ?? row.node.startedAt + row.wallMs));
668
+ const ends = ended.filter((v): v is number => v !== null);
669
+ const span =
670
+ started.length && ends.length
671
+ ? Math.max(0, Math.max(...ends) - Math.min(...started))
672
+ : null;
673
+ const sumWall = rows.reduce((sum, row) => sum + row.wallMs, 0);
674
+ const fy = r.y + r.h - 1;
675
+ const spanText = span === null ? "—" : formatDuration(span);
676
+ cv.text(r.x, fy, ellipsize(`span ${spanText} · sum ${formatDuration(sumWall)}`, r.w), "dim");
677
+ }
678
+
679
+ if (rows.length > rowsAvail && rowsAvail > 0) {
680
+ const thumb = Math.max(1, Math.round((rowsAvail / rows.length) * rowsAvail));
681
+ const pos = Math.round((state.offset / Math.max(1, rows.length - rowsAvail)) * (rowsAvail - thumb));
682
+ for (let i = 0; i < rowsAvail; i++) {
683
+ cv.set(
684
+ r.x + r.w - 1,
685
+ r.y + 1 + i,
686
+ i >= pos && i < pos + thumb ? "┃" : "│",
687
+ i >= pos && i < pos + thumb ? "scrollbarThumb" : "scrollbarTrack",
688
+ );
689
+ }
690
+ }
691
+ }
@@ -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