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.
@@ -1,4 +1,5 @@
1
1
  import type { DatabaseSync } from "./db.ts";
2
+ import { LIVE_CHILD_STATUSES } from "./nodes.ts";
2
3
  import { withTransaction } from "./db.ts";
3
4
  import { appendEvent } from "./events.ts";
4
5
 
@@ -64,14 +65,15 @@ export function checkLaunchBudget(
64
65
  ): BudgetDecision {
65
66
  validateRequest(request);
66
67
  const limits = normalizeLimits(options.limits);
68
+ const liveStatuses = LIVE_CHILD_STATUSES.map(() => "?").join(", ");
67
69
  const row = db
68
70
  .prepare(
69
71
  `SELECT
70
- (SELECT COUNT(*) FROM nodes WHERE parent_id IS ?) AS child_count,
72
+ (SELECT COUNT(*) FROM nodes WHERE parent_id IS ? AND status IN (${liveStatuses})) AS child_count,
71
73
  (SELECT COUNT(*) FROM nodes WHERE status IN ('running', 'starting')) AS active_count,
72
74
  CASE WHEN ? IS NULL THEN 1 ELSE EXISTS(SELECT 1 FROM nodes WHERE id = ?) END AS parent_exists`,
73
75
  )
74
- .get(request.parentId, request.parentId, request.parentId);
76
+ .get(request.parentId, ...LIVE_CHILD_STATUSES, request.parentId, request.parentId);
75
77
  const counts = {
76
78
  children: Number(row?.child_count ?? 0),
77
79
  activeNodes: Number(row?.active_count ?? 0),
@@ -86,7 +88,7 @@ export function checkLaunchBudget(
86
88
  if (counts.children >= limits.maxChildrenPerNode) {
87
89
  return denied(
88
90
  "max_children_per_node",
89
- `parent already has ${counts.children} children; maxChildrenPerNode is ${limits.maxChildrenPerNode}`,
91
+ `parent already has ${counts.children} live children; maxChildrenPerNode is ${limits.maxChildrenPerNode}`,
90
92
  base,
91
93
  );
92
94
  }
@@ -21,6 +21,13 @@ export const NON_TERMINAL_NODE_STATUSES = [
21
21
  "retired",
22
22
  ] as const satisfies readonly NodeStatus[];
23
23
 
24
+ /** Statuses that occupy a maxChildrenPerNode concurrency slot (not retired/session-only). */
25
+ export const LIVE_CHILD_STATUSES = [
26
+ "starting",
27
+ "running",
28
+ "blocked",
29
+ ] as const satisfies readonly NodeStatus[];
30
+
24
31
  export interface LegionNode {
25
32
  id: string;
26
33
  parentId: string | null;
@@ -18,7 +18,7 @@ import type { RunStore } from "./store.ts";
18
18
  import type { LegionEventBus, LegionProcessEventType } from "../extension/event-bus.ts";
19
19
  import { appendEvent } from "../legion/events.ts";
20
20
  import { withLaunchBudget, type LegionBudgetLimits } from "../legion/budget.ts";
21
- import { getChildByName, getNode, insertNode, nodeIdForChild, sanitizeNodeSlug, type LegionNode, updateNode } from "../legion/nodes.ts";
21
+ import { getChildByName, getNode, insertNode, nodeIdForChild, sanitizeNodeSlug, type LegionNode, updateNode, LIVE_CHILD_STATUSES } from "../legion/nodes.ts";
22
22
  import { upsertUsage } from "../legion/usage.ts";
23
23
  import { evaluatePhaseTransition, isLegionPhase, isTerminalPhase } from "../legion/state-machine.ts";
24
24
  import { CHILD_ACCEPTANCE_ROLE_ENV, CHILD_ROLE_ENV } from "../extension/child-guard.ts";
@@ -341,7 +341,8 @@ export class Orchestrator {
341
341
  const depthCeiling = input.utility
342
342
  ? parent.depth + 1
343
343
  : Math.min(legion.settings?.maxDepth ?? this.maxDepthValue, MAX_NESTED_PATH_ENTRIES);
344
- const childCount = Number(legion.db.prepare("SELECT COUNT(*) AS count FROM nodes WHERE parent_id = ?").get(legion.nodeId)?.count ?? 0);
344
+ const liveStatuses = LIVE_CHILD_STATUSES.map(() => "?").join(", ");
345
+ const childCount = Number(legion.db.prepare(`SELECT COUNT(*) AS count FROM nodes WHERE parent_id = ? AND status IN (${liveStatuses})`).get(legion.nodeId, ...LIVE_CHILD_STATUSES)?.count ?? 0);
345
346
  const maxChildren = previous ? Math.max(legion.settings?.maxChildrenPerNode ?? 8, childCount + 1) : legion.settings?.maxChildrenPerNode;
346
347
  const activeReservation = previous && (previous.status === "starting" || previous.status === "running") ? 1 : 0;
347
348
  const limits: LegionBudgetLimits = { maxDepth: depthCeiling, maxChildrenPerNode: maxChildren, maxActiveNodes: (legion.settings?.maxActiveNodes ?? 30) + activeReservation };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared wall/gen/other math for `/legion timing` (#150) and the TUI timing panel (#151).
3
+ * Zero schema — callers supply wall_ms and generation_ms (null = no turn data → other —).
4
+ */
5
+ export interface TimingBreakdown {
6
+ wall_ms: number;
7
+ generation_ms: number | null;
8
+ /** max(0, wall − gen); null when generation_ms is null. Coarse bucket, not wait/tool. */
9
+ other_ms: number | null;
10
+ }
11
+
12
+ /** Pure breakdown: other = max(0, wall − gen); gen absent → other —. */
13
+ export function breakdown(
14
+ wall_ms: number,
15
+ generation_ms: number | null,
16
+ ): TimingBreakdown {
17
+ return {
18
+ wall_ms,
19
+ generation_ms,
20
+ other_ms:
21
+ generation_ms === null ? null : Math.max(0, wall_ms - generation_ms),
22
+ };
23
+ }
@@ -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
+ }