pi-legion 2.0.3 → 3.0.0
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 +93 -1
- package/CONTRIBUTING.md +27 -24
- package/README.md +103 -19
- package/docs/contracts/osc-busy-plugin.md +72 -0
- package/docs/contracts/spawn-default-inprocess-v3.md +322 -0
- package/docs/contracts/wait-not-stop.md +30 -0
- package/docs/design.md +2 -2
- package/docs/headless-child-contract.md +83 -0
- package/docs/inprocess-supervisor.md +140 -0
- package/docs/qa-hunting-round.md +150 -0
- package/docs/subagents-teams.md +55 -18
- package/docs/tui-qa-playbook.md +284 -0
- package/index.ts +396 -62
- package/package.json +15 -8
- package/skills/pi-legion/SKILL.md +5 -0
- package/src/agents/agents.ts +34 -6
- package/src/agents/context.ts +83 -1
- package/src/agents/overrides.ts +1 -0
- package/src/agents/settings.ts +122 -1
- package/src/api.ts +23 -0
- package/src/extension/child-guard.ts +42 -3
- package/src/extension/legion-commands.ts +15 -3
- package/src/extension/legion-doctor.ts +55 -0
- package/src/extension/legion-stop.ts +6 -2
- package/src/extension/playbook.ts +3 -3
- package/src/extension/runtime.ts +57 -8
- package/src/extension/scout-guard.ts +39 -1
- package/src/extension/session-errors.ts +191 -0
- package/src/extension/timing.ts +391 -0
- package/src/runs/args.ts +8 -0
- package/src/runs/orchestrator.ts +125 -16
- package/src/shared/output-cap.ts +2 -2
- package/src/shared/session.ts +2 -1
- package/src/shared/timing-breakdown.ts +23 -0
- package/src/shared/types.ts +74 -0
- package/src/supervisor/child-context.ts +90 -0
- package/src/supervisor/child-memory.ts +27 -3
- package/src/supervisor/inprocess-depth.ts +71 -0
- package/src/supervisor/inprocess-supervisor.ts +1011 -0
- package/src/supervisor/routing-supervisor.ts +179 -0
- package/src/supervisor/rpc-supervisor.ts +14 -0
- package/src/supervisor/spawn-mode.ts +35 -0
- package/src/supervisor/types.ts +13 -0
- package/src/supervisor/ui-proxy.ts +78 -0
- package/src/tui/live-overlay.ts +297 -24
- package/src/tui/status.ts +71 -35
- package/src/tui/viz/layout.ts +50 -6
- package/src/tui/viz/model.ts +6 -0
- package/src/tui/viz/panels.ts +164 -12
- package/src/tui/viz/railway.ts +154 -21
- package/src/tui/viz/rate.ts +36 -14
- package/src/tui/viz/view.ts +69 -10
package/src/tui/live-overlay.ts
CHANGED
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
visibleWidth,
|
|
9
9
|
type Component,
|
|
10
10
|
type OverlayHandle,
|
|
11
|
+
type OverlayOptions,
|
|
12
|
+
type SizeValue,
|
|
11
13
|
type TUI,
|
|
12
14
|
type TuiMouseEvent,
|
|
13
15
|
} from "@earendil-works/pi-tui";
|
|
@@ -21,6 +23,9 @@ import {
|
|
|
21
23
|
} from "./chat-items.ts";
|
|
22
24
|
|
|
23
25
|
export interface LiveOverlayNode extends Pick<LegionNode, "id" | "name" | "role" | "model" | "status" | "createdAt" | "updatedAt"> {
|
|
26
|
+
/** Null parent = root/主 agent; set for 子 agents so the modal title can mark 主/子. */
|
|
27
|
+
parentId?: string | null;
|
|
28
|
+
depth?: number;
|
|
24
29
|
turn?: number;
|
|
25
30
|
turns?: number;
|
|
26
31
|
sessionFile?: string | null;
|
|
@@ -147,6 +152,59 @@ const DEFAULT_BACKFILL_TURNS = 3;
|
|
|
147
152
|
const DEFAULT_TERMINAL_ROWS = 24;
|
|
148
153
|
const TOOL_COLLAPSED_PREVIEW_LINES = 10;
|
|
149
154
|
const BACKFILL_POLL_INTERVAL_MS = 250;
|
|
155
|
+
/**
|
|
156
|
+
* #157: Cap the floating context modal by terminal ratio so large transcripts do
|
|
157
|
+
* not stretch the shell near-fullscreen. Content scrolls inside the body.
|
|
158
|
+
* Tuned on 80×24 + 160×50 dumps (≈75% × ≈65% feels clearly smaller than full
|
|
159
|
+
* screen without feeling cramped). Floor sizes kick in on mid terminals; on
|
|
160
|
+
* very small terminals we still fill the screen so body remains usable.
|
|
161
|
+
*
|
|
162
|
+
* #193: the surrounding area is NOT painted by the viewer. pi-tui's overlay
|
|
163
|
+
* compositor discards every base cell inside the overlay rectangle (there is no
|
|
164
|
+
* transparency option in `OverlayOptions`), so any mask this component paints is
|
|
165
|
+
* opaque by construction. The host underlay (chat / /legion) stays visible only
|
|
166
|
+
* because the overlay is anchored to the frame rect alone — see
|
|
167
|
+
* `resolveModalOverlayOptions()`.
|
|
168
|
+
*/
|
|
169
|
+
const MODAL_MAX_WIDTH_RATIO = 0.75;
|
|
170
|
+
const MODAL_MAX_HEIGHT_RATIO = 0.65;
|
|
171
|
+
/** Prefer at least this frame height once the terminal itself is tall enough. */
|
|
172
|
+
const MODAL_MIN_FRAME_ROWS = 8;
|
|
173
|
+
const MODAL_CLOSE_HINT = "Esc";
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Host overlay geometry for the floating frame: cover the frame rect only.
|
|
177
|
+
*
|
|
178
|
+
* pi-tui composites an overlay by replacing every base cell inside its
|
|
179
|
+
* rectangle, and `OverlayOptions` has no transparency mechanism. A full-screen
|
|
180
|
+
* overlay therefore hides the host chat / /legion underlay completely, and
|
|
181
|
+
* because the viewer's mask cells are spaces with no background of their own,
|
|
182
|
+
* the wiped area renders as the terminal's default background — the "black
|
|
183
|
+
* modal" bug (#193). Anchoring the overlay to the frame rect instead leaves
|
|
184
|
+
* every cell outside the frame to the host, which paints it.
|
|
185
|
+
*
|
|
186
|
+
* The frame is centered horizontally and vertically by the host (`anchor:
|
|
187
|
+
* "center"`); this module only declares what fraction of the terminal the
|
|
188
|
+
* frame should occupy.
|
|
189
|
+
*/
|
|
190
|
+
export function resolveModalOverlayOptions(): {
|
|
191
|
+
width: SizeValue;
|
|
192
|
+
anchor: "center";
|
|
193
|
+
} & OverlayOptions {
|
|
194
|
+
// Percentages, not absolute cells: pi resolves `overlayOptions()` once when the
|
|
195
|
+
// overlay is shown, so absolute numbers freeze at the open-time terminal size and
|
|
196
|
+
// the frame gets truncated on resize (bottom border and footer disappear).
|
|
197
|
+
// pi-tui re-derives percentages from the current terminal every frame.
|
|
198
|
+
//
|
|
199
|
+
// No `maxHeight`: the component renders exactly the frame rows for whatever
|
|
200
|
+
// width it is given, so its own height is already the right rect height. A
|
|
201
|
+
// percentage cap would disagree with it on small terminals (the layout has a
|
|
202
|
+
// minimum frame height), cutting the bottom border off.
|
|
203
|
+
return {
|
|
204
|
+
width: `${MODAL_MAX_WIDTH_RATIO * 100}%`,
|
|
205
|
+
anchor: "center",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
150
208
|
|
|
151
209
|
/**
|
|
152
210
|
* Build an on-demand context-viewer controller. Calling `open` subscribes to
|
|
@@ -287,7 +345,13 @@ function createSession(
|
|
|
287
345
|
return value;
|
|
288
346
|
};
|
|
289
347
|
|
|
290
|
-
|
|
348
|
+
/** Full terminal width last passed to render/handleMouse (modal chrome uses this). */
|
|
349
|
+
let terminalWidth = 80;
|
|
350
|
+
|
|
351
|
+
const layoutFor = (width = terminalWidth): ModalLayout =>
|
|
352
|
+
resolveModalLayout(currentTerminalRows(), Math.max(1, Math.floor(width)));
|
|
353
|
+
|
|
354
|
+
const bodyPageSize = (): number => layoutFor().bodyRows;
|
|
291
355
|
|
|
292
356
|
const mountNewItems = (): void => {
|
|
293
357
|
for (const item of mountedItems) {
|
|
@@ -458,7 +522,7 @@ function createSession(
|
|
|
458
522
|
consumedFinalizedText ||
|
|
459
523
|
message.role !== "assistant" ||
|
|
460
524
|
!Array.isArray(message.content) ||
|
|
461
|
-
!messageText(message.content)
|
|
525
|
+
!finalizedText.includes(messageText(message.content))
|
|
462
526
|
) {
|
|
463
527
|
return message;
|
|
464
528
|
}
|
|
@@ -534,7 +598,6 @@ function createSession(
|
|
|
534
598
|
|
|
535
599
|
const updateStreamingText = (delta: string): void => {
|
|
536
600
|
if (!delta) return;
|
|
537
|
-
if (finalizedAssistantText) finalizedAssistantText = "";
|
|
538
601
|
streamingText = clampStreamingText(`${streamingText}${delta}`);
|
|
539
602
|
appendActivityText(delta);
|
|
540
603
|
chat.updateStreamingText(streamingText, sourceNode()?.model ?? initialNode?.model ?? undefined);
|
|
@@ -661,13 +724,19 @@ function createSession(
|
|
|
661
724
|
};
|
|
662
725
|
|
|
663
726
|
let stopPending = false;
|
|
664
|
-
|
|
665
|
-
|
|
727
|
+
|
|
728
|
+
/** Slim title: name + 主/子 only. Run meta lives in the footer band (#157). */
|
|
729
|
+
const titlePlain = (): string => {
|
|
666
730
|
const node = sourceNode();
|
|
667
|
-
if (!node) {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
731
|
+
if (!node) return `● ${nodeId} · node unavailable`;
|
|
732
|
+
const kind = contextKindLabel(node, nodeId);
|
|
733
|
+
return `● ${safeText(node.name)} · ${kind}`;
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
/** Agent/run meta shown below the message body (footer band), not in the title. */
|
|
737
|
+
const metaPlain = (): string => {
|
|
738
|
+
const node = sourceNode();
|
|
739
|
+
if (!node) return "node unavailable";
|
|
671
740
|
const status = node.status;
|
|
672
741
|
const completed = status === "settled" || status === "failed" || status === "retired";
|
|
673
742
|
const elapsedEnd = completed ? node.updatedAt : now();
|
|
@@ -677,14 +746,45 @@ function createSession(
|
|
|
677
746
|
? `${Math.max(0, Math.round((now() - turnStartedAt) / 1_000))}s`
|
|
678
747
|
: undefined;
|
|
679
748
|
const turnLabel = `turn ${turnNumber}${turnElapsed !== undefined ? ` (${turnElapsed})` : ""}`;
|
|
680
|
-
|
|
681
|
-
|
|
749
|
+
return [
|
|
750
|
+
safeText(node.role),
|
|
682
751
|
...(node.model ? [safeText(node.model)] : []),
|
|
683
752
|
turnLabel,
|
|
684
753
|
elapsed,
|
|
685
754
|
status,
|
|
686
|
-
];
|
|
687
|
-
|
|
755
|
+
].join(" · ");
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
const titleBarInner = (innerWidth: number): string => {
|
|
759
|
+
const safeWidth = Math.max(1, Math.floor(innerWidth));
|
|
760
|
+
const node = sourceNode();
|
|
761
|
+
const completed = node !== undefined &&
|
|
762
|
+
(node.status === "settled" || node.status === "failed" || node.status === "retired");
|
|
763
|
+
if (stopPending) {
|
|
764
|
+
return style(
|
|
765
|
+
options.theme,
|
|
766
|
+
"accent",
|
|
767
|
+
fitLine(
|
|
768
|
+
`Stop ${safeText(node?.name ?? nodeId)}? Its own subagents are retired with it. y stop · any other key cancel`,
|
|
769
|
+
safeWidth,
|
|
770
|
+
),
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
const closeHint = ` ${MODAL_CLOSE_HINT} `;
|
|
774
|
+
const closeWidth = visibleWidth(closeHint);
|
|
775
|
+
const titleBudget = Math.max(1, safeWidth - closeWidth);
|
|
776
|
+
const coloredTitle = style(
|
|
777
|
+
options.theme,
|
|
778
|
+
!node || completed ? "dim" : "accent",
|
|
779
|
+
fitLine(titlePlain(), titleBudget),
|
|
780
|
+
);
|
|
781
|
+
const pad = Math.max(0, titleBudget - visibleWidth(stripTerminalSequences(coloredTitle)));
|
|
782
|
+
return coloredTitle + " ".repeat(pad) + style(options.theme, "dim", closeHint);
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
const metaBarInner = (innerWidth: number): string => {
|
|
786
|
+
const safeWidth = Math.max(1, Math.floor(innerWidth));
|
|
787
|
+
return style(options.theme, "dim", fitLine(metaPlain(), safeWidth));
|
|
688
788
|
};
|
|
689
789
|
|
|
690
790
|
const close = (): void => {
|
|
@@ -741,15 +841,27 @@ function createSession(
|
|
|
741
841
|
const component: LiveOverlayComponent = {
|
|
742
842
|
nodeId,
|
|
743
843
|
render(width) {
|
|
744
|
-
|
|
844
|
+
terminalWidth = Math.max(1, Math.floor(width));
|
|
845
|
+
const layout = layoutFor(terminalWidth);
|
|
846
|
+
contentWidth = layout.innerWidth;
|
|
745
847
|
lastBodyLines = renderBody(contentWidth);
|
|
746
848
|
syncScroll(lastBodyLines);
|
|
747
849
|
const start = Math.max(0, scrollTop);
|
|
748
|
-
const end = start +
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
850
|
+
const end = start + layout.bodyRows;
|
|
851
|
+
const visible = lastBodyLines.slice(start, end);
|
|
852
|
+
while (visible.length < layout.bodyRows) visible.push("");
|
|
853
|
+
const footerHint = layout.showMeta
|
|
854
|
+
? "↑↓ scroll · Esc close"
|
|
855
|
+
// Tiny terminals collapse the dedicated meta row into the bottom chrome.
|
|
856
|
+
: `${metaPlain()} · ↑↓ · Esc`;
|
|
857
|
+
return paintModalFrame({
|
|
858
|
+
layout,
|
|
859
|
+
theme: options.theme,
|
|
860
|
+
titleInner: titleBarInner(layout.innerWidth),
|
|
861
|
+
bodyLines: visible,
|
|
862
|
+
metaInner: layout.showMeta ? metaBarInner(layout.innerWidth) : undefined,
|
|
863
|
+
footerHint,
|
|
864
|
+
});
|
|
753
865
|
},
|
|
754
866
|
handleInput(data) {
|
|
755
867
|
if (!open) return;
|
|
@@ -794,20 +906,33 @@ function createSession(
|
|
|
794
906
|
},
|
|
795
907
|
handleMouse(event: TuiMouseEvent) {
|
|
796
908
|
if (!open) return undefined;
|
|
909
|
+
terminalWidth = Math.max(1, Math.floor(event.width));
|
|
910
|
+
const layout = layoutFor(terminalWidth);
|
|
911
|
+
contentWidth = layout.innerWidth;
|
|
797
912
|
if (event.type === "wheel" && event.wheelDelta) {
|
|
913
|
+
// Wheel over the frame scrolls the modal body.
|
|
798
914
|
scrollBy(event.wheelDelta);
|
|
799
915
|
return { handled: true };
|
|
800
916
|
}
|
|
801
|
-
contentWidth = Math.max(1, Math.floor(event.width));
|
|
802
917
|
lastBodyLines = renderBody(contentWidth);
|
|
803
918
|
clampScroll(lastBodyLines);
|
|
804
|
-
const
|
|
805
|
-
if (
|
|
806
|
-
|
|
807
|
-
|
|
919
|
+
const hit = hitTestModal(layout, event.x, event.y);
|
|
920
|
+
if (hit.zone === "close") {
|
|
921
|
+
if (event.type === "click" && event.button === "left") {
|
|
922
|
+
// Esc / close hint: dismiss only — never retire or stop (esc-no-retire-v3).
|
|
923
|
+
close();
|
|
924
|
+
return { handled: true };
|
|
925
|
+
}
|
|
926
|
+
return { handled: true };
|
|
927
|
+
}
|
|
928
|
+
if (hit.zone !== "body" || hit.bodyY === undefined) return { handled: true };
|
|
929
|
+
const transcriptY = hit.bodyY + scrollTop;
|
|
930
|
+
if (transcriptY >= lastBodyLines.length) return { handled: true };
|
|
808
931
|
return transcript.handleMouse({
|
|
809
932
|
...event,
|
|
933
|
+
x: hit.bodyX ?? 0,
|
|
810
934
|
y: transcriptY,
|
|
935
|
+
width: layout.innerWidth,
|
|
811
936
|
height: lastBodyLines.length,
|
|
812
937
|
});
|
|
813
938
|
},
|
|
@@ -872,3 +997,151 @@ function style(
|
|
|
872
997
|
): string {
|
|
873
998
|
return theme ? theme.fg(color, line) : line;
|
|
874
999
|
}
|
|
1000
|
+
|
|
1001
|
+
/** Label the viewed agent as 主 (root) or 子 (child) for the modal title bar. */
|
|
1002
|
+
export function contextKindLabel(node: LiveOverlayNode | undefined, nodeId: string): "主" | "子" {
|
|
1003
|
+
if (node?.parentId === null || node?.depth === 0 || nodeId === "root") return "主";
|
|
1004
|
+
if (node?.parentId !== undefined && node.parentId !== null) return "子";
|
|
1005
|
+
if (node?.depth !== undefined && node.depth > 0) return "子";
|
|
1006
|
+
// Infer from Legion id shape: root has no dots; children are parent.child…
|
|
1007
|
+
return nodeId === "root" || !nodeId.includes(".") ? "主" : "子";
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
export interface ModalLayout {
|
|
1011
|
+
rows: number;
|
|
1012
|
+
cols: number;
|
|
1013
|
+
marginY: number;
|
|
1014
|
+
marginX: number;
|
|
1015
|
+
titleRow: number;
|
|
1016
|
+
bodyStartRow: number;
|
|
1017
|
+
/** Fixed meta row below the scrollable body; null when collapsed into the footer. */
|
|
1018
|
+
metaRow: number | null;
|
|
1019
|
+
footerRow: number;
|
|
1020
|
+
bodyRows: number;
|
|
1021
|
+
innerWidth: number;
|
|
1022
|
+
/** Inclusive frame height in rows (title + body + optional meta + footer). */
|
|
1023
|
+
frameRows: number;
|
|
1024
|
+
/** Column where modal border (│/┏) starts. */
|
|
1025
|
+
frameCol: number;
|
|
1026
|
+
/** Whether a dedicated meta row is painted below the body. */
|
|
1027
|
+
showMeta: boolean;
|
|
1028
|
+
/**
|
|
1029
|
+
* x range of the Esc close hint in the title bar, RELATIVE to the frame rect
|
|
1030
|
+
* (pi-tui hands the component `screenX - layout.col`, not the screen column).
|
|
1031
|
+
*/
|
|
1032
|
+
closeColStart: number;
|
|
1033
|
+
closeColEnd: number;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Layout of the floating frame. All row/col values are relative to the frame
|
|
1038
|
+
* rect the component is mounted at (#193): the host sizes that rect
|
|
1039
|
+
* (`resolveModalOverlayOptions()`) and centers it, and the component paints
|
|
1040
|
+
* nothing outside it, so the host chat / /legion underlay stays visible.
|
|
1041
|
+
*
|
|
1042
|
+
* `rows` is the terminal height (used for the height ratio); `cols` is the
|
|
1043
|
+
* frame rect width handed to `render()`.
|
|
1044
|
+
*/
|
|
1045
|
+
export function resolveModalLayout(rows: number, cols: number): ModalLayout {
|
|
1046
|
+
const safeRows = Math.max(1, Math.floor(rows));
|
|
1047
|
+
const safeCols = Math.max(1, Math.floor(cols));
|
|
1048
|
+
const ratioRows = Math.max(1, Math.floor(safeRows * MODAL_MAX_HEIGHT_RATIO));
|
|
1049
|
+
// Small terminals: fill the screen so the body stays usable. Otherwise cap by
|
|
1050
|
+
// ratio (and a modest floor) so the shell stays clearly smaller than the host.
|
|
1051
|
+
const frameRows = safeRows < MODAL_MIN_FRAME_ROWS
|
|
1052
|
+
? safeRows
|
|
1053
|
+
: Math.min(safeRows, Math.max(MODAL_MIN_FRAME_ROWS, ratioRows));
|
|
1054
|
+
// Need title + ≥1 body + meta + footer (=4) for a dedicated meta band.
|
|
1055
|
+
const showMeta = frameRows >= 4;
|
|
1056
|
+
const chromeInside = 2 + (showMeta ? 1 : 0); // title + optional meta + footer
|
|
1057
|
+
const bodyRows = Math.max(1, frameRows - chromeInside);
|
|
1058
|
+
const innerWidth = Math.max(1, safeCols - 2);
|
|
1059
|
+
const titleRow = 0;
|
|
1060
|
+
const bodyStartRow = 1;
|
|
1061
|
+
const metaRow = showMeta ? bodyStartRow + bodyRows : null;
|
|
1062
|
+
const footerRow = showMeta ? metaRow! + 1 : bodyStartRow + bodyRows;
|
|
1063
|
+
const closeWidth = visibleWidth(` ${MODAL_CLOSE_HINT} `);
|
|
1064
|
+
const closeColEnd = 1 + innerWidth;
|
|
1065
|
+
const closeColStart = Math.max(1, closeColEnd - closeWidth);
|
|
1066
|
+
return {
|
|
1067
|
+
rows: safeRows,
|
|
1068
|
+
cols: safeCols,
|
|
1069
|
+
marginY: 0,
|
|
1070
|
+
marginX: 0,
|
|
1071
|
+
titleRow,
|
|
1072
|
+
bodyStartRow,
|
|
1073
|
+
metaRow,
|
|
1074
|
+
footerRow,
|
|
1075
|
+
bodyRows,
|
|
1076
|
+
innerWidth,
|
|
1077
|
+
frameRows,
|
|
1078
|
+
frameCol: 0,
|
|
1079
|
+
showMeta,
|
|
1080
|
+
closeColStart,
|
|
1081
|
+
closeColEnd,
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
type ModalHitZone = "close" | "title" | "body" | "meta" | "footer";
|
|
1086
|
+
|
|
1087
|
+
function hitTestModal(
|
|
1088
|
+
layout: ModalLayout,
|
|
1089
|
+
x: number,
|
|
1090
|
+
y: number,
|
|
1091
|
+
): { zone: ModalHitZone; bodyX?: number; bodyY?: number } {
|
|
1092
|
+
// x/y are relative to the frame rect (#193): pi-tui dispatches
|
|
1093
|
+
// `screenX - layout.col`, and never dispatches outside the rect at all, so
|
|
1094
|
+
// there is no mask zone to hit-test.
|
|
1095
|
+
if (y === layout.titleRow) {
|
|
1096
|
+
if (x >= layout.closeColStart && x < layout.closeColEnd) return { zone: "close" };
|
|
1097
|
+
return { zone: "title" };
|
|
1098
|
+
}
|
|
1099
|
+
if (layout.metaRow !== null && y === layout.metaRow) return { zone: "meta" };
|
|
1100
|
+
if (y === layout.footerRow) return { zone: "footer" };
|
|
1101
|
+
return {
|
|
1102
|
+
zone: "body",
|
|
1103
|
+
bodyX: Math.max(0, x - 1),
|
|
1104
|
+
bodyY: y - layout.bodyStartRow,
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function padToInner(line: string, innerWidth: number): string {
|
|
1109
|
+
const clipped = fitLine(line, innerWidth);
|
|
1110
|
+
const pad = Math.max(0, innerWidth - visibleWidth(stripTerminalSequences(clipped)));
|
|
1111
|
+
return clipped + " ".repeat(pad);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function paintModalFrame(input: {
|
|
1115
|
+
layout: ModalLayout;
|
|
1116
|
+
theme: LiveOverlayTheme | undefined;
|
|
1117
|
+
titleInner: string;
|
|
1118
|
+
bodyLines: readonly string[];
|
|
1119
|
+
/** Fixed meta band below the body; omitted when layout.showMeta is false. */
|
|
1120
|
+
metaInner?: string;
|
|
1121
|
+
footerHint: string;
|
|
1122
|
+
}): string[] {
|
|
1123
|
+
const { layout, theme, titleInner, bodyLines, metaInner, footerHint } = input;
|
|
1124
|
+
const dim = (s: string) => style(theme, "dim", s);
|
|
1125
|
+
const accent = (s: string) => style(theme, "accent", s);
|
|
1126
|
+
const lines: string[] = [];
|
|
1127
|
+
// #193: the component paints the frame rect only — no side/top/bottom mask.
|
|
1128
|
+
// Every cell outside it belongs to the host, which already painted the chat or
|
|
1129
|
+
// the /legion view; painting spaces here would wipe it (the composite has no
|
|
1130
|
+
// transparency) and render as the terminal's default background.
|
|
1131
|
+
const top = accent("┏") + padToInner(titleInner, layout.innerWidth) + accent("┓");
|
|
1132
|
+
const bottom = accent("┗") +
|
|
1133
|
+
padToInner(dim(` ${footerHint} `), layout.innerWidth) +
|
|
1134
|
+
accent("┛");
|
|
1135
|
+
|
|
1136
|
+
lines.push(top);
|
|
1137
|
+
for (let i = 0; i < layout.bodyRows; i++) {
|
|
1138
|
+
const body = padToInner(bodyLines[i] ?? "", layout.innerWidth);
|
|
1139
|
+
lines.push(accent("┃") + body + accent("┃"));
|
|
1140
|
+
}
|
|
1141
|
+
if (layout.showMeta) {
|
|
1142
|
+
const meta = padToInner(metaInner ?? "", layout.innerWidth);
|
|
1143
|
+
lines.push(accent("┃") + meta + accent("┃"));
|
|
1144
|
+
}
|
|
1145
|
+
lines.push(bottom);
|
|
1146
|
+
return lines;
|
|
1147
|
+
}
|
package/src/tui/status.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { railwayLines, type RailScroll } from "./viz/railway.ts";
|
|
|
22
22
|
import { IDLE_STOP_HINT, IDLE_STOP_HINT_SHORT } from "../extension/legion-stop.ts";
|
|
23
23
|
|
|
24
24
|
export const STATUS_WIDGET_KEY = "pi-legion-status";
|
|
25
|
+
/** @deprecated Footer segment removed (#192); kept for API soft-land. Prefer the aboveEditor widget. */
|
|
25
26
|
export const STATUS_FOOTER_KEY = "pi-legion";
|
|
26
27
|
export const STATUS_WIDGET_PLACEMENT = "aboveEditor" as const;
|
|
27
28
|
|
|
@@ -47,6 +48,11 @@ export interface StatusEntry {
|
|
|
47
48
|
currentTurnStartedAt?: number;
|
|
48
49
|
/** Unix ms the current turn settled. Absent while the turn is still in flight. */
|
|
49
50
|
currentTurnEndedAt?: number;
|
|
51
|
+
/**
|
|
52
|
+
* Unix ms of the agent's last visible activity (edit / reply / status or
|
|
53
|
+
* output refresh). Not the run wall-clock start.
|
|
54
|
+
*/
|
|
55
|
+
lastActivityAt?: number;
|
|
50
56
|
/** Cumulative tokens (input+cache+output); from session jsonl (fresh) or ledger usage (≤30s stale). */
|
|
51
57
|
tokensTotal?: number;
|
|
52
58
|
/** Cumulative output tokens only; numerator for tok/s / t/s. */
|
|
@@ -100,6 +106,21 @@ export function formatElapsed(ms: number): string {
|
|
|
100
106
|
return formatElapsedParts(ms, false);
|
|
101
107
|
}
|
|
102
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Relative age for railway "last activity" (`3s ago` / `2m ago` / `1h ago`).
|
|
111
|
+
* Missing timestamp → `—` (placeholder; see #159).
|
|
112
|
+
*/
|
|
113
|
+
export function formatActivityAgo(lastActivityAt: number | undefined, now: number): string {
|
|
114
|
+
if (lastActivityAt === undefined || !Number.isFinite(lastActivityAt)) return "—";
|
|
115
|
+
const secs = Math.max(0, Math.floor((now - lastActivityAt) / 1000));
|
|
116
|
+
if (secs < 60) return `${secs}s ago`;
|
|
117
|
+
const mins = Math.floor(secs / 60);
|
|
118
|
+
if (mins < 60) return `${mins}m ago`;
|
|
119
|
+
const hours = Math.floor(mins / 60);
|
|
120
|
+
if (hours < 48) return `${hours}h ago`;
|
|
121
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
122
|
+
}
|
|
123
|
+
|
|
103
124
|
/** Compact token count: 950 -> "950", 1200 -> "1.2K", 2500000 -> "2.5M". */
|
|
104
125
|
function formatTokensCompact(value: number): string {
|
|
105
126
|
if (!Number.isFinite(value) || value <= 0) return "0";
|
|
@@ -111,28 +132,25 @@ function formatTokensCompact(value: number): string {
|
|
|
111
132
|
}
|
|
112
133
|
|
|
113
134
|
/**
|
|
114
|
-
* Elapsed
|
|
135
|
+
* Elapsed since the most recent task (turn) started, shown next to the
|
|
136
|
+
* lifetime elapsed.
|
|
115
137
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
138
|
+
* Ticks with `now` for as long as the agent itself is live — including the
|
|
139
|
+
* quiet gap after a turn settles and before the next message arrives, which
|
|
140
|
+
* is part of "time since the last task started". It freezes only when the
|
|
141
|
+
* agent ends: a terminal entry clamps at `finishedAt` so the clock does not
|
|
142
|
+
* run on after the agent exits. Undefined when the turn start is unknown, or
|
|
143
|
+
* when the entry is neither active nor terminal (an idle seat has no
|
|
144
|
+
* meaningful running clock).
|
|
121
145
|
*/
|
|
122
146
|
function currentTurnElapsedMs(
|
|
123
|
-
entry: Pick<StatusEntry, "state" | "herdrStatus" | "finishedAt" | "currentTurnStartedAt"
|
|
147
|
+
entry: Pick<StatusEntry, "state" | "herdrStatus" | "finishedAt" | "currentTurnStartedAt">,
|
|
124
148
|
now: number,
|
|
125
149
|
): number | undefined {
|
|
126
150
|
if (entry.currentTurnStartedAt === undefined) return undefined;
|
|
127
151
|
const start = entry.currentTurnStartedAt;
|
|
128
|
-
if (entry.currentTurnEndedAt !== undefined) {
|
|
129
|
-
return Math.max(0, entry.currentTurnEndedAt - start);
|
|
130
|
-
}
|
|
131
152
|
const status = entry.herdrStatus ?? entry.state;
|
|
132
|
-
if (TERMINAL_STATES.has(status))
|
|
133
|
-
if (entry.finishedAt === undefined) return undefined;
|
|
134
|
-
return Math.max(0, entry.finishedAt - start);
|
|
135
|
-
}
|
|
153
|
+
if (TERMINAL_STATES.has(status)) return Math.max(0, (entry.finishedAt ?? now) - start);
|
|
136
154
|
if (!ACTIVE_STATES.has(status)) return undefined;
|
|
137
155
|
return Math.max(0, now - start);
|
|
138
156
|
}
|
|
@@ -147,20 +165,38 @@ function lifetimeElapsedMs(
|
|
|
147
165
|
}
|
|
148
166
|
|
|
149
167
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
168
|
+
* How close `currentTurnStartedAt` may sit to `startedAt` and still count as
|
|
169
|
+
* the same event (round 1). Only a fallback for entries that report no turn
|
|
170
|
+
* count: the turn start comes from the session's first user message while
|
|
171
|
+
* `startedAt` is the process spawn, so they differ by the spawn handshake
|
|
172
|
+
* (~1s) even on round 1.
|
|
173
|
+
*/
|
|
174
|
+
export const SAME_TURN_START_MS = 5_000;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Headline clock: `12m12s / 1h03m` (since last task start / lifetime).
|
|
178
|
+
*
|
|
179
|
+
* Lifetime only when the leading clock adds nothing: either this turn has no
|
|
180
|
+
* timing yet, or it is still round 1 — that turn started with the agent, so
|
|
181
|
+
* the two clocks are one fact and `16s / 17s` is just the spawn gap.
|
|
152
182
|
*/
|
|
153
183
|
export function formatAgentElapsed(
|
|
154
|
-
entry: Pick<StatusEntry, "state" | "herdrStatus" | "startedAt" | "finishedAt" | "currentTurnStartedAt" | "
|
|
184
|
+
entry: Pick<StatusEntry, "state" | "herdrStatus" | "startedAt" | "finishedAt" | "currentTurnStartedAt" | "turns">,
|
|
155
185
|
now: number,
|
|
156
186
|
): string {
|
|
157
187
|
const total = formatElapsed(lifetimeElapsedMs(entry, now));
|
|
158
188
|
const turnElapsed = currentTurnElapsedMs(entry, now);
|
|
159
189
|
if (turnElapsed === undefined) return total;
|
|
190
|
+
if (entry.turns !== undefined) {
|
|
191
|
+
if (entry.turns <= 1) return total;
|
|
192
|
+
} else if (entry.currentTurnStartedAt !== undefined
|
|
193
|
+
&& Math.abs(entry.currentTurnStartedAt - entry.startedAt) <= SAME_TURN_START_MS) {
|
|
194
|
+
return total;
|
|
195
|
+
}
|
|
160
196
|
return `${formatElapsedParts(turnElapsed, true)} / ${total}`;
|
|
161
197
|
}
|
|
162
198
|
|
|
163
|
-
function activeEntries(entries: StatusEntry[]): StatusEntry[] {
|
|
199
|
+
export function activeEntries(entries: StatusEntry[]): StatusEntry[] {
|
|
164
200
|
return entries.filter((entry) =>
|
|
165
201
|
entry.parentId !== null && entry.depth !== 0 && ACTIVE_STATES.has(entry.herdrStatus ?? entry.state),
|
|
166
202
|
);
|
|
@@ -169,7 +205,7 @@ function activeEntries(entries: StatusEntry[]): StatusEntry[] {
|
|
|
169
205
|
/**
|
|
170
206
|
* Panel-visibility gate for the status board: with any Legion-identity
|
|
171
207
|
* entries present, the board mirrors the live-tree filter (completed
|
|
172
|
-
* branches age out;
|
|
208
|
+
* branches age out; busy counts stay consistent with the widget).
|
|
173
209
|
* Legacy flat rosters keep the historical always-shown behavior.
|
|
174
210
|
*/
|
|
175
211
|
function hasTreeIdentity(entries: StatusEntry[]): boolean {
|
|
@@ -182,11 +218,15 @@ export function visiblePanelEntries(entries: StatusEntry[]): StatusEntry[] {
|
|
|
182
218
|
);
|
|
183
219
|
if (!hasTreeIdentity) return entries;
|
|
184
220
|
const live = filterLiveTree(entries);
|
|
185
|
-
//
|
|
221
|
+
// Busy labels key off activeEntries(); keep the ledger root row out
|
|
186
222
|
// of the visible set entirely (it is not an agent).
|
|
187
223
|
return live.filter((entry) => entry.nodeId !== "root");
|
|
188
224
|
}
|
|
189
225
|
|
|
226
|
+
/**
|
|
227
|
+
* @deprecated Footer `N agents running` removed (#192); the aboveEditor widget
|
|
228
|
+
* already shows the same count. Kept for API soft-land — do not call from new code.
|
|
229
|
+
*/
|
|
190
230
|
export function formatFooterStatus(entries: StatusEntry[]): string | undefined {
|
|
191
231
|
const active = activeEntries(entries);
|
|
192
232
|
if (active.length === 0) return undefined;
|
|
@@ -203,7 +243,7 @@ export function formatBusyLabel(entries: StatusEntry[]): string | undefined {
|
|
|
203
243
|
names.length > 0
|
|
204
244
|
? ` (${names.slice(0, 3).join(", ")}${names.length > 3 ? ", …" : ""})`
|
|
205
245
|
: "";
|
|
206
|
-
return
|
|
246
|
+
return `${active.length} subagent${active.length === 1 ? "" : "s"}${who}`;
|
|
207
247
|
}
|
|
208
248
|
|
|
209
249
|
function roleSuffix(entry: StatusEntry): string {
|
|
@@ -247,7 +287,7 @@ function headlineBits(entry: StatusEntry, now: number): string[] {
|
|
|
247
287
|
return bits;
|
|
248
288
|
}
|
|
249
289
|
|
|
250
|
-
function detailBits(entry: StatusEntry
|
|
290
|
+
function detailBits(entry: StatusEntry): string[] {
|
|
251
291
|
const status = entryStatus(entry);
|
|
252
292
|
const bits = [entry.phase ? `${entry.phase}/${status}` : status];
|
|
253
293
|
if (entry.turns && entry.turns > 0) bits.push(`turn ${entry.turns}`);
|
|
@@ -257,9 +297,9 @@ function detailBits(entry: StatusEntry, now: number): string[] {
|
|
|
257
297
|
if (entry.worktreeBranch) {
|
|
258
298
|
bits.push(`wt ${formatWorktreeBranch(entry.worktreeBranch)}`);
|
|
259
299
|
}
|
|
260
|
-
//
|
|
300
|
+
// Lifetime avg output tok/s over generationMs (not the header 60s window / live-only nowRate).
|
|
261
301
|
if (entry.tokensOut && entry.tokensOut > 0 && entry.generationMs && entry.generationMs >= 1_000) {
|
|
262
|
-
bits.push(`${formatTokensCompact(Math.round(entry.tokensOut / (entry.generationMs / 1_000)))} t/s`);
|
|
302
|
+
bits.push(`${formatTokensCompact(Math.round(entry.tokensOut / (entry.generationMs / 1_000)))} avg t/s`);
|
|
263
303
|
}
|
|
264
304
|
return bits;
|
|
265
305
|
}
|
|
@@ -271,7 +311,7 @@ function formatEntryLines(
|
|
|
271
311
|
): string[] {
|
|
272
312
|
return [
|
|
273
313
|
`${prefix.head}● ${headlineBits(entry, now).join(" · ")}`,
|
|
274
|
-
`${prefix.detail} ⎿ ${detailBits(entry
|
|
314
|
+
`${prefix.detail} ⎿ ${detailBits(entry).join(" · ")}`,
|
|
275
315
|
];
|
|
276
316
|
}
|
|
277
317
|
|
|
@@ -476,7 +516,7 @@ function renderTreeWidgetLines(
|
|
|
476
516
|
lines.push(widgetRow(`${branch}● ${headline.join(" · ")}`, entry.nodeId));
|
|
477
517
|
const detailPrefix = `${ancestors}${isLast ? " " : "│ "}`;
|
|
478
518
|
lines.push(widgetRow(
|
|
479
|
-
`${detailPrefix} ⎿ ${detailBits(effectiveEntry
|
|
519
|
+
`${detailPrefix} ⎿ ${detailBits(effectiveEntry).map(cleanDisplayText).join(" · ")}`,
|
|
480
520
|
entry.nodeId,
|
|
481
521
|
));
|
|
482
522
|
const childPrefix = `${ancestors}${isLast ? " " : "│ "}`;
|
|
@@ -532,7 +572,7 @@ type Theme = {
|
|
|
532
572
|
fg(color: string, text: string): string;
|
|
533
573
|
};
|
|
534
574
|
|
|
535
|
-
type WidgetTui = { requestRender(): void };
|
|
575
|
+
export type WidgetTui = { requestRender(): void };
|
|
536
576
|
|
|
537
577
|
export type WidgetFactory = (
|
|
538
578
|
tui: WidgetTui,
|
|
@@ -598,18 +638,18 @@ export interface StatusVizSnapshot {
|
|
|
598
638
|
nodes: VizNode[];
|
|
599
639
|
sampler: RateSampler;
|
|
600
640
|
now: number;
|
|
601
|
-
/** Header
|
|
641
|
+
/** Header tok/s (60s window, live nodes only; 0 when none running). */
|
|
602
642
|
rate: number;
|
|
603
643
|
}
|
|
604
644
|
|
|
605
645
|
export interface StatusBoard {
|
|
606
646
|
bind(ctx: StatusUi): void;
|
|
607
|
-
/** Live data for the /legion view (same sampler
|
|
647
|
+
/** Live data for the /legion view (same sampler; rate is header nowRate). */
|
|
608
648
|
vizSnapshot?(now?: number): StatusVizSnapshot;
|
|
609
649
|
paint(entries: StatusEntry[], now: number): void;
|
|
610
650
|
clear(): void;
|
|
611
651
|
/** The TUI the widget is mounted in, when registered (for focus/overlay checks). */
|
|
612
|
-
getTui?():
|
|
652
|
+
getTui?(): WidgetTui | undefined;
|
|
613
653
|
}
|
|
614
654
|
|
|
615
655
|
/**
|
|
@@ -704,17 +744,14 @@ export function createStatusBoard(options: {
|
|
|
704
744
|
if (registered && ui) {
|
|
705
745
|
safe(() => ui!.setWidget(STATUS_WIDGET_KEY, undefined));
|
|
706
746
|
}
|
|
707
|
-
if (ui) safe(() => ui!.setStatus(STATUS_FOOTER_KEY, undefined));
|
|
708
747
|
registered = false;
|
|
709
748
|
tui = undefined;
|
|
710
749
|
};
|
|
711
750
|
|
|
712
751
|
const paint = (): void => {
|
|
713
752
|
if (!ctx || !liveHasUi(ctx)) return;
|
|
714
|
-
//
|
|
715
|
-
// stay consistent ("N agents running" refers to visible live agents).
|
|
753
|
+
// Widget uses the live-tree visibility filter (completed branches age out).
|
|
716
754
|
const visible = visiblePanelEntries(entries);
|
|
717
|
-
safe(() => ctx!.ui.setStatus(STATUS_FOOTER_KEY, formatFooterStatus(visible)));
|
|
718
755
|
if (visible.length === 0) {
|
|
719
756
|
dropWidget();
|
|
720
757
|
return;
|
|
@@ -832,7 +869,6 @@ export function applyStatus(
|
|
|
832
869
|
themed.length ? themed : undefined,
|
|
833
870
|
{ placement: STATUS_WIDGET_PLACEMENT },
|
|
834
871
|
);
|
|
835
|
-
ctx.ui.setStatus(STATUS_FOOTER_KEY, formatFooterStatus(entries));
|
|
836
872
|
} catch (error) {
|
|
837
873
|
if (!isStaleUiError(error)) throw error;
|
|
838
874
|
}
|