@yaag/tui 0.8.3 → 0.9.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/tree-glyphs.ts +15 -0
- package/src/tree/tree-model.ts +39 -17
- package/src/tree/tree-render.ts +19 -4
- package/src/tree/tree-rows.ts +59 -9
- package/src/tree/tree-state.ts +48 -3
- package/src/view/index.ts +6 -0
- package/src/view/live-ticker.ts +55 -0
- package/src/view/run-tree-view.ts +28 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yaag/tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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.9.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/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 " ";
|
package/src/tree/tree-model.ts
CHANGED
|
@@ -3,10 +3,12 @@ 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";
|
|
10
12
|
import { graftNestedNodes } from "./tree-nested.ts";
|
|
11
13
|
import type { TreeNode } from "./tree-node.ts";
|
|
12
14
|
import type { TreeState } from "./tree-state.ts";
|
|
@@ -39,7 +41,12 @@ function agentNode(state: TreeState, name: string, agent: AgentInfo, now: number
|
|
|
39
41
|
const nested = graftNestedNodes(name, agent.nodes, now);
|
|
40
42
|
const gist = agent.activity === null ? null : activityText(agent.activity);
|
|
41
43
|
const children: TreeNode[] = ledger.rows.map((row) =>
|
|
42
|
-
askNode(
|
|
44
|
+
askNode(row, nested.get(row.index) ?? [], {
|
|
45
|
+
agent: name,
|
|
46
|
+
now,
|
|
47
|
+
activityGist: gist,
|
|
48
|
+
agentModel: agent.model,
|
|
49
|
+
}),
|
|
43
50
|
);
|
|
44
51
|
const finished = agent.finishedNodesPruned + ledger.settledPruned;
|
|
45
52
|
if (finished > 0) children.push(rollup(`${name}#pruned`, `and ${finished} more finished`));
|
|
@@ -48,23 +55,32 @@ function agentNode(state: TreeState, name: string, agent: AgentInfo, now: number
|
|
|
48
55
|
kind: "agent",
|
|
49
56
|
label: sanitizeTerminalLine(name),
|
|
50
57
|
state: agentState(agent),
|
|
51
|
-
facts: agentFacts(
|
|
58
|
+
facts: agentFacts(
|
|
59
|
+
agent,
|
|
60
|
+
state.settledAsks(name),
|
|
61
|
+
agentTimers({ agent, ledger, spawnedAt: state.spawnedAt(name), now }),
|
|
62
|
+
),
|
|
52
63
|
children,
|
|
53
64
|
activityGist: gist,
|
|
54
65
|
startedAt: agent.stateChangedAt,
|
|
55
66
|
};
|
|
56
67
|
}
|
|
57
68
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
/** Render-time inputs one Ask row needs beyond the row itself. */
|
|
70
|
+
interface AskNodeOptions {
|
|
71
|
+
readonly agent: string;
|
|
72
|
+
readonly now: number;
|
|
73
|
+
readonly activityGist: string | null;
|
|
74
|
+
/** Fallback stamp for a live row whose `agent_model` occurrence was dropped. */
|
|
75
|
+
readonly agentModel: string | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function askNode(row: AskRow, children: readonly TreeNode[], options: AskNodeOptions): TreeNode {
|
|
79
|
+
const { agent, now, activityGist } = options;
|
|
65
80
|
const live = row.endedAt === null;
|
|
66
|
-
const
|
|
67
|
-
const facts = [durationText(
|
|
81
|
+
const model = row.model ?? (live ? options.agentModel : null);
|
|
82
|
+
const facts = [durationText(askDwellMs(row, now))];
|
|
83
|
+
if (model !== null) facts.push(modelText(model));
|
|
68
84
|
if (row.replayed) facts.push("replayed");
|
|
69
85
|
return {
|
|
70
86
|
path: `${sanitizeTerminalLine(agent)}:${row.index}`,
|
|
@@ -96,16 +112,22 @@ function agentState(agent: AgentInfo): TreeNode["state"] {
|
|
|
96
112
|
return agent.state === "asking" ? "running" : "idle";
|
|
97
113
|
}
|
|
98
114
|
|
|
99
|
-
|
|
115
|
+
/**
|
|
116
|
+
* The Agent row's facts. The headline fact keeps the Agent's state and both
|
|
117
|
+
* timers in one string, because the compact and inline frames render only the
|
|
118
|
+
* first fact.
|
|
119
|
+
*/
|
|
120
|
+
function agentFacts(agent: AgentInfo, settled: number, timers: AgentTimers): readonly string[] {
|
|
121
|
+
const clocks = `active ${durationText(timers.activeMs)} · idle ${durationText(timers.idleMs)}`;
|
|
100
122
|
const facts: string[] = [];
|
|
101
|
-
if (agent.state === "asking")
|
|
102
|
-
facts.push(`ask #${agent.askIndex + 1} · ${durationText(now - (agent.askStartedAt ?? now))}`);
|
|
123
|
+
if (agent.state === "asking") facts.push(`ask #${agent.askIndex + 1} · ${clocks}`);
|
|
103
124
|
else if (agent.state === "exited")
|
|
104
|
-
facts.push(`exited · ${settled} ask${settled === 1 ? "" : "s"}`);
|
|
105
|
-
else facts.push(
|
|
125
|
+
facts.push(`exited · ${settled} ask${settled === 1 ? "" : "s"} · ${clocks}`);
|
|
126
|
+
else facts.push(clocks);
|
|
106
127
|
facts.push(
|
|
107
128
|
`${costText(agent.cost, agent.incomplete)} · ${tokensText(agent.tokens?.total ?? null)}`,
|
|
108
129
|
);
|
|
130
|
+
if (agent.model !== null) facts.push(modelText(agent.model));
|
|
109
131
|
const last = agent.modelFallbacks.at(-1);
|
|
110
132
|
if (last !== undefined) {
|
|
111
133
|
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,19 @@ 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
|
+
|
|
124
144
|
/** The bounded, sanitized two-line output tail for an Agent's live Ask. */
|
|
125
145
|
outputTail(agent: string): readonly string[] {
|
|
126
146
|
const tail = this.#tails.get(agent);
|
|
@@ -154,19 +174,44 @@ export class TreeState {
|
|
|
154
174
|
|
|
155
175
|
#project(event: LifecycleEvent): void {
|
|
156
176
|
switch (event.type) {
|
|
177
|
+
case "agent_spawn": {
|
|
178
|
+
const seen = this.#spawnedAt.get(event.agent);
|
|
179
|
+
this.#spawnedAt.set(event.agent, seen === undefined ? event.at : Math.min(seen, event.at));
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
157
182
|
case "ask_start":
|
|
158
|
-
this.#ledgers.set(
|
|
183
|
+
this.#ledgers.set(
|
|
184
|
+
event.agent,
|
|
185
|
+
applyAskStart(this.ledger(event.agent), event, this.#modelOf(event.agent)),
|
|
186
|
+
);
|
|
159
187
|
this.#tails.set(event.agent, { index: event.index, text: "" });
|
|
160
188
|
break;
|
|
161
189
|
case "ask_output":
|
|
162
190
|
this.#appendOutput(event);
|
|
163
191
|
break;
|
|
164
192
|
case "ask_end":
|
|
165
|
-
this.#ledgers.set(
|
|
193
|
+
this.#ledgers.set(
|
|
194
|
+
event.agent,
|
|
195
|
+
applyAskEnd(this.ledger(event.agent), event, this.#modelOf(event.agent)),
|
|
196
|
+
);
|
|
197
|
+
break;
|
|
198
|
+
case "agent_model":
|
|
199
|
+
// The event, not the Summary, is authoritative here: the fold skips an
|
|
200
|
+
// exited Agent (ADR-0041), while the ledger restamps live rows only.
|
|
201
|
+
this.#ledgers.set(event.agent, stampLiveModel(this.ledger(event.agent), event.model));
|
|
166
202
|
break;
|
|
167
203
|
}
|
|
168
204
|
}
|
|
169
205
|
|
|
206
|
+
/**
|
|
207
|
+
* The concrete model an Agent runs now. Both `apply()` and `ingest()` settle
|
|
208
|
+
* the Summary before they project, so this read is current for the event
|
|
209
|
+
* being projected.
|
|
210
|
+
*/
|
|
211
|
+
#modelOf(agent: string): string | null {
|
|
212
|
+
return this.#summary.agents[agent]?.model ?? null;
|
|
213
|
+
}
|
|
214
|
+
|
|
170
215
|
#rememberAgent(event: LifecycleEvent): void {
|
|
171
216
|
if ("agent" in event && !this.#order.includes(event.agent)) this.#order.push(event.agent);
|
|
172
217
|
}
|
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,
|
|
@@ -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
|