@yaag/tui 0.8.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaag/tui",
3
- "version": "0.8.2",
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.8.2"
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 { clampToWidth, sanitizeTerminalLine, sanitizeTerminalText } from "./terminal-text.ts";
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, and this package emits unstyled lines only.
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
- function columnLimit(width: number): number {
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
+ }
@@ -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. A repeated start for an index already present keeps
33
- * the first-seen row, so a redraw cannot duplicate an Ask row.
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(ledger: AskLedger, event: AskStartEvent): AskLedger {
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], settledPruned: ledger.settledPruned });
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(ledger: AskLedger, event: AskEndEvent): AskLedger {
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], settledPruned: ledger.settledPruned });
104
+ return prune({ ...ledger, rows: [...ledger.rows, row] });
64
105
  }
65
106
  const rows = ledger.rows.map((row, position) =>
66
- position === index ? { ...row, endedAt: event.at, ok: event.ok } : row,
107
+ position === index
108
+ ? { ...row, endedAt: event.at, ok: event.ok, durationMs: event.durationMs }
109
+ : row,
67
110
  );
68
- return prune({ rows, settledPruned: ledger.settledPruned });
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 { rows: kept, settledPruned: ledger.settledPruned + pruned };
146
+ return {
147
+ rows: kept,
148
+ settledPruned: ledger.settledPruned + pruned,
149
+ prunedActiveMs: ledger.prunedActiveMs + prunedMs,
150
+ };
85
151
  }
@@ -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
- clampToWidth(`${stateGlyph(node.state)} ${sanitizeTerminalLine(node.path)}`, options.width),
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));
@@ -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 " ";
@@ -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 { AskRow } from "./ask-ledger.ts";
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(name, row, nested.get(row.index) ?? [], now, gist),
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(agent, state.settledAsks(name), now),
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
- function askNode(
59
- agent: string,
60
- row: AskRow,
61
- children: readonly TreeNode[],
62
- now: number,
63
- activityGist: string | null,
64
- ): TreeNode {
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 dwell = live ? now - (row.startedAt ?? now) : row.endedAt - (row.startedAt ?? row.endedAt);
67
- const facts = [durationText(dwell)];
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
- function agentFacts(agent: AgentInfo, settled: number, now: number): readonly string[] {
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(`idle · ${durationText(now - (agent.stateChangedAt ?? now))}`);
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}`));
@@ -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, as unstyled terminal lines, every line truncated
38
- * to `width`. `interactive: false` drops the regions a surface that takes no
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 state and options return equal
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, { width: options.width, selectedPath: options.selectedPath }),
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));
@@ -1,11 +1,18 @@
1
- import { clampToWidth, sanitizeTerminalLine } from "../text/index.ts";
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 facts = row.node.facts.map(sanitizeTerminalLine).join(" · ");
22
- const head = `${selected ? "❯" : " "}${prefix}${foldGlyph(row.hasChildren, row.expanded)} ${stateGlyph(row.node.state)} ${sanitizeTerminalLine(row.node.label)}`;
23
- const lines = [clampToWidth(facts === "" ? head : `${head} ${facts}`, options.width)];
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
- clampToWidth(
28
- ` ${prefix} ${GLYPHS.branch} ${GLYPHS.running} ${sanitizeTerminalLine(gist)}`,
29
- options.width,
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) {
@@ -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 { type AskLedger, applyAskEnd, applyAskStart, emptyLedger } from "./ask-ledger.ts";
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(event.agent, applyAskStart(this.ledger(event.agent), event));
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(event.agent, applyAskEnd(this.ledger(event.agent), event));
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 owns no clock and no I/O of its own: every capability, including the
19
- * reap-ladder stop, arrives through `RunTreeViewHost`.
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