@yaag/tui 0.8.3 → 0.10.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/package.json +2 -2
- package/src/index.ts +6 -0
- package/src/style/index.ts +11 -0
- package/src/style/styled-line.ts +42 -0
- package/src/style/styler.ts +34 -0
- package/src/text/index.ts +7 -1
- package/src/text/model-text.ts +10 -0
- package/src/text/terminal-text.ts +12 -2
- package/src/tree/agent-timers.ts +62 -0
- package/src/tree/ask-ledger.ts +76 -10
- package/src/tree/details-pane.ts +11 -2
- package/src/tree/index.ts +1 -0
- package/src/tree/tree-agents.ts +21 -0
- package/src/tree/tree-fold.ts +27 -5
- package/src/tree/tree-glyphs.ts +15 -0
- package/src/tree/tree-lineage.ts +40 -0
- package/src/tree/tree-model.ts +70 -21
- package/src/tree/tree-render.ts +19 -4
- package/src/tree/tree-rows.ts +59 -9
- package/src/tree/tree-state.ts +53 -3
- package/src/view/compact-render.ts +9 -6
- package/src/view/index.ts +6 -0
- package/src/view/inline-render.ts +13 -9
- package/src/view/live-ticker.ts +55 -0
- package/src/view/run-tree-view.ts +28 -3
- package/src/view/snapshot-render.ts +32 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yaag/tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -19,6 +19,6 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@earendil-works/pi-tui": "^0.84.0",
|
|
22
|
-
"@yaag/runtime": "0.
|
|
22
|
+
"@yaag/runtime": "0.10.0"
|
|
23
23
|
}
|
|
24
24
|
}
|
package/src/index.ts
CHANGED
|
@@ -52,11 +52,13 @@ export {
|
|
|
52
52
|
scrollBy,
|
|
53
53
|
type TranscriptOverlayOptions,
|
|
54
54
|
} from "./overlay/index.ts";
|
|
55
|
+
export type { BackgroundToken, StyleToken, TreeStyler } from "./style/index.ts";
|
|
55
56
|
export {
|
|
56
57
|
activityText,
|
|
57
58
|
clampToWidth,
|
|
58
59
|
costText,
|
|
59
60
|
durationText,
|
|
61
|
+
modelText,
|
|
60
62
|
sanitizeTerminalLine,
|
|
61
63
|
sanitizeTerminalText,
|
|
62
64
|
tokensText,
|
|
@@ -114,6 +116,8 @@ export {
|
|
|
114
116
|
compactAgentLine,
|
|
115
117
|
createRunTreeView,
|
|
116
118
|
type InlineRenderOptions,
|
|
119
|
+
type LiveTicker,
|
|
120
|
+
type LiveTickerOptions,
|
|
117
121
|
livePhase,
|
|
118
122
|
type RunPhase,
|
|
119
123
|
type RunTreeView,
|
|
@@ -130,4 +134,6 @@ export {
|
|
|
130
134
|
resultText,
|
|
131
135
|
type SnapshotRenderOptions,
|
|
132
136
|
STDERR_TAIL_LINES,
|
|
137
|
+
startLiveTicker,
|
|
138
|
+
type TickerSchedule,
|
|
133
139
|
} from "./view/index.ts";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public surface of the `style/` module: the styling seam of the renderers.
|
|
3
|
+
* Files inside this directory import each other directly.
|
|
4
|
+
*/
|
|
5
|
+
export { renderStyledLine, type Span, type StyledLineOptions } from "./styled-line.ts";
|
|
6
|
+
export {
|
|
7
|
+
type BackgroundToken,
|
|
8
|
+
identityStyler,
|
|
9
|
+
type StyleToken,
|
|
10
|
+
type TreeStyler,
|
|
11
|
+
} from "./styler.ts";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { clampToWidth, columnLimit } from "../text/index.ts";
|
|
3
|
+
import type { BackgroundToken, StyleToken, TreeStyler } from "./styler.ts";
|
|
4
|
+
|
|
5
|
+
/** One run of already sanitized plain text, with the role it draws in. */
|
|
6
|
+
export interface Span {
|
|
7
|
+
readonly text: string;
|
|
8
|
+
readonly token?: StyleToken;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** How one line of spans is clamped and coloured. */
|
|
12
|
+
export interface StyledLineOptions {
|
|
13
|
+
readonly width: number;
|
|
14
|
+
readonly styler: TreeStyler;
|
|
15
|
+
/** Pads the clamped line to `width` and fills it with this background. */
|
|
16
|
+
readonly background?: BackgroundToken;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Joins the spans, clamps the plain result to `width`, then colours each span.
|
|
21
|
+
*
|
|
22
|
+
* Clamping happens before styling, so no escape byte is ever measured as a
|
|
23
|
+
* display column. With {@link identityStyler} and no background the result
|
|
24
|
+
* equals `clampToWidth(spans.map((span) => span.text).join(""), width)` byte
|
|
25
|
+
* for byte.
|
|
26
|
+
*/
|
|
27
|
+
export function renderStyledLine(spans: readonly Span[], options: StyledLineOptions): string {
|
|
28
|
+
const clamped = clampToWidth(spans.map((span) => span.text).join(""), options.width);
|
|
29
|
+
const remainder = [...clamped];
|
|
30
|
+
let cursor = 0;
|
|
31
|
+
let line = "";
|
|
32
|
+
for (const span of spans) {
|
|
33
|
+
const length = [...span.text].length;
|
|
34
|
+
const text = remainder.slice(cursor, cursor + length).join("");
|
|
35
|
+
cursor = Math.min(cursor + length, remainder.length);
|
|
36
|
+
if (text === "") continue;
|
|
37
|
+
line += span.token === undefined ? text : options.styler.fg(span.token, text);
|
|
38
|
+
}
|
|
39
|
+
if (options.background === undefined) return line;
|
|
40
|
+
const pad = Math.max(0, columnLimit(options.width) - visibleWidth(clamped));
|
|
41
|
+
return options.styler.bg(options.background, line + " ".repeat(pad));
|
|
42
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The styling seam of the tree renderer (spec D10).
|
|
3
|
+
*
|
|
4
|
+
* The renderer composes plain text and clamps it; a styler applies colour to
|
|
5
|
+
* the parts afterwards. The tokens named here belong to this package: a host
|
|
6
|
+
* maps them onto its own theme, so `@yaag/tui` depends on no theme library.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A foreground role one span of a rendered line can carry. */
|
|
10
|
+
export type StyleToken = "accent" | "running" | "failed" | "exited" | "idle" | "muted";
|
|
11
|
+
|
|
12
|
+
/** A background role a whole rendered line can carry. */
|
|
13
|
+
export type BackgroundToken = "selected";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Colours a rendered line.
|
|
17
|
+
*
|
|
18
|
+
* Both members must be pure, must keep the visible width of their argument
|
|
19
|
+
* unchanged, and must add no newline. A styler that breaks one of the three
|
|
20
|
+
* breaks the width clamp of every surface that uses it.
|
|
21
|
+
*/
|
|
22
|
+
export interface TreeStyler {
|
|
23
|
+
fg(token: StyleToken, text: string): string;
|
|
24
|
+
bg(token: BackgroundToken, line: string): string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The default styler: it returns the text unchanged, so a surface that passes
|
|
29
|
+
* no styler gets byte-identical plain lines.
|
|
30
|
+
*/
|
|
31
|
+
export const identityStyler: TreeStyler = {
|
|
32
|
+
fg: (_token: StyleToken, text: string): string => text,
|
|
33
|
+
bg: (_token: BackgroundToken, line: string): string => line,
|
|
34
|
+
};
|
package/src/text/index.ts
CHANGED
|
@@ -5,5 +5,11 @@
|
|
|
5
5
|
export { costText, tokensText } from "./accounting-text.ts";
|
|
6
6
|
export { activityText } from "./activity-text.ts";
|
|
7
7
|
export { durationText } from "./duration-text.ts";
|
|
8
|
-
export {
|
|
8
|
+
export { modelText } from "./model-text.ts";
|
|
9
|
+
export {
|
|
10
|
+
clampToWidth,
|
|
11
|
+
columnLimit,
|
|
12
|
+
sanitizeTerminalLine,
|
|
13
|
+
sanitizeTerminalText,
|
|
14
|
+
} from "./terminal-text.ts";
|
|
9
15
|
export { wrapLines } from "./wrap-text.ts";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { sanitizeTerminalLine } from "./terminal-text.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One row fact for a concrete model. The text is the model identity exactly as
|
|
5
|
+
* the Run reported it — never a fallback pattern — so the caller decides only
|
|
6
|
+
* where the fact sits on the row.
|
|
7
|
+
*/
|
|
8
|
+
export function modelText(model: string): string {
|
|
9
|
+
return sanitizeTerminalLine(model);
|
|
10
|
+
}
|
|
@@ -48,7 +48,9 @@ export function sanitizeTerminalLine(value: string): string {
|
|
|
48
48
|
* degrades to one column instead of throwing or returning an unbounded line.
|
|
49
49
|
*
|
|
50
50
|
* pi-tui's `truncateToWidth` is deliberately not used: it appends a colour
|
|
51
|
-
* reset sequence
|
|
51
|
+
* reset sequence. This package composes plain text, clamps it here, and applies
|
|
52
|
+
* style afterwards through the `style/` seam, so no value measured here holds
|
|
53
|
+
* an escape byte.
|
|
52
54
|
*/
|
|
53
55
|
export function clampToWidth(value: string, width: number): string {
|
|
54
56
|
const limit = columnLimit(width);
|
|
@@ -64,7 +66,15 @@ export function clampToWidth(value: string, width: number): string {
|
|
|
64
66
|
return `${text}…`;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
|
|
69
|
+
/**
|
|
70
|
+
* The display columns one line may use at this width: the same rule
|
|
71
|
+
* {@link clampToWidth} cuts by, so a caller that pads a clamped line to the
|
|
72
|
+
* full width measures against one policy, not a copy of it.
|
|
73
|
+
*
|
|
74
|
+
* A width that is not a finite number cannot be measured against, so it
|
|
75
|
+
* degrades to one column.
|
|
76
|
+
*/
|
|
77
|
+
export function columnLimit(width: number): number {
|
|
68
78
|
if (!Number.isFinite(width)) return 1;
|
|
69
79
|
return Math.max(1, Math.floor(width));
|
|
70
80
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { AgentInfo } from "@yaag/runtime";
|
|
2
|
+
import { type AskLedger, askDwellMs } from "./ask-ledger.ts";
|
|
3
|
+
|
|
4
|
+
/** The two clocks an Agent row shows: time spent inside Asks, and the rest of its lifetime. */
|
|
5
|
+
export interface AgentTimers {
|
|
6
|
+
readonly activeMs: number;
|
|
7
|
+
readonly idleMs: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The part of an Agent the split reads: its state, that state's stamp, and its live Ask. */
|
|
11
|
+
export type TimedAgent = Pick<AgentInfo, "state" | "stateChangedAt" | "askIndex" | "askStartedAt">;
|
|
12
|
+
|
|
13
|
+
/** Everything the split needs; `now` keeps the calculation clock-free. */
|
|
14
|
+
export interface AgentTimersInput {
|
|
15
|
+
readonly agent: TimedAgent;
|
|
16
|
+
readonly ledger: AskLedger;
|
|
17
|
+
/** The Agent's spawn instant, or null when neither a spawn event nor a Run start was observed. */
|
|
18
|
+
readonly spawnedAt: number | null;
|
|
19
|
+
readonly now: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Splits an Agent's lifetime into Ask time (`activeMs`) and the remainder
|
|
24
|
+
* (`idleMs`).
|
|
25
|
+
*
|
|
26
|
+
* Both clocks freeze when the Agent exits: an exited Agent measures up to its
|
|
27
|
+
* exit stamp instead of `now`, so repeated renders give the same pair. Two
|
|
28
|
+
* edges do not freeze exactly. An exit that carries no stamp
|
|
29
|
+
* (`stateChangedAt === null`) measures up to `now` and keeps growing, because
|
|
30
|
+
* there is no instant to freeze at. A second, later exit event replaces the
|
|
31
|
+
* stamp in the Summary (`canReplaceExit`), which moves the frozen pair forward
|
|
32
|
+
* once.
|
|
33
|
+
*
|
|
34
|
+
* Idle is clamped at zero, because an Ask settled after the exit stamp, or a
|
|
35
|
+
* missing spawn stamp, can push active past the lifetime.
|
|
36
|
+
*
|
|
37
|
+
* The Ask ledger is the source of the active total. A Summary-only state
|
|
38
|
+
* (`TreeState.fromSummary`, so `yaag_status`, a stored Run in `/yaag`) holds no
|
|
39
|
+
* ledger: there the live Ask still counts, through `askStartedAt`, but the
|
|
40
|
+
* Asks that already settled do not, so the active total of such an Agent is a
|
|
41
|
+
* lower bound and its idle time an over-estimate.
|
|
42
|
+
*/
|
|
43
|
+
export function agentTimers(input: AgentTimersInput): AgentTimers {
|
|
44
|
+
const { agent, ledger, spawnedAt, now } = input;
|
|
45
|
+
const until = agent.state === "exited" ? (agent.stateChangedAt ?? now) : now;
|
|
46
|
+
let activeMs = ledger.prunedActiveMs;
|
|
47
|
+
for (const row of ledger.rows) activeMs += askDwellMs(row, until);
|
|
48
|
+
activeMs += liveAskMs(agent, ledger, until);
|
|
49
|
+
const lifetimeMs = spawnedAt === null ? activeMs : Math.max(0, until - spawnedAt);
|
|
50
|
+
return { activeMs, idleMs: Math.max(0, lifetimeMs - activeMs) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The live Ask the Summary reports but the ledger never saw, so a Summary-only
|
|
55
|
+
* Agent that is asking does not report `active 0s`. Zero when the ledger
|
|
56
|
+
* already holds that Ask, which keeps it counted exactly once.
|
|
57
|
+
*/
|
|
58
|
+
function liveAskMs(agent: TimedAgent, ledger: AskLedger, until: number): number {
|
|
59
|
+
if (agent.state !== "asking" || agent.askStartedAt === null) return 0;
|
|
60
|
+
if (ledger.rows.some((row) => row.index === agent.askIndex)) return 0;
|
|
61
|
+
return Math.max(0, until - agent.askStartedAt);
|
|
62
|
+
}
|
package/src/tree/ask-ledger.ts
CHANGED
|
@@ -15,24 +15,53 @@ export interface AskRow {
|
|
|
15
15
|
readonly endedAt: number | null;
|
|
16
16
|
readonly ok: boolean | null;
|
|
17
17
|
readonly replayed: boolean;
|
|
18
|
+
/** Duration the settlement reported, the fallback when the start was never observed. */
|
|
19
|
+
readonly durationMs: number | null;
|
|
20
|
+
/**
|
|
21
|
+
* The concrete model the Agent ran when the Ask started, restamped by a
|
|
22
|
+
* mid-Ask model swap that landed, and frozen at settlement (ADR-0041).
|
|
23
|
+
*/
|
|
24
|
+
readonly model: string | null;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
/** One Agent's bounded Ask history, oldest settled Asks rolled into a counter. */
|
|
21
28
|
export interface AskLedger {
|
|
22
29
|
readonly rows: readonly AskRow[];
|
|
23
30
|
readonly settledPruned: number;
|
|
31
|
+
/** Elapsed time of the settled rows this ledger dropped, so an Agent's active total survives pruning. */
|
|
32
|
+
readonly prunedActiveMs: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One Ask row's elapsed time. A live row grows until `until`; a settled row
|
|
37
|
+
* measures its own stamps, and falls back to the duration the settlement
|
|
38
|
+
* reported when the observer never saw the Ask start.
|
|
39
|
+
*/
|
|
40
|
+
export function askDwellMs(row: AskRow, until: number): number {
|
|
41
|
+
if (row.endedAt !== null) {
|
|
42
|
+
if (row.startedAt === null) return Math.max(0, row.durationMs ?? 0);
|
|
43
|
+
return Math.max(0, row.endedAt - row.startedAt);
|
|
44
|
+
}
|
|
45
|
+
if (row.startedAt === null) return 0;
|
|
46
|
+
return Math.max(0, until - row.startedAt);
|
|
24
47
|
}
|
|
25
48
|
|
|
26
49
|
/** The ledger of an Agent that has started no Ask. */
|
|
27
50
|
export function emptyLedger(): AskLedger {
|
|
28
|
-
return { rows: [], settledPruned: 0 };
|
|
51
|
+
return { rows: [], settledPruned: 0, prunedActiveMs: 0 };
|
|
29
52
|
}
|
|
30
53
|
|
|
31
54
|
/**
|
|
32
|
-
* Records one Ask start.
|
|
33
|
-
* the
|
|
55
|
+
* Records one Ask start. `model` is the concrete model the Agent runs now; it
|
|
56
|
+
* becomes the new row's stamp. A repeated start for an index already present
|
|
57
|
+
* keeps the first-seen row and does not restamp it, so a redraw cannot
|
|
58
|
+
* duplicate an Ask row or rewrite its model.
|
|
34
59
|
*/
|
|
35
|
-
export function applyAskStart(
|
|
60
|
+
export function applyAskStart(
|
|
61
|
+
ledger: AskLedger,
|
|
62
|
+
event: AskStartEvent,
|
|
63
|
+
model: string | null,
|
|
64
|
+
): AskLedger {
|
|
36
65
|
if (ledger.rows.some((row) => row.index === event.index)) return ledger;
|
|
37
66
|
const row: AskRow = {
|
|
38
67
|
index: event.index,
|
|
@@ -41,15 +70,25 @@ export function applyAskStart(ledger: AskLedger, event: AskStartEvent): AskLedge
|
|
|
41
70
|
endedAt: null,
|
|
42
71
|
ok: null,
|
|
43
72
|
replayed: event.replayed === true,
|
|
73
|
+
durationMs: null,
|
|
74
|
+
model,
|
|
44
75
|
};
|
|
45
|
-
return prune({ rows: [...ledger.rows, row]
|
|
76
|
+
return prune({ ...ledger, rows: [...ledger.rows, row] });
|
|
46
77
|
}
|
|
47
78
|
|
|
48
79
|
/**
|
|
49
80
|
* Records one Ask settlement. A settlement for an index the ledger never saw
|
|
50
81
|
* start still records a row, so a late observer keeps the Ask count honest.
|
|
82
|
+
*
|
|
83
|
+
* `model` stamps that never-observed row alone. A row the ledger already holds
|
|
84
|
+
* keeps its own model, which a landed swap restamped while the row was live,
|
|
85
|
+
* so the settled row states the model that settled it.
|
|
51
86
|
*/
|
|
52
|
-
export function applyAskEnd(
|
|
87
|
+
export function applyAskEnd(
|
|
88
|
+
ledger: AskLedger,
|
|
89
|
+
event: AskEndEvent,
|
|
90
|
+
model: string | null,
|
|
91
|
+
): AskLedger {
|
|
53
92
|
const index = ledger.rows.findIndex((row) => row.index === event.index);
|
|
54
93
|
if (index === -1) {
|
|
55
94
|
const row: AskRow = {
|
|
@@ -59,13 +98,33 @@ export function applyAskEnd(ledger: AskLedger, event: AskEndEvent): AskLedger {
|
|
|
59
98
|
endedAt: event.at,
|
|
60
99
|
ok: event.ok,
|
|
61
100
|
replayed: false,
|
|
101
|
+
durationMs: event.durationMs,
|
|
102
|
+
model,
|
|
62
103
|
};
|
|
63
|
-
return prune({ rows: [...ledger.rows, row]
|
|
104
|
+
return prune({ ...ledger, rows: [...ledger.rows, row] });
|
|
64
105
|
}
|
|
65
106
|
const rows = ledger.rows.map((row, position) =>
|
|
66
|
-
position === index
|
|
107
|
+
position === index
|
|
108
|
+
? { ...row, endedAt: event.at, ok: event.ok, durationMs: event.durationMs }
|
|
109
|
+
: row,
|
|
67
110
|
);
|
|
68
|
-
return prune({
|
|
111
|
+
return prune({ ...ledger, rows });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Restamps every live row of one Agent with the concrete model it runs now.
|
|
116
|
+
* A settled row keeps the model that settled it. The same ledger comes back
|
|
117
|
+
* when no live row changes, so a redraw keeps its identity.
|
|
118
|
+
*/
|
|
119
|
+
export function stampLiveModel(ledger: AskLedger, model: string): AskLedger {
|
|
120
|
+
let changed = false;
|
|
121
|
+
const rows = ledger.rows.map((row) => {
|
|
122
|
+
if (row.endedAt !== null || row.model === model) return row;
|
|
123
|
+
changed = true;
|
|
124
|
+
return { ...row, model };
|
|
125
|
+
});
|
|
126
|
+
if (!changed) return ledger;
|
|
127
|
+
return { ...ledger, rows };
|
|
69
128
|
}
|
|
70
129
|
|
|
71
130
|
function prune(ledger: AskLedger): AskLedger {
|
|
@@ -73,13 +132,20 @@ function prune(ledger: AskLedger): AskLedger {
|
|
|
73
132
|
const excess = ledger.rows.length - AGENT_ASK_LEDGER_MAX;
|
|
74
133
|
const kept: AskRow[] = [];
|
|
75
134
|
let pruned = 0;
|
|
135
|
+
let prunedMs = 0;
|
|
76
136
|
for (const row of ledger.rows) {
|
|
77
137
|
if (row.endedAt !== null && pruned < excess) {
|
|
78
138
|
pruned += 1;
|
|
139
|
+
prunedMs += askDwellMs(row, row.endedAt);
|
|
140
|
+
// A settled row ignores `until`; its own stamps bound it.
|
|
79
141
|
continue;
|
|
80
142
|
}
|
|
81
143
|
kept.push(row);
|
|
82
144
|
}
|
|
83
145
|
if (pruned === 0) return ledger;
|
|
84
|
-
return {
|
|
146
|
+
return {
|
|
147
|
+
rows: kept,
|
|
148
|
+
settledPruned: ledger.settledPruned + pruned,
|
|
149
|
+
prunedActiveMs: ledger.prunedActiveMs + prunedMs,
|
|
150
|
+
};
|
|
85
151
|
}
|
package/src/tree/details-pane.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { identityStyler, renderStyledLine, type TreeStyler } from "../style/index.ts";
|
|
1
2
|
import { clampToWidth, sanitizeTerminalLine } from "../text/index.ts";
|
|
2
|
-
import { stateGlyph } from "./tree-glyphs.ts";
|
|
3
|
+
import { stateGlyph, stateToken } from "./tree-glyphs.ts";
|
|
3
4
|
import type { TreeNode } from "./tree-node.ts";
|
|
4
5
|
|
|
5
6
|
/** The bounded last-output lines the pane draws under the selected node. */
|
|
6
7
|
export interface DetailsPaneOptions {
|
|
7
8
|
readonly width: number;
|
|
8
9
|
readonly outputTail: readonly string[];
|
|
10
|
+
/** Colours the header's state glyph; defaults to identity (spec D10). */
|
|
11
|
+
readonly styler?: TreeStyler;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
/**
|
|
@@ -19,7 +22,13 @@ export function renderDetailsPane(
|
|
|
19
22
|
): readonly string[] {
|
|
20
23
|
if (node === undefined) return [];
|
|
21
24
|
const lines = [
|
|
22
|
-
|
|
25
|
+
renderStyledLine(
|
|
26
|
+
[
|
|
27
|
+
{ text: stateGlyph(node.state), token: stateToken(node.state) },
|
|
28
|
+
{ text: ` ${sanitizeTerminalLine(node.path)}` },
|
|
29
|
+
],
|
|
30
|
+
{ width: options.width, styler: options.styler ?? identityStyler },
|
|
31
|
+
),
|
|
23
32
|
];
|
|
24
33
|
if (node.activityGist !== null)
|
|
25
34
|
lines.push(clampToWidth(` ${sanitizeTerminalLine(node.activityGist)}`, options.width));
|
package/src/tree/index.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Files inside this directory import each other directly.
|
|
4
4
|
*/
|
|
5
5
|
export { AGENT_ASK_LEDGER_MAX, type AskLedger, type AskRow } from "./ask-ledger.ts";
|
|
6
|
+
export { type FlatAgent, flattenAgents } from "./tree-agents.ts";
|
|
6
7
|
export { moveSelection, nodeAt, nodeChain, parentOf, resolveSelection } from "./tree-cursor.ts";
|
|
7
8
|
export {
|
|
8
9
|
emptyFold,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { TreeNode } from "./tree-node.ts";
|
|
2
|
+
|
|
3
|
+
/** One Agent node of a tree and how deep its Parent Link chain runs. */
|
|
4
|
+
export interface FlatAgent {
|
|
5
|
+
readonly agent: TreeNode;
|
|
6
|
+
readonly depth: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Every Agent node of a tree, depth-first, so a frame that draws one line per
|
|
11
|
+
* Agent cannot drop an Agent that a Parent Link nested (ADR-0042).
|
|
12
|
+
*/
|
|
13
|
+
export function flattenAgents(nodes: readonly TreeNode[], depth = 0): readonly FlatAgent[] {
|
|
14
|
+
const flat: FlatAgent[] = [];
|
|
15
|
+
for (const node of nodes) {
|
|
16
|
+
if (node.kind !== "agent") continue;
|
|
17
|
+
flat.push({ agent: node, depth });
|
|
18
|
+
flat.push(...flattenAgents(node.children, depth + 1));
|
|
19
|
+
}
|
|
20
|
+
return flat;
|
|
21
|
+
}
|
package/src/tree/tree-fold.ts
CHANGED
|
@@ -18,11 +18,15 @@ export function emptyFold(): FoldState {
|
|
|
18
18
|
* (spec §1). An Agent with no running Nested Node below it stays collapsed,
|
|
19
19
|
* even when it is settled or asking. A user override for the node's path wins
|
|
20
20
|
* over the default.
|
|
21
|
+
*
|
|
22
|
+
* A child Agent is not covered by this: a collapsed node still draws its
|
|
23
|
+
* agent-kind children, because a Parent Link places an Agent in the tree and
|
|
24
|
+
* must never hide it (ADR-0042). See `visibleRows`.
|
|
21
25
|
*/
|
|
22
26
|
export function isExpanded(node: TreeNode, fold: FoldState): boolean {
|
|
23
27
|
const override = fold.overrides.get(node.path);
|
|
24
28
|
if (override !== undefined) return override;
|
|
25
|
-
if (node.children.
|
|
29
|
+
if (!node.children.some(isFoldable)) return false;
|
|
26
30
|
return leadsToRunningNested(node);
|
|
27
31
|
}
|
|
28
32
|
|
|
@@ -39,8 +43,9 @@ export interface VisibleRow {
|
|
|
39
43
|
/**
|
|
40
44
|
* Flattens the tree into the rows the renderer draws, in depth-first order.
|
|
41
45
|
*
|
|
42
|
-
* A collapsed node contributes its own row
|
|
43
|
-
* `lastAtDepth` flags are a pure function of
|
|
46
|
+
* A collapsed node contributes its own row and its child Agents, which a fold
|
|
47
|
+
* never hides. The row order and the `lastAtDepth` flags are a pure function of
|
|
48
|
+
* the tree and the fold state.
|
|
44
49
|
*/
|
|
45
50
|
export function visibleRows(tree: readonly TreeNode[], fold: FoldState): readonly VisibleRow[] {
|
|
46
51
|
const rows: VisibleRow[] = [];
|
|
@@ -63,15 +68,32 @@ function collect(
|
|
|
63
68
|
node,
|
|
64
69
|
depth,
|
|
65
70
|
expanded,
|
|
66
|
-
|
|
71
|
+
// A child Agent is always drawn, so it is no reason to offer a fold glyph.
|
|
72
|
+
hasChildren: node.children.some(isFoldable),
|
|
67
73
|
lastAtDepth,
|
|
68
74
|
});
|
|
69
|
-
|
|
75
|
+
const drawn = expanded ? node.children : node.children.filter(isAgent);
|
|
76
|
+
collect(drawn, fold, depth + 1, lastAtDepth, rows);
|
|
70
77
|
}
|
|
71
78
|
}
|
|
72
79
|
|
|
80
|
+
function isAgent(node: TreeNode): boolean {
|
|
81
|
+
return node.kind === "agent";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isFoldable(node: TreeNode): boolean {
|
|
85
|
+
return !isAgent(node);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether live nested work sits below this node, inside its own Agent.
|
|
90
|
+
*
|
|
91
|
+
* A child Agent is skipped: its rows are drawn whatever this node's fold says,
|
|
92
|
+
* so its running Nested Nodes are no reason to unfold this Agent's Ask rows.
|
|
93
|
+
*/
|
|
73
94
|
function leadsToRunningNested(node: TreeNode): boolean {
|
|
74
95
|
for (const child of node.children) {
|
|
96
|
+
if (child.kind === "agent") continue;
|
|
75
97
|
if (child.kind === "nested" && child.state === "running") return true;
|
|
76
98
|
if (leadsToRunningNested(child)) return true;
|
|
77
99
|
}
|
package/src/tree/tree-glyphs.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { StyleToken } from "../style/index.ts";
|
|
1
2
|
import type { TreeNodeState } from "./tree-node.ts";
|
|
2
3
|
|
|
3
4
|
/** The glyphs the normative tree mockup uses (`.scratch/run-tree-tui/assets`). */
|
|
@@ -26,6 +27,20 @@ export function stateGlyph(state: TreeNodeState): string {
|
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
/** The style token a row's state glyph and leading state word carry. */
|
|
31
|
+
export function stateToken(state: TreeNodeState): StyleToken {
|
|
32
|
+
switch (state) {
|
|
33
|
+
case "running":
|
|
34
|
+
return "running";
|
|
35
|
+
case "exited":
|
|
36
|
+
return "exited";
|
|
37
|
+
case "failed":
|
|
38
|
+
return "failed";
|
|
39
|
+
case "idle":
|
|
40
|
+
return "idle";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
29
44
|
/** The fold glyph one tree row draws: expanded, collapsed, or nothing for a leaf. */
|
|
30
45
|
export function foldGlyph(hasChildren: boolean, expanded: boolean): string {
|
|
31
46
|
if (!hasChildren) return " ";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Groups Agents by their Parent Link, so the tree can draw a child under its
|
|
3
|
+
* parent.
|
|
4
|
+
*
|
|
5
|
+
* The input is observer data, which may be older, truncated, or hand-edited, so
|
|
6
|
+
* the grouping is defensive: a parent that names an unknown Agent makes its
|
|
7
|
+
* child a root, and so does a parent observed no earlier than its child. That
|
|
8
|
+
* one rule breaks every cycle, because a real Parent Link always names an Agent
|
|
9
|
+
* that spawned first. Order inside every level stays first-observed order.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** One Run's Agent lineage, resolved to roots and child lists. */
|
|
13
|
+
export interface Lineage {
|
|
14
|
+
readonly roots: readonly string[];
|
|
15
|
+
children(name: string): readonly string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function lineageOf(
|
|
19
|
+
order: readonly string[],
|
|
20
|
+
parentOf: (name: string) => string | null,
|
|
21
|
+
): Lineage {
|
|
22
|
+
const position = new Map(order.map((name, index) => [name, index] as const));
|
|
23
|
+
const roots: string[] = [];
|
|
24
|
+
const children = new Map<string, string[]>();
|
|
25
|
+
for (const [index, name] of order.entries()) {
|
|
26
|
+
const parent = parentOf(name);
|
|
27
|
+
const parentIndex = parent === null ? undefined : position.get(parent);
|
|
28
|
+
if (parent === null || parentIndex === undefined || parentIndex >= index) {
|
|
29
|
+
roots.push(name);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const siblings = children.get(parent);
|
|
33
|
+
if (siblings === undefined) children.set(parent, [name]);
|
|
34
|
+
else siblings.push(name);
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
roots,
|
|
38
|
+
children: (name: string): readonly string[] => children.get(name) ?? [],
|
|
39
|
+
};
|
|
40
|
+
}
|
package/src/tree/tree-model.ts
CHANGED
|
@@ -3,10 +3,13 @@ import {
|
|
|
3
3
|
activityText,
|
|
4
4
|
costText,
|
|
5
5
|
durationText,
|
|
6
|
+
modelText,
|
|
6
7
|
sanitizeTerminalLine,
|
|
7
8
|
tokensText,
|
|
8
9
|
} from "../text/index.ts";
|
|
9
|
-
import type
|
|
10
|
+
import { type AgentTimers, agentTimers } from "./agent-timers.ts";
|
|
11
|
+
import { type AskRow, askDwellMs } from "./ask-ledger.ts";
|
|
12
|
+
import { type Lineage, lineageOf } from "./tree-lineage.ts";
|
|
10
13
|
import { graftNestedNodes } from "./tree-nested.ts";
|
|
11
14
|
import type { TreeNode } from "./tree-node.ts";
|
|
12
15
|
import type { TreeState } from "./tree-state.ts";
|
|
@@ -20,27 +23,58 @@ export interface TreeModelOptions {
|
|
|
20
23
|
* Builds the Run's node tree from the folded state.
|
|
21
24
|
*
|
|
22
25
|
* Pure: the same state and options always build the same tree. Agents come in
|
|
23
|
-
* first-observed order,
|
|
26
|
+
* first-observed order, an Agent with a Parent Link sits under its parent, each
|
|
27
|
+
* Agent's children are its Ask rows then its child Agents, and each Ask's
|
|
24
28
|
* children are the Nested Nodes grafted under it by path. Pruned rows become
|
|
25
29
|
* one trailing "and N more finished" roll-up node.
|
|
26
30
|
*/
|
|
27
31
|
export function buildTree(state: TreeState, options: TreeModelOptions): readonly TreeNode[] {
|
|
28
|
-
|
|
32
|
+
// Resolved once, so every later step reads an Agent that is known to exist.
|
|
33
|
+
const known = new Map<string, AgentInfo>();
|
|
29
34
|
for (const name of state.agentOrder) {
|
|
30
35
|
const agent = state.summary.agents[name];
|
|
36
|
+
if (agent !== undefined) known.set(name, agent);
|
|
37
|
+
}
|
|
38
|
+
const lineage = lineageOf([...known.keys()], (name) => state.parentLinkOf(name));
|
|
39
|
+
return subtrees(state, lineage.roots, known, lineage, options.now);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function subtrees(
|
|
43
|
+
state: TreeState,
|
|
44
|
+
names: readonly string[],
|
|
45
|
+
known: ReadonlyMap<string, AgentInfo>,
|
|
46
|
+
lineage: Lineage,
|
|
47
|
+
now: number,
|
|
48
|
+
): readonly TreeNode[] {
|
|
49
|
+
const nodes: TreeNode[] = [];
|
|
50
|
+
for (const name of names) {
|
|
51
|
+
const agent = known.get(name);
|
|
31
52
|
if (agent === undefined) continue;
|
|
32
|
-
|
|
53
|
+
const children = subtrees(state, lineage.children(name), known, lineage, now);
|
|
54
|
+
nodes.push(agentNode(state, name, agent, now, children));
|
|
33
55
|
}
|
|
34
56
|
return nodes;
|
|
35
57
|
}
|
|
36
58
|
|
|
37
|
-
function agentNode(
|
|
59
|
+
function agentNode(
|
|
60
|
+
state: TreeState,
|
|
61
|
+
name: string,
|
|
62
|
+
agent: AgentInfo,
|
|
63
|
+
now: number,
|
|
64
|
+
childAgents: readonly TreeNode[],
|
|
65
|
+
): TreeNode {
|
|
38
66
|
const ledger = state.ledger(name);
|
|
39
67
|
const nested = graftNestedNodes(name, agent.nodes, now);
|
|
40
68
|
const gist = agent.activity === null ? null : activityText(agent.activity);
|
|
41
69
|
const children: TreeNode[] = ledger.rows.map((row) =>
|
|
42
|
-
askNode(
|
|
70
|
+
askNode(row, nested.get(row.index) ?? [], {
|
|
71
|
+
agent: name,
|
|
72
|
+
now,
|
|
73
|
+
activityGist: gist,
|
|
74
|
+
agentModel: agent.model,
|
|
75
|
+
}),
|
|
43
76
|
);
|
|
77
|
+
children.push(...childAgents);
|
|
44
78
|
const finished = agent.finishedNodesPruned + ledger.settledPruned;
|
|
45
79
|
if (finished > 0) children.push(rollup(`${name}#pruned`, `and ${finished} more finished`));
|
|
46
80
|
return {
|
|
@@ -48,23 +82,32 @@ function agentNode(state: TreeState, name: string, agent: AgentInfo, now: number
|
|
|
48
82
|
kind: "agent",
|
|
49
83
|
label: sanitizeTerminalLine(name),
|
|
50
84
|
state: agentState(agent),
|
|
51
|
-
facts: agentFacts(
|
|
85
|
+
facts: agentFacts(
|
|
86
|
+
agent,
|
|
87
|
+
state.settledAsks(name),
|
|
88
|
+
agentTimers({ agent, ledger, spawnedAt: state.spawnedAt(name), now }),
|
|
89
|
+
),
|
|
52
90
|
children,
|
|
53
91
|
activityGist: gist,
|
|
54
92
|
startedAt: agent.stateChangedAt,
|
|
55
93
|
};
|
|
56
94
|
}
|
|
57
95
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
96
|
+
/** Render-time inputs one Ask row needs beyond the row itself. */
|
|
97
|
+
interface AskNodeOptions {
|
|
98
|
+
readonly agent: string;
|
|
99
|
+
readonly now: number;
|
|
100
|
+
readonly activityGist: string | null;
|
|
101
|
+
/** Fallback stamp for a live row whose `agent_model` occurrence was dropped. */
|
|
102
|
+
readonly agentModel: string | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function askNode(row: AskRow, children: readonly TreeNode[], options: AskNodeOptions): TreeNode {
|
|
106
|
+
const { agent, now, activityGist } = options;
|
|
65
107
|
const live = row.endedAt === null;
|
|
66
|
-
const
|
|
67
|
-
const facts = [durationText(
|
|
108
|
+
const model = row.model ?? (live ? options.agentModel : null);
|
|
109
|
+
const facts = [durationText(askDwellMs(row, now))];
|
|
110
|
+
if (model !== null) facts.push(modelText(model));
|
|
68
111
|
if (row.replayed) facts.push("replayed");
|
|
69
112
|
return {
|
|
70
113
|
path: `${sanitizeTerminalLine(agent)}:${row.index}`,
|
|
@@ -96,16 +139,22 @@ function agentState(agent: AgentInfo): TreeNode["state"] {
|
|
|
96
139
|
return agent.state === "asking" ? "running" : "idle";
|
|
97
140
|
}
|
|
98
141
|
|
|
99
|
-
|
|
142
|
+
/**
|
|
143
|
+
* The Agent row's facts. The headline fact keeps the Agent's state and both
|
|
144
|
+
* timers in one string, because the compact and inline frames render only the
|
|
145
|
+
* first fact.
|
|
146
|
+
*/
|
|
147
|
+
function agentFacts(agent: AgentInfo, settled: number, timers: AgentTimers): readonly string[] {
|
|
148
|
+
const clocks = `active ${durationText(timers.activeMs)} · idle ${durationText(timers.idleMs)}`;
|
|
100
149
|
const facts: string[] = [];
|
|
101
|
-
if (agent.state === "asking")
|
|
102
|
-
facts.push(`ask #${agent.askIndex + 1} · ${durationText(now - (agent.askStartedAt ?? now))}`);
|
|
150
|
+
if (agent.state === "asking") facts.push(`ask #${agent.askIndex + 1} · ${clocks}`);
|
|
103
151
|
else if (agent.state === "exited")
|
|
104
|
-
facts.push(`exited · ${settled} ask${settled === 1 ? "" : "s"}`);
|
|
105
|
-
else facts.push(
|
|
152
|
+
facts.push(`exited · ${settled} ask${settled === 1 ? "" : "s"} · ${clocks}`);
|
|
153
|
+
else facts.push(clocks);
|
|
106
154
|
facts.push(
|
|
107
155
|
`${costText(agent.cost, agent.incomplete)} · ${tokensText(agent.tokens?.total ?? null)}`,
|
|
108
156
|
);
|
|
157
|
+
if (agent.model !== null) facts.push(modelText(agent.model));
|
|
109
158
|
const last = agent.modelFallbacks.at(-1);
|
|
110
159
|
if (last !== undefined) {
|
|
111
160
|
facts.push(sanitizeTerminalLine(`fallback · ${last.failedModel} → ${last.resolvedModel}`));
|
package/src/tree/tree-render.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { agentOfPath } from "../node/index.ts";
|
|
2
|
+
import type { TreeStyler } from "../style/index.ts";
|
|
2
3
|
import { clampToWidth, costText, durationText, sanitizeTerminalLine } from "../text/index.ts";
|
|
3
4
|
import { renderDetailsPane } from "./details-pane.ts";
|
|
4
5
|
import { emptyFold, type FoldState, type VisibleRow, visibleRows } from "./tree-fold.ts";
|
|
@@ -30,17 +31,26 @@ export interface TreeRenderOptions {
|
|
|
30
31
|
* It defaults to `true`.
|
|
31
32
|
*/
|
|
32
33
|
readonly interactive?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Colours the tree. Defaults to identity, so a surface that passes none —
|
|
36
|
+
* the transcript component, the snapshot, the CLI — gets byte-identical
|
|
37
|
+
* plain lines. A styler also turns on the selected row's full-width
|
|
38
|
+
* background fill: without a background colour the fill is only trailing
|
|
39
|
+
* whitespace (spec D10).
|
|
40
|
+
*/
|
|
41
|
+
readonly styler?: TreeStyler;
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
/**
|
|
36
45
|
* Renders the four-region frame — header, tree, details pane, key footer — plus
|
|
37
|
-
* the Result of a settled Run,
|
|
38
|
-
*
|
|
46
|
+
* the Result of a settled Run, every line truncated to `width` as plain text
|
|
47
|
+
* and coloured afterwards through the optional styler seam. `interactive: false` drops the regions a surface that takes no
|
|
39
48
|
* input has no use for. A tree that has rows keeps the rule above it and the
|
|
40
49
|
* rule below it; the Result is then the last region, and it gets no rule below
|
|
41
50
|
* it.
|
|
42
51
|
*
|
|
43
|
-
* Pure over the state: two calls with the same
|
|
52
|
+
* Pure over the state: a styler is a pure pair, so two calls with the same
|
|
53
|
+
* state, options and styler return equal
|
|
44
54
|
* arrays. Every untrusted string passes `sanitizeTerminalLine` first, so a
|
|
45
55
|
* hostile gist or path can neither add a line nor emit an escape byte.
|
|
46
56
|
*/
|
|
@@ -57,6 +67,7 @@ export function renderTree(state: TreeState, options: TreeRenderOptions): readon
|
|
|
57
67
|
const details = renderDetailsPane(selected, {
|
|
58
68
|
width: options.width,
|
|
59
69
|
outputTail: selected === undefined ? [] : state.outputTail(agentOfPath(selected.path)),
|
|
70
|
+
...(options.styler === undefined ? {} : { styler: options.styler }),
|
|
60
71
|
});
|
|
61
72
|
if (details.length > 0) lines.push(...details, rule);
|
|
62
73
|
const result = options.result ?? state.result;
|
|
@@ -75,7 +86,11 @@ export function renderTree(state: TreeState, options: TreeRenderOptions): readon
|
|
|
75
86
|
function treeLines(rows: readonly VisibleRow[], options: TreeRenderOptions): readonly string[] {
|
|
76
87
|
const shown = rows.slice(0, MAX_TREE_ROWS);
|
|
77
88
|
const lines = shown.flatMap((row) =>
|
|
78
|
-
renderRow(row, {
|
|
89
|
+
renderRow(row, {
|
|
90
|
+
width: options.width,
|
|
91
|
+
...(options.selectedPath === undefined ? {} : { selectedPath: options.selectedPath }),
|
|
92
|
+
...(options.styler === undefined ? {} : { styler: options.styler }),
|
|
93
|
+
}),
|
|
79
94
|
);
|
|
80
95
|
const hidden = rows.length - shown.length;
|
|
81
96
|
if (hidden > 0) lines.push(clampToWidth(` and ${hidden} more running`, options.width));
|
package/src/tree/tree-rows.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { identityStyler, renderStyledLine, type Span, type TreeStyler } from "../style/index.ts";
|
|
2
|
+
import { sanitizeTerminalLine } from "../text/index.ts";
|
|
2
3
|
import type { VisibleRow } from "./tree-fold.ts";
|
|
3
|
-
import { foldGlyph, GLYPHS, stateGlyph } from "./tree-glyphs.ts";
|
|
4
|
+
import { foldGlyph, GLYPHS, stateGlyph, stateToken } from "./tree-glyphs.ts";
|
|
4
5
|
|
|
5
6
|
/** Marks the selected row in the drawn tree, matched by node path. */
|
|
6
7
|
export interface TreeRowOptions {
|
|
7
8
|
readonly width: number;
|
|
8
9
|
readonly selectedPath?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Colours the row. Without one the row stays plain, and the selected row
|
|
12
|
+
* gets no full-width fill: a fill with no background colour is only
|
|
13
|
+
* trailing whitespace (spec D10).
|
|
14
|
+
*/
|
|
15
|
+
readonly styler?: TreeStyler;
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
/**
|
|
@@ -13,26 +20,69 @@ export interface TreeRowOptions {
|
|
|
13
20
|
* current tool-call gist when the row draws no children of its own.
|
|
14
21
|
*
|
|
15
22
|
* Every untrusted string is sanitized to one line first, so a hostile gist
|
|
16
|
-
* cannot forge an extra row, and the result is truncated to `width
|
|
23
|
+
* cannot forge an extra row, and the result is truncated to `width` as plain
|
|
24
|
+
* text before any colour is applied.
|
|
17
25
|
*/
|
|
18
26
|
export function renderRow(row: VisibleRow, options: TreeRowOptions): readonly string[] {
|
|
19
27
|
const prefix = ancestryPrefix(row);
|
|
20
28
|
const selected = options.selectedPath === row.node.path;
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const lines = [
|
|
29
|
+
const styler = options.styler ?? identityStyler;
|
|
30
|
+
const fill = selected && options.styler !== undefined;
|
|
31
|
+
const lines = [
|
|
32
|
+
renderStyledLine(headSpans(row, prefix, selected), {
|
|
33
|
+
width: options.width,
|
|
34
|
+
styler,
|
|
35
|
+
...(fill ? { background: "selected" as const } : {}),
|
|
36
|
+
}),
|
|
37
|
+
];
|
|
24
38
|
const gist = row.node.activityGist;
|
|
25
39
|
if (gist !== null && !row.expanded) {
|
|
26
40
|
lines.push(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
41
|
+
renderStyledLine(
|
|
42
|
+
[
|
|
43
|
+
{ text: ` ${prefix} ${GLYPHS.branch} ` },
|
|
44
|
+
{ text: GLYPHS.running, token: "running" },
|
|
45
|
+
{ text: ` ${sanitizeTerminalLine(gist)}` },
|
|
46
|
+
],
|
|
47
|
+
{ width: options.width, styler },
|
|
30
48
|
),
|
|
31
49
|
);
|
|
32
50
|
}
|
|
33
51
|
return lines;
|
|
34
52
|
}
|
|
35
53
|
|
|
54
|
+
function headSpans(row: VisibleRow, prefix: string, selected: boolean): readonly Span[] {
|
|
55
|
+
const node = row.node;
|
|
56
|
+
return [
|
|
57
|
+
{ text: `${selected ? "❯" : " "}${prefix}${foldGlyph(row.hasChildren, row.expanded)} ` },
|
|
58
|
+
{ text: stateGlyph(node.state), token: stateToken(node.state) },
|
|
59
|
+
{ text: " " },
|
|
60
|
+
{
|
|
61
|
+
text: sanitizeTerminalLine(node.label),
|
|
62
|
+
...(node.kind === "agent" ? { token: "accent" as const } : {}),
|
|
63
|
+
},
|
|
64
|
+
...factSpans(row),
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The facts tail. Its leading word is the node's own state (`running 22s`,
|
|
70
|
+
* `exited · 2 asks · …`), so that word carries the state colour and the rest
|
|
71
|
+
* of the tail stays plain.
|
|
72
|
+
*/
|
|
73
|
+
function factSpans(row: VisibleRow): readonly Span[] {
|
|
74
|
+
const facts = row.node.facts.map(sanitizeTerminalLine).join(" · ");
|
|
75
|
+
if (facts === "") return [];
|
|
76
|
+
const state = row.node.state;
|
|
77
|
+
if (facts.startsWith(state))
|
|
78
|
+
return [
|
|
79
|
+
{ text: " " },
|
|
80
|
+
{ text: state, token: stateToken(state) },
|
|
81
|
+
{ text: facts.slice(state.length) },
|
|
82
|
+
];
|
|
83
|
+
return [{ text: ` ${facts}` }];
|
|
84
|
+
}
|
|
85
|
+
|
|
36
86
|
function ancestryPrefix(row: VisibleRow): string {
|
|
37
87
|
let prefix = "";
|
|
38
88
|
for (let depth = 0; depth < row.depth; depth += 1) {
|
package/src/tree/tree-state.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { applyEvent, initialSummary, type LifecycleEvent, type RunSummary } from "@yaag/runtime";
|
|
2
2
|
import { sanitizeTerminalText } from "../text/index.ts";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type AskLedger,
|
|
5
|
+
applyAskEnd,
|
|
6
|
+
applyAskStart,
|
|
7
|
+
emptyLedger,
|
|
8
|
+
stampLiveModel,
|
|
9
|
+
} from "./ask-ledger.ts";
|
|
4
10
|
|
|
5
11
|
const MAX_TAIL_CHARS = 2_048;
|
|
6
12
|
const TAIL_LINES = 2;
|
|
@@ -30,6 +36,7 @@ export class TreeState {
|
|
|
30
36
|
readonly #order: string[];
|
|
31
37
|
readonly #ledgers = new Map<string, AskLedger>();
|
|
32
38
|
readonly #tails = new Map<string, OutputTail>();
|
|
39
|
+
readonly #spawnedAt = new Map<string, number>();
|
|
33
40
|
#highestSequence: number | undefined;
|
|
34
41
|
#id: string | undefined;
|
|
35
42
|
#result: string | undefined;
|
|
@@ -121,6 +128,24 @@ export class TreeState {
|
|
|
121
128
|
return Math.max(settled, settledFromSummary(this.#summary.agents[agent]));
|
|
122
129
|
}
|
|
123
130
|
|
|
131
|
+
/**
|
|
132
|
+
* The instant an Agent's lifetime starts: its observed spawn, else the Run's
|
|
133
|
+
* start, because an Agent cannot predate its Run. Null when neither is known.
|
|
134
|
+
*
|
|
135
|
+
* A Summary-only state (a stored Run record, `yaag_status`) holds no spawn
|
|
136
|
+
* event, so a late-spawned Agent falls back to the Run start and its idle
|
|
137
|
+
* time is an over-estimate. Such a state also holds no Ask ledger, so its
|
|
138
|
+
* active total counts the live Ask alone (see `agentTimers`).
|
|
139
|
+
*/
|
|
140
|
+
spawnedAt(agent: string): number | null {
|
|
141
|
+
return this.#spawnedAt.get(agent) ?? this.#summary.startedAt ?? null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The Agent named as this one's parent (Parent Link), or null for a root Agent. */
|
|
145
|
+
parentLinkOf(agent: string): string | null {
|
|
146
|
+
return this.#summary.agents[agent]?.parent ?? null;
|
|
147
|
+
}
|
|
148
|
+
|
|
124
149
|
/** The bounded, sanitized two-line output tail for an Agent's live Ask. */
|
|
125
150
|
outputTail(agent: string): readonly string[] {
|
|
126
151
|
const tail = this.#tails.get(agent);
|
|
@@ -154,19 +179,44 @@ export class TreeState {
|
|
|
154
179
|
|
|
155
180
|
#project(event: LifecycleEvent): void {
|
|
156
181
|
switch (event.type) {
|
|
182
|
+
case "agent_spawn": {
|
|
183
|
+
const seen = this.#spawnedAt.get(event.agent);
|
|
184
|
+
this.#spawnedAt.set(event.agent, seen === undefined ? event.at : Math.min(seen, event.at));
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
157
187
|
case "ask_start":
|
|
158
|
-
this.#ledgers.set(
|
|
188
|
+
this.#ledgers.set(
|
|
189
|
+
event.agent,
|
|
190
|
+
applyAskStart(this.ledger(event.agent), event, this.#modelOf(event.agent)),
|
|
191
|
+
);
|
|
159
192
|
this.#tails.set(event.agent, { index: event.index, text: "" });
|
|
160
193
|
break;
|
|
161
194
|
case "ask_output":
|
|
162
195
|
this.#appendOutput(event);
|
|
163
196
|
break;
|
|
164
197
|
case "ask_end":
|
|
165
|
-
this.#ledgers.set(
|
|
198
|
+
this.#ledgers.set(
|
|
199
|
+
event.agent,
|
|
200
|
+
applyAskEnd(this.ledger(event.agent), event, this.#modelOf(event.agent)),
|
|
201
|
+
);
|
|
202
|
+
break;
|
|
203
|
+
case "agent_model":
|
|
204
|
+
// The event, not the Summary, is authoritative here: the fold skips an
|
|
205
|
+
// exited Agent (ADR-0041), while the ledger restamps live rows only.
|
|
206
|
+
this.#ledgers.set(event.agent, stampLiveModel(this.ledger(event.agent), event.model));
|
|
166
207
|
break;
|
|
167
208
|
}
|
|
168
209
|
}
|
|
169
210
|
|
|
211
|
+
/**
|
|
212
|
+
* The concrete model an Agent runs now. Both `apply()` and `ingest()` settle
|
|
213
|
+
* the Summary before they project, so this read is current for the event
|
|
214
|
+
* being projected.
|
|
215
|
+
*/
|
|
216
|
+
#modelOf(agent: string): string | null {
|
|
217
|
+
return this.#summary.agents[agent]?.model ?? null;
|
|
218
|
+
}
|
|
219
|
+
|
|
170
220
|
#rememberAgent(event: LifecycleEvent): void {
|
|
171
221
|
if ("agent" in event && !this.#order.includes(event.agent)) this.#order.push(event.agent);
|
|
172
222
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { clampToWidth, costText, durationText, sanitizeTerminalLine } from "../text/index.ts";
|
|
2
2
|
import type { TreeNode, TreeState } from "../tree/index.ts";
|
|
3
|
-
import { buildTree, GLYPHS, stateGlyph } from "../tree/index.ts";
|
|
3
|
+
import { buildTree, flattenAgents, GLYPHS, stateGlyph } from "../tree/index.ts";
|
|
4
4
|
|
|
5
5
|
/** Everything the compact background view needs; `now` keeps it clock-free. */
|
|
6
6
|
export interface CompactRenderOptions {
|
|
@@ -38,19 +38,22 @@ export function compactHeaderLine(state: TreeState, options: CompactRenderOption
|
|
|
38
38
|
);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* One line per Agent, with a one-line nested-work gist for each live Agent. A
|
|
43
|
+
* child Agent keeps its own line, indented one level per Parent Link.
|
|
44
|
+
*/
|
|
42
45
|
export function compactAgentLines(
|
|
43
46
|
state: TreeState,
|
|
44
47
|
options: CompactRenderOptions,
|
|
45
48
|
): readonly string[] {
|
|
46
|
-
return buildTree(state, { now: options.now }).map((
|
|
47
|
-
compactAgentLine(agent, options.width),
|
|
49
|
+
return flattenAgents(buildTree(state, { now: options.now })).map((entry) =>
|
|
50
|
+
compactAgentLine(entry.agent, options.width, entry.depth),
|
|
48
51
|
);
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
/** The one Agent line of the compact view, drawn for one Agent node. */
|
|
52
|
-
export function compactAgentLine(agent: TreeNode, width: number): string {
|
|
53
|
-
return clampToWidth(agentLine(agent)
|
|
55
|
+
export function compactAgentLine(agent: TreeNode, width: number, depth = 0): string {
|
|
56
|
+
return clampToWidth(`${" ".repeat(depth)}${agentLine(agent)}`, width);
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
function agentLine(agent: TreeNode): string {
|
package/src/view/index.ts
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export { type CompactRenderOptions, compactAgentLine, renderCompact } from "./compact-render.ts";
|
|
6
6
|
export { type InlineRenderOptions, renderInlineRun } from "./inline-render.ts";
|
|
7
|
+
export {
|
|
8
|
+
type LiveTicker,
|
|
9
|
+
type LiveTickerOptions,
|
|
10
|
+
startLiveTicker,
|
|
11
|
+
type TickerSchedule,
|
|
12
|
+
} from "./live-ticker.ts";
|
|
7
13
|
export {
|
|
8
14
|
createRunTreeView,
|
|
9
15
|
type RunTreeView,
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { clampToWidth } from "../text/index.ts";
|
|
14
14
|
import type { TreeNode, TreeState } from "../tree/index.ts";
|
|
15
|
-
import { buildTree } from "../tree/index.ts";
|
|
15
|
+
import { buildTree, type FlatAgent, flattenAgents } from "../tree/index.ts";
|
|
16
16
|
import { compactAgentLine, compactHeaderLine } from "./compact-render.ts";
|
|
17
17
|
|
|
18
18
|
/** Default line budget; pi truncates a widget past 10 lines. */
|
|
@@ -43,15 +43,19 @@ export function renderInlineRun(state: TreeState, options: InlineRenderOptions):
|
|
|
43
43
|
...(options.label === undefined ? {} : { label: options.label }),
|
|
44
44
|
};
|
|
45
45
|
const header = compactHeaderLine(state, compact);
|
|
46
|
-
|
|
46
|
+
// Flattened first, so a busy child Agent competes for the budget like any other.
|
|
47
|
+
const agents = flattenAgents(buildTree(state, { now: options.now }));
|
|
47
48
|
if (budget === 1) return [header];
|
|
48
49
|
if (agents.length + 1 <= budget)
|
|
49
|
-
return [
|
|
50
|
+
return [
|
|
51
|
+
header,
|
|
52
|
+
...agents.map((entry) => compactAgentLine(entry.agent, options.width, entry.depth)),
|
|
53
|
+
];
|
|
50
54
|
const kept = keepWatched(agents, budget - 2);
|
|
51
55
|
const hidden = agents.length - kept.length;
|
|
52
56
|
return [
|
|
53
57
|
header,
|
|
54
|
-
...kept.map((
|
|
58
|
+
...kept.map((entry) => compactAgentLine(entry.agent, options.width, entry.depth)),
|
|
55
59
|
clampToWidth(` … +${hidden} more`, options.width),
|
|
56
60
|
];
|
|
57
61
|
}
|
|
@@ -64,19 +68,19 @@ export function renderInlineRun(state: TreeState, options: InlineRenderOptions):
|
|
|
64
68
|
* sorts last. Ties keep the first-observed order, so the choice is
|
|
65
69
|
* deterministic for a given state.
|
|
66
70
|
*/
|
|
67
|
-
function keepWatched(agents: readonly
|
|
71
|
+
function keepWatched(agents: readonly FlatAgent[], count: number): readonly FlatAgent[] {
|
|
68
72
|
if (count <= 0) return [];
|
|
69
73
|
const ranked = agents
|
|
70
|
-
.map((
|
|
74
|
+
.map((entry, index) => ({ entry, index }))
|
|
71
75
|
.sort(
|
|
72
76
|
(left, right) =>
|
|
73
|
-
rank(right.agent) - rank(left.agent) ||
|
|
74
|
-
changedAt(right.agent) - changedAt(left.agent) ||
|
|
77
|
+
rank(right.entry.agent) - rank(left.entry.agent) ||
|
|
78
|
+
changedAt(right.entry.agent) - changedAt(left.entry.agent) ||
|
|
75
79
|
left.index - right.index,
|
|
76
80
|
)
|
|
77
81
|
.slice(0, count)
|
|
78
82
|
.sort((left, right) => left.index - right.index);
|
|
79
|
-
return ranked.map((
|
|
83
|
+
return ranked.map((keeper) => keeper.entry);
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
function rank(agent: TreeNode): number {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The redraw seam an interactive surface runs while a Run is live (spec D11).
|
|
3
|
+
*
|
|
4
|
+
* Timers in the tree come from `now` at render time only, so a live Run needs a
|
|
5
|
+
* redraw each second for its times to advance. Nothing here reads the clock,
|
|
6
|
+
* and no timer state enters the tree model.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How often an interactive surface redraws a live Run (spec D11).
|
|
11
|
+
*
|
|
12
|
+
* It stays internal: every surface runs the same second, so no caller chooses
|
|
13
|
+
* an interval.
|
|
14
|
+
*/
|
|
15
|
+
const TICK_MS = 1000;
|
|
16
|
+
|
|
17
|
+
/** Wakes `tick` every `intervalMs` ms; returns the cancel. Tests inject a fake. */
|
|
18
|
+
export type TickerSchedule = (tick: () => void, intervalMs: number) => () => void;
|
|
19
|
+
|
|
20
|
+
/** What a live ticker needs to exist. */
|
|
21
|
+
export interface LiveTickerOptions {
|
|
22
|
+
readonly tick: () => void;
|
|
23
|
+
/** The interval seam; defaults to a real `setInterval`. Tests inject a fake. */
|
|
24
|
+
readonly schedule?: TickerSchedule;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A running ticker; `stop()` is idempotent and no tick lands after it. */
|
|
28
|
+
export interface LiveTicker {
|
|
29
|
+
stop(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A real interval that never keeps the Host Session or the CLI alive. */
|
|
33
|
+
const defaultSchedule: TickerSchedule = (tick, intervalMs) => {
|
|
34
|
+
const timer = setInterval(tick, intervalMs);
|
|
35
|
+
timer.unref?.();
|
|
36
|
+
return () => clearInterval(timer);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Starts a ticker that wakes `tick` until `stop()`. */
|
|
40
|
+
export function startLiveTicker(options: LiveTickerOptions): LiveTicker {
|
|
41
|
+
const schedule = options.schedule ?? defaultSchedule;
|
|
42
|
+
let stopped = false;
|
|
43
|
+
const cancel = schedule(() => {
|
|
44
|
+
// A tick queued in the same turn as `stop()` must draw nothing.
|
|
45
|
+
if (stopped) return;
|
|
46
|
+
options.tick();
|
|
47
|
+
}, TICK_MS);
|
|
48
|
+
return {
|
|
49
|
+
stop(): void {
|
|
50
|
+
if (stopped) return;
|
|
51
|
+
stopped = true;
|
|
52
|
+
cancel();
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -15,15 +15,20 @@
|
|
|
15
15
|
* level, so a Host Session may consume the byte before this component sees
|
|
16
16
|
* it; `ctrl+q` is the gesture to document to users.
|
|
17
17
|
*
|
|
18
|
-
* The view
|
|
19
|
-
* reap-ladder stop, arrives through
|
|
18
|
+
* The view reads no clock into the tree model and owns no I/O of its own: every
|
|
19
|
+
* capability, including the reap-ladder stop, arrives through
|
|
20
|
+
* `RunTreeViewHost`. `now` enters at render time only; while the Run is live a
|
|
21
|
+
* 1-second ticker asks the host for a redraw, so the rendered times advance
|
|
22
|
+
* between fd 3 frames (spec D11). The ticker stops on settle and on dispose.
|
|
20
23
|
*/
|
|
21
24
|
import { DrillController, type DrillHost, routeDrillKey, routeMenuKey } from "../drill/index.ts";
|
|
22
25
|
import { renderStopPrompt, STOP_PROMPT_CHOICES } from "../overlay/index.ts";
|
|
26
|
+
import type { TreeStyler } from "../style/index.ts";
|
|
23
27
|
import { clampToWidth } from "../text/index.ts";
|
|
24
28
|
import { routeSessionKey } from "../transcript/index.ts";
|
|
25
29
|
import type { TreeState } from "../tree/index.ts";
|
|
26
30
|
import { buildTree, renderTree, TreeNavigator } from "../tree/index.ts";
|
|
31
|
+
import { startLiveTicker, type TickerSchedule } from "./live-ticker.ts";
|
|
27
32
|
import { type RunViewResult, resultText } from "./run-view-result.ts";
|
|
28
33
|
import {
|
|
29
34
|
applyRunViewAction,
|
|
@@ -57,6 +62,16 @@ export interface RunTreeViewOptions {
|
|
|
57
62
|
readonly host: RunTreeViewHost;
|
|
58
63
|
/** The Run id, e.g. `r1`; heads the tree. */
|
|
59
64
|
readonly label?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Colours the tree: `/yaag` passes an adapter over pi's `Theme`, the CLI
|
|
67
|
+
* passes none and stays plain (spec D10).
|
|
68
|
+
*/
|
|
69
|
+
readonly styler?: TreeStyler;
|
|
70
|
+
/**
|
|
71
|
+
* The interval seam of the live ticker; defaults to a real 1-second
|
|
72
|
+
* `setInterval`. Tests inject a fake.
|
|
73
|
+
*/
|
|
74
|
+
readonly schedule?: TickerSchedule;
|
|
60
75
|
now?(): number;
|
|
61
76
|
}
|
|
62
77
|
|
|
@@ -101,6 +116,12 @@ export function createRunTreeView(options: RunTreeViewOptions): RunTreeView {
|
|
|
101
116
|
let phase: RunPhase = livePhase();
|
|
102
117
|
let result: RunViewResult | undefined;
|
|
103
118
|
let exited = false;
|
|
119
|
+
// A live Run redraws each second, so its times advance without an event
|
|
120
|
+
// (spec D11); `apply` stops the ticker as soon as the phase settles.
|
|
121
|
+
const ticker = startLiveTicker({
|
|
122
|
+
tick: () => host.requestRender(),
|
|
123
|
+
...(options.schedule === undefined ? {} : { schedule: options.schedule }),
|
|
124
|
+
});
|
|
104
125
|
|
|
105
126
|
const perform = (effect: RunViewEffect): void => {
|
|
106
127
|
switch (effect) {
|
|
@@ -123,6 +144,7 @@ export function createRunTreeView(options: RunTreeViewOptions): RunTreeView {
|
|
|
123
144
|
const transition = applyRunViewAction(phase, action);
|
|
124
145
|
const changed = transition.phase !== phase;
|
|
125
146
|
phase = transition.phase;
|
|
147
|
+
if (phase.kind === "settled") ticker.stop();
|
|
126
148
|
if (transition.effect !== undefined) perform(transition.effect);
|
|
127
149
|
if (changed || transition.effect !== undefined) host.requestRender();
|
|
128
150
|
};
|
|
@@ -159,6 +181,7 @@ export function createRunTreeView(options: RunTreeViewOptions): RunTreeView {
|
|
|
159
181
|
...(options.label === undefined ? {} : { label: options.label }),
|
|
160
182
|
...(navigator.selectedPath === undefined ? {} : { selectedPath: navigator.selectedPath }),
|
|
161
183
|
fold: navigator.fold,
|
|
184
|
+
...(options.styler === undefined ? {} : { styler: options.styler }),
|
|
162
185
|
...(result === undefined ? {} : { result: resultText(result) }),
|
|
163
186
|
}),
|
|
164
187
|
];
|
|
@@ -191,7 +214,9 @@ export function createRunTreeView(options: RunTreeViewOptions): RunTreeView {
|
|
|
191
214
|
drill.handleInput(data);
|
|
192
215
|
},
|
|
193
216
|
invalidate(): void {},
|
|
194
|
-
dispose(): void {
|
|
217
|
+
dispose(): void {
|
|
218
|
+
ticker.stop();
|
|
219
|
+
},
|
|
195
220
|
settle(settled: RunViewResult): void {
|
|
196
221
|
result = settled;
|
|
197
222
|
// `apply` requests the redraw for the phase change; one settlement is one
|
|
@@ -2,7 +2,7 @@ import type { RunSummary } from "@yaag/runtime";
|
|
|
2
2
|
import { renderNodeTable } from "../node/index.ts";
|
|
3
3
|
import { clampToWidth, sanitizeTerminalLine } from "../text/index.ts";
|
|
4
4
|
import type { TreeState } from "../tree/index.ts";
|
|
5
|
-
import { buildTree } from "../tree/index.ts";
|
|
5
|
+
import { buildTree, flattenAgents } from "../tree/index.ts";
|
|
6
6
|
import { compactAgentLine, compactHeaderLine } from "./compact-render.ts";
|
|
7
7
|
|
|
8
8
|
/** Everything the model-facing snapshot needs; `now` keeps it clock-free. */
|
|
@@ -33,13 +33,21 @@ export function renderSnapshot(
|
|
|
33
33
|
const lines: string[] = [compactHeaderLine(state, header)];
|
|
34
34
|
const warning = checkpointLostLine(state.summary, options.width);
|
|
35
35
|
if (warning !== undefined) lines.push(warning);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
lines.push(compactAgentLine(agent, options.width));
|
|
40
|
-
const info = state.summary.agents[
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
// Each Agent line is paired with the Summary by node path, so nesting cannot
|
|
37
|
+
// shift a Nested Node table under the wrong Agent.
|
|
38
|
+
for (const { agent, depth } of flattenAgents(buildTree(state, { now: options.now }))) {
|
|
39
|
+
lines.push(compactAgentLine(agent, options.width, depth));
|
|
40
|
+
const info = state.summary.agents[agent.path];
|
|
41
|
+
// The table is indented with its Agent, so it stays visibly that Agent's.
|
|
42
|
+
// It is drawn into the width the indent leaves, so no line outgrows `width`.
|
|
43
|
+
if (info !== undefined) {
|
|
44
|
+
lines.push(
|
|
45
|
+
...indent(renderNodeTable(info, tableWidth(options.width, depth)), depth).map((line) =>
|
|
46
|
+
clampToWidth(line, options.width),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
43
51
|
if (options.result === undefined) return lines;
|
|
44
52
|
return [
|
|
45
53
|
...lines,
|
|
@@ -60,6 +68,22 @@ function checkpointLostLine(summary: RunSummary, width: number): string | undefi
|
|
|
60
68
|
return clampToWidth(sanitizeTerminalLine(`! checkpoint lost: ${summary.checkpointLost}`), width);
|
|
61
69
|
}
|
|
62
70
|
|
|
71
|
+
/** Columns one indent step takes from a nested Agent's rows. */
|
|
72
|
+
const INDENT = 2;
|
|
73
|
+
|
|
74
|
+
/** The narrowest table a deep nesting may shrink to, so a row keeps its shape. */
|
|
75
|
+
const MIN_TABLE_WIDTH = 8;
|
|
76
|
+
|
|
77
|
+
function tableWidth(width: number, depth: number): number {
|
|
78
|
+
return Math.max(MIN_TABLE_WIDTH, width - INDENT * depth);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function indent(lines: readonly string[], depth: number): readonly string[] {
|
|
82
|
+
if (depth === 0) return lines;
|
|
83
|
+
const prefix = " ".repeat(INDENT * depth);
|
|
84
|
+
return lines.map((line) => (line === "" ? line : `${prefix}${line}`));
|
|
85
|
+
}
|
|
86
|
+
|
|
63
87
|
function labelOf(options: SnapshotRenderOptions): { readonly label?: string } {
|
|
64
88
|
return options.label === undefined ? {} : { label: options.label };
|
|
65
89
|
}
|