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.
- package/CHANGELOG.md +23 -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/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/live-overlay.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
665
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 +
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
|
805
|
-
if (
|
|
806
|
-
|
|
807
|
-
|
|
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
|
+
}
|
package/src/tui/viz/layout.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/tui/viz/panels.ts
CHANGED
|
@@ -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
|
|
285
|
-
|
|
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
|
+
}
|
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
|
|