@yaag/tui 0.1.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 +24 -0
- package/src/accounting-text.ts +19 -0
- package/src/activity-text.ts +27 -0
- package/src/ask-ledger.ts +85 -0
- package/src/compact-render.ts +75 -0
- package/src/details-pane.ts +33 -0
- package/src/drill-controller.ts +234 -0
- package/src/drill-keys.ts +74 -0
- package/src/drill-state.ts +163 -0
- package/src/duration-text.ts +12 -0
- package/src/index.ts +126 -0
- package/src/key-router.ts +47 -0
- package/src/node-actions.ts +57 -0
- package/src/node-path-parse.ts +54 -0
- package/src/node-table.ts +34 -0
- package/src/overlay-frame.ts +99 -0
- package/src/overlay-scroll.ts +47 -0
- package/src/run-tree-view.ts +236 -0
- package/src/run-view-result.ts +43 -0
- package/src/run-view-state.ts +89 -0
- package/src/session-keys.ts +31 -0
- package/src/session-transcript.ts +164 -0
- package/src/snapshot-render.ts +51 -0
- package/src/stop-prompt.ts +58 -0
- package/src/terminal-text.ts +62 -0
- package/src/transcript-content.ts +42 -0
- package/src/transcript-overlay.ts +42 -0
- package/src/transcript-render.ts +115 -0
- package/src/tree-cursor.ts +75 -0
- package/src/tree-fold.ts +79 -0
- package/src/tree-glyphs.ts +33 -0
- package/src/tree-keys.ts +45 -0
- package/src/tree-model.ts +107 -0
- package/src/tree-navigation.ts +76 -0
- package/src/tree-navigator.ts +70 -0
- package/src/tree-nested.ts +129 -0
- package/src/tree-node.ts +20 -0
- package/src/tree-render.ts +91 -0
- package/src/tree-rows.ts +64 -0
- package/src/tree-state.ts +199 -0
- package/src/widget-render.ts +88 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { resolveSelection } from "./tree-cursor.ts";
|
|
2
|
+
import type { FoldState } from "./tree-fold.ts";
|
|
3
|
+
import { type NamedKeybindings, routeTreeKey } from "./tree-keys.ts";
|
|
4
|
+
import { applyTreeAction, emptyView, type TreeView } from "./tree-navigation.ts";
|
|
5
|
+
import type { TreeNode } from "./tree-node.ts";
|
|
6
|
+
|
|
7
|
+
/** What the keyboard router needs from its host component. */
|
|
8
|
+
export interface TreeNavigatorOptions {
|
|
9
|
+
/** The current node tree, re-read on every key: a live Run keeps growing. */
|
|
10
|
+
snapshot(): readonly TreeNode[];
|
|
11
|
+
/** pi's injected manager in the Host Session, `cliKeybindings()` in the CLI. */
|
|
12
|
+
readonly keybindings: NamedKeybindings;
|
|
13
|
+
/**
|
|
14
|
+
* Whether the tree holds input focus. Defaults to always focused, because pi
|
|
15
|
+
* and pi-tui dispatch input to the focused component only; a host that layers
|
|
16
|
+
* an overlay over the tree passes its own predicate (spec §2).
|
|
17
|
+
*/
|
|
18
|
+
focused?(): boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The tree's keyboard router: it owns the cursor and the user's folds and turns
|
|
23
|
+
* input bytes into cursor and fold moves.
|
|
24
|
+
*
|
|
25
|
+
* `handleInput` reports whether the tree consumed the byte. It consumes only
|
|
26
|
+
* the keys of `TREE_KEY_TABLE`, and nothing at all without input focus, so no
|
|
27
|
+
* other pi default binding is shadowed (spec §2).
|
|
28
|
+
*/
|
|
29
|
+
export class TreeNavigator {
|
|
30
|
+
readonly #options: TreeNavigatorOptions;
|
|
31
|
+
#view: TreeView = emptyView();
|
|
32
|
+
|
|
33
|
+
constructor(options: TreeNavigatorOptions) {
|
|
34
|
+
this.#options = options;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The visible node the cursor rests on, re-resolved against the live tree, so
|
|
39
|
+
* a branch that the default fold collapses on its own — its nested work has
|
|
40
|
+
* settled — carries the cursor up to the collapsed ancestor.
|
|
41
|
+
*
|
|
42
|
+
* The resolved path is written back into the navigator state, so the move to
|
|
43
|
+
* the ancestor is permanent: later live work that re-expands the branch does
|
|
44
|
+
* not restore the cursor to the node the fold hid (spec §2).
|
|
45
|
+
*/
|
|
46
|
+
get selectedPath(): string | undefined {
|
|
47
|
+
const resolved = resolveSelection(
|
|
48
|
+
this.#options.snapshot(),
|
|
49
|
+
this.#view.fold,
|
|
50
|
+
this.#view.selectedPath,
|
|
51
|
+
);
|
|
52
|
+
if (resolved !== this.#view.selectedPath)
|
|
53
|
+
this.#view = { ...this.#view, selectedPath: resolved };
|
|
54
|
+
return resolved;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The user's explicit folds, for `renderTree`'s `fold` option. */
|
|
58
|
+
get fold(): FoldState {
|
|
59
|
+
return this.#view.fold;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Routes one input byte string; returns whether the tree consumed it. */
|
|
63
|
+
handleInput(data: string): boolean {
|
|
64
|
+
if (this.#options.focused?.() === false) return false;
|
|
65
|
+
const action = routeTreeKey(data, this.#options.keybindings);
|
|
66
|
+
if (action === undefined) return false;
|
|
67
|
+
this.#view = applyTreeAction(this.#options.snapshot(), this.#view, action);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { type NodeInfo, sanitizeNodeName } from "@yaag/runtime";
|
|
2
|
+
import { costText, tokensText } from "./accounting-text.ts";
|
|
3
|
+
import { durationText } from "./duration-text.ts";
|
|
4
|
+
import { parseNodePath } from "./node-path-parse.ts";
|
|
5
|
+
import { sanitizeTerminalLine } from "./terminal-text.ts";
|
|
6
|
+
import type { TreeNode, TreeNodeKind, TreeNodeState } from "./tree-node.ts";
|
|
7
|
+
|
|
8
|
+
interface Draft {
|
|
9
|
+
readonly path: string;
|
|
10
|
+
readonly kind: TreeNodeKind;
|
|
11
|
+
label: string;
|
|
12
|
+
state: TreeNodeState;
|
|
13
|
+
facts: readonly string[];
|
|
14
|
+
activityGist: string | null;
|
|
15
|
+
startedAt: number | null;
|
|
16
|
+
readonly children: Map<string, Draft>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Grafts one Agent's bounded node table onto its Asks, keyed by node path.
|
|
21
|
+
*
|
|
22
|
+
* Returns the Nested Node children of each Ask index. A path that does not
|
|
23
|
+
* parse, or whose first segment is not this Agent with an Ask index, is
|
|
24
|
+
* dropped: an unattributable `node_update` renders nothing rather than a
|
|
25
|
+
* forged row. Intermediate segments materialize even when only a deep leaf
|
|
26
|
+
* reported.
|
|
27
|
+
*/
|
|
28
|
+
export function graftNestedNodes(
|
|
29
|
+
agent: string,
|
|
30
|
+
nodes: readonly NodeInfo[],
|
|
31
|
+
now: number,
|
|
32
|
+
): ReadonlyMap<number, readonly TreeNode[]> {
|
|
33
|
+
const roots = new Map<number, Map<string, Draft>>();
|
|
34
|
+
const expected = sanitizeNodeName(agent);
|
|
35
|
+
for (const node of nodes) {
|
|
36
|
+
const segments = parseNodePath(node.path);
|
|
37
|
+
const first = segments?.[0];
|
|
38
|
+
if (segments === undefined || segments === null || first === undefined) continue;
|
|
39
|
+
if (first.name !== expected || first.askIndex === undefined) continue;
|
|
40
|
+
const container = roots.get(first.askIndex) ?? new Map<string, Draft>();
|
|
41
|
+
roots.set(first.askIndex, container);
|
|
42
|
+
graft(container, `${first.name}:${first.askIndex}`, segments.slice(1), node, now);
|
|
43
|
+
}
|
|
44
|
+
const grafted = new Map<number, readonly TreeNode[]>();
|
|
45
|
+
for (const [index, container] of roots) grafted.set(index, freezeAll(container));
|
|
46
|
+
return grafted;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function graft(
|
|
50
|
+
container: Map<string, Draft>,
|
|
51
|
+
prefix: string,
|
|
52
|
+
segments: readonly { readonly name: string; readonly askIndex?: number }[],
|
|
53
|
+
node: NodeInfo,
|
|
54
|
+
now: number,
|
|
55
|
+
): void {
|
|
56
|
+
let where = container;
|
|
57
|
+
let path = prefix;
|
|
58
|
+
for (const [position, segment] of segments.entries()) {
|
|
59
|
+
path = `${path}/${segment.name}`;
|
|
60
|
+
const nested = upsert(where, path, "nested", sanitizeTerminalLine(segment.name));
|
|
61
|
+
if (position === segments.length - 1) applyNode(nested, node, now);
|
|
62
|
+
if (segment.askIndex === undefined) {
|
|
63
|
+
where = nested.children;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
path = `${path}:${segment.askIndex}`;
|
|
67
|
+
where = upsert(nested.children, path, "ask", askLabel(segment.askIndex)).children;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function upsert(
|
|
72
|
+
container: Map<string, Draft>,
|
|
73
|
+
path: string,
|
|
74
|
+
kind: TreeNodeKind,
|
|
75
|
+
label: string,
|
|
76
|
+
): Draft {
|
|
77
|
+
const existing = container.get(path);
|
|
78
|
+
if (existing !== undefined) return existing;
|
|
79
|
+
const draft: Draft = {
|
|
80
|
+
path,
|
|
81
|
+
kind,
|
|
82
|
+
label,
|
|
83
|
+
state: "running",
|
|
84
|
+
facts: [],
|
|
85
|
+
activityGist: null,
|
|
86
|
+
startedAt: null,
|
|
87
|
+
children: new Map(),
|
|
88
|
+
};
|
|
89
|
+
container.set(path, draft);
|
|
90
|
+
return draft;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function applyNode(draft: Draft, node: NodeInfo, now: number): void {
|
|
94
|
+
draft.state = node.state === "failed" ? "failed" : node.state;
|
|
95
|
+
draft.activityGist = node.activityGist === null ? null : sanitizeTerminalLine(node.activityGist);
|
|
96
|
+
draft.startedAt = node.updatedAt;
|
|
97
|
+
draft.facts = nodeFacts(node, now);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function nodeFacts(node: NodeInfo, now: number): readonly string[] {
|
|
101
|
+
const facts: string[] = [];
|
|
102
|
+
if (node.state === "running")
|
|
103
|
+
facts.push(`running ${durationText(now - (node.updatedAt ?? now))}`);
|
|
104
|
+
else facts.push(node.state);
|
|
105
|
+
if (node.cost !== null) facts.push(costText(node.cost));
|
|
106
|
+
if (node.tokens !== null) facts.push(tokensText(node.tokens.total));
|
|
107
|
+
return facts;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function askLabel(index: number): string {
|
|
111
|
+
return `ask #${index + 1}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function freezeAll(container: Map<string, Draft>): readonly TreeNode[] {
|
|
115
|
+
return [...container.values()].map(freeze);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function freeze(draft: Draft): TreeNode {
|
|
119
|
+
return {
|
|
120
|
+
path: draft.path,
|
|
121
|
+
kind: draft.kind,
|
|
122
|
+
label: draft.label,
|
|
123
|
+
state: draft.state,
|
|
124
|
+
facts: draft.facts,
|
|
125
|
+
children: freezeAll(draft.children),
|
|
126
|
+
activityGist: draft.activityGist,
|
|
127
|
+
startedAt: draft.startedAt,
|
|
128
|
+
};
|
|
129
|
+
}
|
package/src/tree-node.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** What one tree row represents. */
|
|
2
|
+
export type TreeNodeKind = "agent" | "ask" | "nested" | "rollup";
|
|
3
|
+
|
|
4
|
+
/** The drawn lifecycle state of one tree row. */
|
|
5
|
+
export type TreeNodeState = "running" | "idle" | "exited" | "failed";
|
|
6
|
+
|
|
7
|
+
/** One node of the Run tree, identified by its path (spec §1). */
|
|
8
|
+
export interface TreeNode {
|
|
9
|
+
/** The node's identity: `name(:askIndex(/childName…))`. */
|
|
10
|
+
readonly path: string;
|
|
11
|
+
readonly kind: TreeNodeKind;
|
|
12
|
+
readonly label: string;
|
|
13
|
+
readonly state: TreeNodeState;
|
|
14
|
+
/** Right-hand facts, already formatted: dwell, cost, tokens. */
|
|
15
|
+
readonly facts: readonly string[];
|
|
16
|
+
readonly children: readonly TreeNode[];
|
|
17
|
+
/** The current tool-call gist, drawn as the `└`-line under a running node. */
|
|
18
|
+
readonly activityGist: string | null;
|
|
19
|
+
readonly startedAt: number | null;
|
|
20
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { costText } from "./accounting-text.ts";
|
|
2
|
+
import { renderDetailsPane } from "./details-pane.ts";
|
|
3
|
+
import { durationText } from "./duration-text.ts";
|
|
4
|
+
import { agentOfPath } from "./node-path-parse.ts";
|
|
5
|
+
import { sanitizeTerminalLine } from "./terminal-text.ts";
|
|
6
|
+
import { emptyFold, type FoldState, type VisibleRow, visibleRows } from "./tree-fold.ts";
|
|
7
|
+
import { buildTree } from "./tree-model.ts";
|
|
8
|
+
import type { TreeNode } from "./tree-node.ts";
|
|
9
|
+
import { clamp, renderRow } from "./tree-rows.ts";
|
|
10
|
+
import type { TreeState } from "./tree-state.ts";
|
|
11
|
+
|
|
12
|
+
/** Tree rows drawn before the renderer rolls the remainder into one line. */
|
|
13
|
+
const MAX_TREE_ROWS = 64;
|
|
14
|
+
|
|
15
|
+
const FOOTER = " ↑↓ move ←→ fold ↵ actions t transcript esc back";
|
|
16
|
+
|
|
17
|
+
/** Everything the full view needs; `now` keeps the renderer clock-free. */
|
|
18
|
+
export interface TreeRenderOptions {
|
|
19
|
+
readonly now: number;
|
|
20
|
+
readonly width: number;
|
|
21
|
+
/** Header text before the Program name; defaults to `yaag`. */
|
|
22
|
+
readonly label?: string;
|
|
23
|
+
readonly selectedPath?: string;
|
|
24
|
+
readonly fold?: FoldState;
|
|
25
|
+
/** The settled Run's Result, or the error plus bounded stderr tail. */
|
|
26
|
+
readonly result?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Renders the four stacked regions — header, tree, details pane, key footer —
|
|
31
|
+
* as unstyled terminal lines, every line truncated to `width`.
|
|
32
|
+
*
|
|
33
|
+
* Pure over the state: two calls with the same state and options return equal
|
|
34
|
+
* arrays. Every untrusted string passes `sanitizeTerminalLine` first, so a
|
|
35
|
+
* hostile gist or path can neither add a line nor emit an escape byte.
|
|
36
|
+
*/
|
|
37
|
+
export function renderTree(state: TreeState, options: TreeRenderOptions): readonly string[] {
|
|
38
|
+
const tree = buildTree(state, { now: options.now });
|
|
39
|
+
const rows = visibleRows(tree, options.fold ?? emptyFold());
|
|
40
|
+
const rule = "─".repeat(Math.max(1, Math.floor(options.width)));
|
|
41
|
+
const selected = selectedNode(rows, options.selectedPath);
|
|
42
|
+
const lines = [headerLine(state, options), rule, ...treeLines(rows, options), rule];
|
|
43
|
+
const details = renderDetailsPane(selected, {
|
|
44
|
+
width: options.width,
|
|
45
|
+
outputTail: selected === undefined ? [] : state.outputTail(agentOfPath(selected.path)),
|
|
46
|
+
});
|
|
47
|
+
if (details.length > 0) lines.push(...details, rule);
|
|
48
|
+
const result = options.result ?? state.result;
|
|
49
|
+
if (result !== undefined) {
|
|
50
|
+
lines.push(clamp("Result:", options.width));
|
|
51
|
+
for (const line of result.split("\n"))
|
|
52
|
+
lines.push(clamp(` ${sanitizeTerminalLine(line)}`, options.width));
|
|
53
|
+
lines.push(rule);
|
|
54
|
+
}
|
|
55
|
+
lines.push(clamp(FOOTER, options.width));
|
|
56
|
+
return lines;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function treeLines(rows: readonly VisibleRow[], options: TreeRenderOptions): readonly string[] {
|
|
60
|
+
const shown = rows.slice(0, MAX_TREE_ROWS);
|
|
61
|
+
const lines = shown.flatMap((row) =>
|
|
62
|
+
renderRow(row, { width: options.width, selectedPath: options.selectedPath }),
|
|
63
|
+
);
|
|
64
|
+
const hidden = rows.length - shown.length;
|
|
65
|
+
if (hidden > 0) lines.push(clamp(` and ${hidden} more running`, options.width));
|
|
66
|
+
return lines;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function headerLine(state: TreeState, options: TreeRenderOptions): string {
|
|
70
|
+
const summary = state.summary;
|
|
71
|
+
const elapsed =
|
|
72
|
+
summary.runState === "ended"
|
|
73
|
+
? summary.durationMs
|
|
74
|
+
: options.now - (summary.startedAt ?? options.now);
|
|
75
|
+
const status = summary.runState === "ended" ? summary.outcome : "running";
|
|
76
|
+
const asks = `${summary.asksSettled} ask${summary.asksSettled === 1 ? "" : "s"} settled`;
|
|
77
|
+
const label = sanitizeTerminalLine(options.label ?? "yaag");
|
|
78
|
+
const program = sanitizeTerminalLine(summary.program === "" ? "Run" : summary.program);
|
|
79
|
+
return clamp(
|
|
80
|
+
`${label} ▸ ${program} ${status} ${durationText(elapsed)} ${costText(summary.cost, summary.incomplete)} · ${asks}`,
|
|
81
|
+
options.width,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function selectedNode(
|
|
86
|
+
rows: readonly VisibleRow[],
|
|
87
|
+
selectedPath: string | undefined,
|
|
88
|
+
): TreeNode | undefined {
|
|
89
|
+
if (selectedPath === undefined) return undefined;
|
|
90
|
+
return rows.find((row) => row.node.path === selectedPath)?.node;
|
|
91
|
+
}
|
package/src/tree-rows.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { sanitizeTerminalLine } from "./terminal-text.ts";
|
|
3
|
+
import type { VisibleRow } from "./tree-fold.ts";
|
|
4
|
+
import { foldGlyph, GLYPHS, stateGlyph } from "./tree-glyphs.ts";
|
|
5
|
+
|
|
6
|
+
/** Marks the selected row in the drawn tree, matched by node path. */
|
|
7
|
+
export interface TreeRowOptions {
|
|
8
|
+
readonly width: number;
|
|
9
|
+
readonly selectedPath?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Draws one visible row, plus the `└`-line that carries a running node's
|
|
14
|
+
* current tool-call gist when the row draws no children of its own.
|
|
15
|
+
*
|
|
16
|
+
* Every untrusted string is sanitized to one line first, so a hostile gist
|
|
17
|
+
* cannot forge an extra row, and the result is truncated to `width`.
|
|
18
|
+
*/
|
|
19
|
+
export function renderRow(row: VisibleRow, options: TreeRowOptions): readonly string[] {
|
|
20
|
+
const prefix = ancestryPrefix(row);
|
|
21
|
+
const selected = options.selectedPath === row.node.path;
|
|
22
|
+
const facts = row.node.facts.map(sanitizeTerminalLine).join(" · ");
|
|
23
|
+
const head = `${selected ? "❯" : " "}${prefix}${foldGlyph(row.hasChildren, row.expanded)} ${stateGlyph(row.node.state)} ${sanitizeTerminalLine(row.node.label)}`;
|
|
24
|
+
const lines = [clamp(facts === "" ? head : `${head} ${facts}`, options.width)];
|
|
25
|
+
const gist = row.node.activityGist;
|
|
26
|
+
if (gist !== null && !row.expanded) {
|
|
27
|
+
lines.push(
|
|
28
|
+
clamp(
|
|
29
|
+
` ${prefix} ${GLYPHS.branch} ${GLYPHS.running} ${sanitizeTerminalLine(gist)}`,
|
|
30
|
+
options.width,
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return lines;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Truncates one already sanitized line to a terminal column count, measuring
|
|
39
|
+
* columns rather than code points so a CJK or emoji label cannot overflow.
|
|
40
|
+
*
|
|
41
|
+
* pi-tui's `truncateToWidth` is deliberately not used: it appends a colour
|
|
42
|
+
* reset sequence, and this package emits unstyled lines only.
|
|
43
|
+
*/
|
|
44
|
+
export function clamp(value: string, width: number): string {
|
|
45
|
+
const limit = Math.max(1, Math.floor(width));
|
|
46
|
+
if (visibleWidth(value) <= limit) return value;
|
|
47
|
+
let text = "";
|
|
48
|
+
let used = 0;
|
|
49
|
+
for (const character of value) {
|
|
50
|
+
const next = used + visibleWidth(character);
|
|
51
|
+
if (next > limit - 1) break;
|
|
52
|
+
text += character;
|
|
53
|
+
used = next;
|
|
54
|
+
}
|
|
55
|
+
return `${text}…`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ancestryPrefix(row: VisibleRow): string {
|
|
59
|
+
let prefix = "";
|
|
60
|
+
for (let depth = 0; depth < row.depth; depth += 1) {
|
|
61
|
+
prefix += row.lastAtDepth[depth] === true ? " " : `${GLYPHS.trunk} `;
|
|
62
|
+
}
|
|
63
|
+
return prefix;
|
|
64
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { applyEvent, initialSummary, type LifecycleEvent, type RunSummary } from "@yaag/runtime";
|
|
2
|
+
import { type AskLedger, applyAskEnd, applyAskStart, emptyLedger } from "./ask-ledger.ts";
|
|
3
|
+
import { sanitizeTerminalText } from "./terminal-text.ts";
|
|
4
|
+
|
|
5
|
+
const MAX_TAIL_CHARS = 2_048;
|
|
6
|
+
const TAIL_LINES = 2;
|
|
7
|
+
|
|
8
|
+
interface OutputTail {
|
|
9
|
+
readonly index: number;
|
|
10
|
+
readonly text: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A details-channel occurrence: the canonical Summary plus its producing event. */
|
|
14
|
+
export interface TreeUpdate {
|
|
15
|
+
readonly summary: RunSummary;
|
|
16
|
+
/** fd 3 stream occurrence identity — never an event value or timestamp. */
|
|
17
|
+
readonly sequence?: number;
|
|
18
|
+
readonly event?: LifecycleEvent;
|
|
19
|
+
readonly id?: string;
|
|
20
|
+
readonly result?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The Run tree's folded state: the canonical Summary plus the bounded,
|
|
25
|
+
* renderer-only projection the tree draws (per-Agent Ask ledger and output
|
|
26
|
+
* tails). It reads no clock; every render takes `now` as an option.
|
|
27
|
+
*/
|
|
28
|
+
export class TreeState {
|
|
29
|
+
#summary: RunSummary;
|
|
30
|
+
readonly #order: string[];
|
|
31
|
+
readonly #ledgers = new Map<string, AskLedger>();
|
|
32
|
+
readonly #tails = new Map<string, OutputTail>();
|
|
33
|
+
#highestSequence: number | undefined;
|
|
34
|
+
#id: string | undefined;
|
|
35
|
+
#result: string | undefined;
|
|
36
|
+
|
|
37
|
+
constructor(summary: RunSummary = initialSummary()) {
|
|
38
|
+
this.#summary = summary;
|
|
39
|
+
this.#order = Object.keys(summary.agents);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Reconstructs a settled tree state without retaining event history. */
|
|
43
|
+
static fromSummary(summary: RunSummary): TreeState {
|
|
44
|
+
return new TreeState(summary);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Canonical Summary after every ingested Lifecycle Event. */
|
|
48
|
+
get summary(): RunSummary {
|
|
49
|
+
return this.#summary;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Agent names in their first-observed insertion order. */
|
|
53
|
+
get agentOrder(): readonly string[] {
|
|
54
|
+
return this.#order;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The Run's identity, when an ingested occurrence carried one. */
|
|
58
|
+
get runId(): string | undefined {
|
|
59
|
+
return this.#id;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The settled Run's Result text, when an ingested occurrence carried one. */
|
|
63
|
+
get result(): string | undefined {
|
|
64
|
+
return this.#result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Folds one stamped Lifecycle Event, for the CLI path that owns the stream. */
|
|
68
|
+
apply(event: LifecycleEvent): void {
|
|
69
|
+
this.#rememberAgent(event);
|
|
70
|
+
this.#summary = applyEvent(this.#summary, event);
|
|
71
|
+
this.#project(event);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Ingests one details occurrence: replaces the canonical Summary and folds
|
|
76
|
+
* the renderer-only projection exactly once, keyed by the fd 3 occurrence
|
|
77
|
+
* `sequence`.
|
|
78
|
+
*
|
|
79
|
+
* An occurrence whose sequence is not greater than the highest already
|
|
80
|
+
* processed sequence changes nothing at all — not the Summary, not the id,
|
|
81
|
+
* not the Result — so a repeat or a stale replay cannot duplicate Ask facts
|
|
82
|
+
* (spec §5). An occurrence that carries no `sequence` is not stream-keyed,
|
|
83
|
+
* so it is always processed.
|
|
84
|
+
*/
|
|
85
|
+
ingest(update: TreeUpdate): void {
|
|
86
|
+
const sequence = update.sequence;
|
|
87
|
+
if (
|
|
88
|
+
sequence !== undefined &&
|
|
89
|
+
this.#highestSequence !== undefined &&
|
|
90
|
+
sequence <= this.#highestSequence
|
|
91
|
+
)
|
|
92
|
+
return;
|
|
93
|
+
if (sequence !== undefined) this.#highestSequence = sequence;
|
|
94
|
+
this.#replaceSummary(update.summary);
|
|
95
|
+
if (update.id !== undefined) this.#id = update.id;
|
|
96
|
+
if (update.result !== undefined) this.#result = update.result;
|
|
97
|
+
const event = update.event;
|
|
98
|
+
if (event === undefined) return;
|
|
99
|
+
this.#rememberAgent(event);
|
|
100
|
+
this.#project(event);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One Agent's bounded Ask ledger; empty for an unknown Agent. */
|
|
104
|
+
ledger(agent: string): AskLedger {
|
|
105
|
+
return this.#ledgers.get(agent) ?? emptyLedger();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Number of Ask settlements observed for an Agent.
|
|
110
|
+
*
|
|
111
|
+
* The count merges two sources and keeps the larger one. The renderer
|
|
112
|
+
* ledger counts the settled rows it holds plus its pruned settlements. The
|
|
113
|
+
* Agent Summary (`askIndex` and state) gives a lower bound, because a
|
|
114
|
+
* partially reconstructed ledger can hold fewer rows than the Run already
|
|
115
|
+
* settled. A larger ledger count wins, so live observation is never clamped
|
|
116
|
+
* down to the Summary.
|
|
117
|
+
*/
|
|
118
|
+
settledAsks(agent: string): number {
|
|
119
|
+
const ledger = this.ledger(agent);
|
|
120
|
+
const settled = ledger.rows.filter((row) => row.endedAt !== null).length + this.#pruned(agent);
|
|
121
|
+
return Math.max(settled, settledFromSummary(this.#summary.agents[agent]));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The bounded, sanitized two-line output tail for an Agent's live Ask. */
|
|
125
|
+
outputTail(agent: string): readonly string[] {
|
|
126
|
+
const tail = this.#tails.get(agent);
|
|
127
|
+
const current = this.#summary.agents[agent];
|
|
128
|
+
if (
|
|
129
|
+
tail === undefined ||
|
|
130
|
+
current?.state !== "asking" ||
|
|
131
|
+
current.askIndex !== tail.index ||
|
|
132
|
+
current.replayed
|
|
133
|
+
)
|
|
134
|
+
return [];
|
|
135
|
+
return tail.text.split("\n").slice(-TAIL_LINES);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Whether an Agent's currently active Ask is Cassette-backed. */
|
|
139
|
+
replayed(agent: string): boolean {
|
|
140
|
+
const current = this.#summary.agents[agent];
|
|
141
|
+
return current?.state === "asking" && current.replayed;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#pruned(agent: string): number {
|
|
145
|
+
return this.ledger(agent).settledPruned;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#replaceSummary(summary: RunSummary): void {
|
|
149
|
+
this.#summary = summary;
|
|
150
|
+
for (const name of Object.keys(summary.agents)) {
|
|
151
|
+
if (!this.#order.includes(name)) this.#order.push(name);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#project(event: LifecycleEvent): void {
|
|
156
|
+
switch (event.type) {
|
|
157
|
+
case "ask_start":
|
|
158
|
+
this.#ledgers.set(event.agent, applyAskStart(this.ledger(event.agent), event));
|
|
159
|
+
this.#tails.set(event.agent, { index: event.index, text: "" });
|
|
160
|
+
break;
|
|
161
|
+
case "ask_output":
|
|
162
|
+
this.#appendOutput(event);
|
|
163
|
+
break;
|
|
164
|
+
case "ask_end":
|
|
165
|
+
this.#ledgers.set(event.agent, applyAskEnd(this.ledger(event.agent), event));
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#rememberAgent(event: LifecycleEvent): void {
|
|
171
|
+
if ("agent" in event && !this.#order.includes(event.agent)) this.#order.push(event.agent);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#appendOutput(event: Extract<LifecycleEvent, { readonly type: "ask_output" }>): void {
|
|
175
|
+
const current = this.#summary.agents[event.agent];
|
|
176
|
+
const tail = this.#tails.get(event.agent);
|
|
177
|
+
if (
|
|
178
|
+
current?.state !== "asking" ||
|
|
179
|
+
current.askIndex !== event.index ||
|
|
180
|
+
current.replayed ||
|
|
181
|
+
(tail !== undefined && tail.index !== event.index)
|
|
182
|
+
)
|
|
183
|
+
return;
|
|
184
|
+
this.#tails.set(event.agent, {
|
|
185
|
+
index: event.index,
|
|
186
|
+
text: boundTail(`${tail?.text ?? ""}${sanitizeTerminalText(event.text)}`),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function settledFromSummary(agent: RunSummary["agents"][string] | undefined): number {
|
|
192
|
+
if (agent === undefined || agent.askIndex === null) return 0;
|
|
193
|
+
return agent.state === "asking" ? agent.askIndex : agent.askIndex + 1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function boundTail(text: string): string {
|
|
197
|
+
const bounded = text.slice(-MAX_TAIL_CHARS);
|
|
198
|
+
return bounded.split("\n").slice(-TAIL_LINES).join("\n");
|
|
199
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The combined inline widget for every live background Run (spec §1).
|
|
3
|
+
*
|
|
4
|
+
* One frame holds every Run: a header line each, expanded to the compact
|
|
5
|
+
* per-Agent lines while a Run has an Agent waiting on an Ask. Expansion is a
|
|
6
|
+
* pure policy over the state, because a pi `string[]` widget receives no input
|
|
7
|
+
* and there is no gesture to expand an entry with.
|
|
8
|
+
*/
|
|
9
|
+
import { compactAgentLines, compactHeaderLine } from "./compact-render.ts";
|
|
10
|
+
import { clamp } from "./tree-rows.ts";
|
|
11
|
+
import type { TreeState } from "./tree-state.ts";
|
|
12
|
+
|
|
13
|
+
/** Default line budget; pi truncates a widget past 10 lines. */
|
|
14
|
+
const DEFAULT_MAX_LINES = 10;
|
|
15
|
+
|
|
16
|
+
/** One Run in the widget: its Run id and the renderer projection it drives. */
|
|
17
|
+
export interface WidgetRun {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly state: TreeState;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Render-time inputs; `now` keeps the renderer clock-free. */
|
|
23
|
+
export interface WidgetRenderOptions {
|
|
24
|
+
readonly now: number;
|
|
25
|
+
readonly width: number;
|
|
26
|
+
/** Hard line budget for the whole frame; defaults to 10. */
|
|
27
|
+
readonly maxLines?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Entry {
|
|
31
|
+
readonly header: string;
|
|
32
|
+
readonly agents: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Renders every Run into one frame that never exceeds `maxLines`.
|
|
37
|
+
*
|
|
38
|
+
* Degrades deterministically: expanded entries collapse from the tail, then
|
|
39
|
+
* trailing headers roll up into one `and N more Runs` line. Pure over the
|
|
40
|
+
* given states, and every line is already width-clamped and sanitized.
|
|
41
|
+
*/
|
|
42
|
+
export function renderRunsWidget(
|
|
43
|
+
runs: readonly WidgetRun[],
|
|
44
|
+
options: WidgetRenderOptions,
|
|
45
|
+
): readonly string[] {
|
|
46
|
+
const budget = Math.max(1, options.maxLines ?? DEFAULT_MAX_LINES);
|
|
47
|
+
if (runs.length === 0) return [];
|
|
48
|
+
const entries = runs.map((run) => entryOf(run, options));
|
|
49
|
+
let expanded = entries.map((entry) => entry.agents.length > 0);
|
|
50
|
+
for (let index = entries.length - 1; index >= 0 && lineCount(entries, expanded) > budget; --index)
|
|
51
|
+
expanded = expanded.map((value, at) => (at === index ? false : value));
|
|
52
|
+
if (lineCount(entries, expanded) <= budget) return frame(entries, expanded);
|
|
53
|
+
const kept = Math.max(0, budget - 1);
|
|
54
|
+
const rest = entries.length - kept;
|
|
55
|
+
return [
|
|
56
|
+
...entries.slice(0, kept).map((entry) => entry.header),
|
|
57
|
+
clamp(` and ${rest} more Run${rest === 1 ? "" : "s"}`, options.width),
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function entryOf(run: WidgetRun, options: WidgetRenderOptions): Entry {
|
|
62
|
+
const compact = { now: options.now, width: options.width, label: run.id };
|
|
63
|
+
return {
|
|
64
|
+
header: compactHeaderLine(run.state, compact),
|
|
65
|
+
agents: expandable(run.state) ? compactAgentLines(run.state, compact) : [],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A Run is expanded while at least one of its Agents waits on an Ask. */
|
|
70
|
+
function expandable(state: TreeState): boolean {
|
|
71
|
+
return Object.values(state.summary.agents).some((agent) => agent.state === "asking");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function lineCount(entries: readonly Entry[], expanded: readonly boolean[]): number {
|
|
75
|
+
return entries.reduce(
|
|
76
|
+
(total, entry, index) => total + 1 + (expanded[index] === true ? entry.agents.length : 0),
|
|
77
|
+
0,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function frame(entries: readonly Entry[], expanded: readonly boolean[]): readonly string[] {
|
|
82
|
+
const lines: string[] = [];
|
|
83
|
+
entries.forEach((entry, index) => {
|
|
84
|
+
lines.push(entry.header);
|
|
85
|
+
if (expanded[index] === true) lines.push(...entry.agents);
|
|
86
|
+
});
|
|
87
|
+
return lines;
|
|
88
|
+
}
|