@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,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removes terminal control sequences from untrusted event text while retaining
|
|
3
|
+
* printable Unicode and newlines used by Ask output tails.
|
|
4
|
+
*
|
|
5
|
+
* Unterminated escape sequences are discarded through the end of the value.
|
|
6
|
+
*/
|
|
7
|
+
export function sanitizeTerminalText(value: string): string {
|
|
8
|
+
let text = "";
|
|
9
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
10
|
+
const code = value.charCodeAt(index);
|
|
11
|
+
if (code === 27) {
|
|
12
|
+
index = skipEscape(value, index);
|
|
13
|
+
} else if (code === 0x9b) {
|
|
14
|
+
index = skipCsi(value, index + 1);
|
|
15
|
+
} else if (code === 0x90 || code === 0x98 || code === 0x9d || code === 0x9e || code === 0x9f) {
|
|
16
|
+
index = skipString(value, index + 1);
|
|
17
|
+
} else if (isPrintable(code) || code === 10) {
|
|
18
|
+
text += value[index];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return text;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Sanitizes untrusted event text for one-line contexts: control sequences are
|
|
26
|
+
* removed and embedded newlines flatten to single spaces, so a hostile value
|
|
27
|
+
* cannot forge extra log lines or alter a rendered frame's line structure.
|
|
28
|
+
*/
|
|
29
|
+
export function sanitizeTerminalLine(value: string): string {
|
|
30
|
+
return sanitizeTerminalText(value).replace(/\n+/g, " ");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function skipEscape(value: string, index: number): number {
|
|
34
|
+
const next = value.charCodeAt(index + 1);
|
|
35
|
+
if (next === 91) return skipCsi(value, index + 2);
|
|
36
|
+
if (next === 93 || next === 80 || next === 88 || next === 94 || next === 95)
|
|
37
|
+
return skipString(value, index + 2);
|
|
38
|
+
return index + 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function skipCsi(value: string, index: number): number {
|
|
42
|
+
while (index < value.length) {
|
|
43
|
+
const code = value.charCodeAt(index);
|
|
44
|
+
if (code >= 64 && code <= 126) return index;
|
|
45
|
+
index += 1;
|
|
46
|
+
}
|
|
47
|
+
return value.length;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function skipString(value: string, index: number): number {
|
|
51
|
+
while (index < value.length) {
|
|
52
|
+
const code = value.charCodeAt(index);
|
|
53
|
+
if (code === 7 || code === 0x9c) return index;
|
|
54
|
+
if (code === 27 && value.charCodeAt(index + 1) === 92) return index + 1;
|
|
55
|
+
index += 1;
|
|
56
|
+
}
|
|
57
|
+
return value.length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isPrintable(code: number): boolean {
|
|
61
|
+
return code >= 32 && code !== 127 && (code < 0x80 || code > 0x9f);
|
|
62
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The content of one drill-in transcript overlay: the status header of the node's
|
|
3
|
+
* owning Agent, then that Agent's session transcript.
|
|
4
|
+
*
|
|
5
|
+
* A nested node runs with `--no-session`, so its turns live inside the owning
|
|
6
|
+
* Agent's session file; the node path stays the identity the overlay shows, and
|
|
7
|
+
* the owning Agent's session file is the read source.
|
|
8
|
+
*/
|
|
9
|
+
import type { AgentInfo } from "@yaag/runtime";
|
|
10
|
+
import { parseTranscript } from "./session-transcript.ts";
|
|
11
|
+
import { NO_TRANSCRIPT_STUB, renderTranscript, transcriptHeader } from "./transcript-render.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One transcript-content request for a single node.
|
|
15
|
+
*
|
|
16
|
+
* Contract: `nodePath` is the identity the header shows; `agent` supplies the
|
|
17
|
+
* header facts of the owning Agent; `session` is that Agent's session body.
|
|
18
|
+
* Failure mode: a null or unparsable `session` renders `NO_TRANSCRIPT_STUB`
|
|
19
|
+
* under the same header; no input makes the renderer throw.
|
|
20
|
+
*/
|
|
21
|
+
export interface NodeTranscriptOptions {
|
|
22
|
+
/** The node path the overlay is titled with (spec §1 node-path grammar). */
|
|
23
|
+
readonly nodePath: string;
|
|
24
|
+
/** The owning Agent's observer projection, for the header facts. */
|
|
25
|
+
readonly agent: AgentInfo;
|
|
26
|
+
/** The owning Agent's session file body, or null when it is missing. */
|
|
27
|
+
readonly session: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Renders the overlay's content lines: header, a blank separator, transcript.
|
|
32
|
+
*
|
|
33
|
+
* Never throws: a missing or unparsable session body renders
|
|
34
|
+
* `NO_TRANSCRIPT_STUB` under a header that still names the node.
|
|
35
|
+
*/
|
|
36
|
+
export function nodeTranscriptLines(options: NodeTranscriptOptions): readonly string[] {
|
|
37
|
+
const body =
|
|
38
|
+
options.session === null
|
|
39
|
+
? [NO_TRANSCRIPT_STUB]
|
|
40
|
+
: renderTranscript(parseTranscript(options.session));
|
|
41
|
+
return [...transcriptHeader(options.nodePath, options.agent), "", ...body];
|
|
42
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read-only transcript overlay drawn over the Run tree (spec §1).
|
|
3
|
+
*
|
|
4
|
+
* Pure: two calls with the same content and scroll return equal arrays. It holds
|
|
5
|
+
* no clock and no I/O, so a host refreshes a tailed session file by re-rendering.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { renderFramedBox } from "./overlay-frame.ts";
|
|
9
|
+
import { resolveOffset, type Scroll } from "./overlay-scroll.ts";
|
|
10
|
+
|
|
11
|
+
const FOOTER = " ↑↓ scroll c copy path esc back";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One transcript-overlay render request.
|
|
15
|
+
*
|
|
16
|
+
* Contract: `content` and `scroll` decide the visible slice; `rows` is the
|
|
17
|
+
* content row budget; `width` is the exact visible width of every returned
|
|
18
|
+
* line. Failure mode: empty `content`, a `rows` of zero or less, and a scroll
|
|
19
|
+
* offset past the end all draw a valid frame instead of throwing.
|
|
20
|
+
*/
|
|
21
|
+
export interface TranscriptOverlayOptions {
|
|
22
|
+
/** The node path the overlay is titled with (spec §1 node-path grammar). */
|
|
23
|
+
readonly nodePath: string;
|
|
24
|
+
/** Already-rendered content lines, from `nodeTranscriptLines`. */
|
|
25
|
+
readonly content: readonly string[];
|
|
26
|
+
readonly scroll: Scroll;
|
|
27
|
+
/** Content rows the overlay may draw; a zero or negative value draws one. */
|
|
28
|
+
readonly rows: number;
|
|
29
|
+
readonly width: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Draws the framed, scrolled overlay for one node's transcript. */
|
|
33
|
+
export function renderTranscriptOverlay(options: TranscriptOverlayOptions): readonly string[] {
|
|
34
|
+
const rows = Math.max(1, Math.floor(options.rows));
|
|
35
|
+
const offset = resolveOffset(options.scroll, options.content.length, rows);
|
|
36
|
+
return renderFramedBox({
|
|
37
|
+
title: `${options.nodePath} ─ transcript`,
|
|
38
|
+
lines: options.content.slice(offset, offset + rows),
|
|
39
|
+
footer: `${FOOTER} ${options.scroll.following ? "following" : "paused"}`,
|
|
40
|
+
width: options.width,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders one Agent's Peek: a status header folded from its Summary record, then
|
|
3
|
+
* its transcript with every item truncated to a few lines. Pure string work — no
|
|
4
|
+
* clock, no I/O — so the viewer can re-render a tailed session file on a timer.
|
|
5
|
+
*/
|
|
6
|
+
import type { AgentInfo } from "@yaag/runtime";
|
|
7
|
+
import type { TranscriptItem } from "./session-transcript.ts";
|
|
8
|
+
|
|
9
|
+
/** Shown in place of the transcript when no session file backs an Agent. */
|
|
10
|
+
export const NO_TRANSCRIPT_STUB =
|
|
11
|
+
"(no transcript available — this Agent has no readable session file)";
|
|
12
|
+
|
|
13
|
+
export interface TranscriptViewOptions {
|
|
14
|
+
/** Lines kept per transcript item before an elision marker; defaults to 4. */
|
|
15
|
+
readonly maxLinesPerItem?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DEFAULT_MAX_LINES = 4;
|
|
19
|
+
|
|
20
|
+
/** A one-line-per-fact status header for an Agent, from its Summary projection. */
|
|
21
|
+
export function transcriptHeader(name: string, agent: AgentInfo): readonly string[] {
|
|
22
|
+
return [
|
|
23
|
+
`Agent ${name} — ${agent.state}${activitySuffix(agent)}`,
|
|
24
|
+
` ask: ${askLabel(agent)}`,
|
|
25
|
+
` usage: ${usageLabel(agent)}`,
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Renders transcript items, each labelled and truncated to `maxLinesPerItem`.
|
|
31
|
+
*
|
|
32
|
+
* An empty item list renders the `NO_TRANSCRIPT_STUB` line, so a Cassette-playback
|
|
33
|
+
* Agent or an unreadable file is labelled rather than hidden.
|
|
34
|
+
*/
|
|
35
|
+
export function renderTranscript(
|
|
36
|
+
items: readonly TranscriptItem[],
|
|
37
|
+
options: TranscriptViewOptions = {},
|
|
38
|
+
): readonly string[] {
|
|
39
|
+
if (items.length === 0) return [NO_TRANSCRIPT_STUB];
|
|
40
|
+
const max = options.maxLinesPerItem ?? DEFAULT_MAX_LINES;
|
|
41
|
+
const lines: string[] = [];
|
|
42
|
+
for (const item of items) {
|
|
43
|
+
lines.push(label(item));
|
|
44
|
+
lines.push(...truncate(bodyText(item), max));
|
|
45
|
+
}
|
|
46
|
+
return lines;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function label(item: TranscriptItem): string {
|
|
50
|
+
switch (item.kind) {
|
|
51
|
+
case "user":
|
|
52
|
+
return "▶ user";
|
|
53
|
+
case "assistant":
|
|
54
|
+
return "◀ assistant";
|
|
55
|
+
case "thinking":
|
|
56
|
+
return "· thinking";
|
|
57
|
+
case "tool-call":
|
|
58
|
+
return `⚙ tool → ${item.name}(${item.args})`;
|
|
59
|
+
case "tool-result":
|
|
60
|
+
return `⚑ result ← ${item.name}${item.isError ? " [error]" : ""}`;
|
|
61
|
+
default: {
|
|
62
|
+
const never: never = item;
|
|
63
|
+
throw new Error(`unhandled transcript item: ${JSON.stringify(never)}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function bodyText(item: TranscriptItem): string {
|
|
69
|
+
switch (item.kind) {
|
|
70
|
+
case "user":
|
|
71
|
+
case "assistant":
|
|
72
|
+
case "thinking":
|
|
73
|
+
return item.text;
|
|
74
|
+
case "tool-call":
|
|
75
|
+
return "";
|
|
76
|
+
case "tool-result":
|
|
77
|
+
return item.text;
|
|
78
|
+
default: {
|
|
79
|
+
const never: never = item;
|
|
80
|
+
throw new Error(`unhandled transcript item: ${JSON.stringify(never)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function truncate(text: string, max: number): readonly string[] {
|
|
86
|
+
if (text === "") return [];
|
|
87
|
+
const all = text.split("\n").map((line) => ` ${line}`);
|
|
88
|
+
if (all.length <= max) return all;
|
|
89
|
+
return [...all.slice(0, max), ` … (${all.length - max} more lines)`];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function activitySuffix(agent: AgentInfo): string {
|
|
93
|
+
const activity = agent.activity;
|
|
94
|
+
if (activity === null) return "";
|
|
95
|
+
switch (activity.type) {
|
|
96
|
+
case "tool":
|
|
97
|
+
return ` · ${activity.name}`;
|
|
98
|
+
case "retrying":
|
|
99
|
+
return ` · retrying ${activity.attempt}/${activity.max}`;
|
|
100
|
+
default:
|
|
101
|
+
return ` · ${activity.type}`;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function askLabel(agent: AgentInfo): string {
|
|
106
|
+
if (agent.state === "asking") return `#${agent.askIndex} ${agent.promptGist}`;
|
|
107
|
+
if (agent.askIndex === null) return "none yet";
|
|
108
|
+
return `#${agent.askIndex} (settled)`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function usageLabel(agent: AgentInfo): string {
|
|
112
|
+
const cost = agent.cost === null ? "unknown" : `$${agent.cost.toFixed(4)}`;
|
|
113
|
+
const total = agent.tokens === null ? "unknown" : `${agent.tokens.total} tok`;
|
|
114
|
+
return `${total}, ${cost}${agent.incomplete ? " (floor)" : ""}`;
|
|
115
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type FoldState, visibleRows } from "./tree-fold.ts";
|
|
2
|
+
import type { TreeNode } from "./tree-node.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The ancestor chain of one node, root first and the node itself last, or null
|
|
6
|
+
* when no node carries the path.
|
|
7
|
+
*
|
|
8
|
+
* Ancestry is walked, never parsed out of the path string: a roll-up row's
|
|
9
|
+
* `agent#pruned` path is outside the node-path grammar (spec §1).
|
|
10
|
+
*/
|
|
11
|
+
export function nodeChain(tree: readonly TreeNode[], path: string): readonly TreeNode[] | null {
|
|
12
|
+
for (const node of tree) {
|
|
13
|
+
if (node.path === path) return [node];
|
|
14
|
+
const below = nodeChain(node.children, path);
|
|
15
|
+
if (below !== null) return [node, ...below];
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The node at one path, or undefined when no node carries it. */
|
|
21
|
+
export function nodeAt(tree: readonly TreeNode[], path: string): TreeNode | undefined {
|
|
22
|
+
return nodeChain(tree, path)?.at(-1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The parent of one path; undefined for a root node or an unknown path. */
|
|
26
|
+
export function parentOf(tree: readonly TreeNode[], path: string): TreeNode | undefined {
|
|
27
|
+
return nodeChain(tree, path)?.at(-2);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The visible node the cursor rests on.
|
|
32
|
+
*
|
|
33
|
+
* A cursor over a node the current fold hides resolves to its deepest visible
|
|
34
|
+
* ancestor. This covers both a user fold and the default policy collapsing a
|
|
35
|
+
* branch on its own once the nested work settles (spec §1). An unknown path,
|
|
36
|
+
* or none, resolves to the first visible row, so the cursor never disappears.
|
|
37
|
+
* Returns undefined only for an empty tree.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveSelection(
|
|
40
|
+
tree: readonly TreeNode[],
|
|
41
|
+
fold: FoldState,
|
|
42
|
+
path: string | undefined,
|
|
43
|
+
): string | undefined {
|
|
44
|
+
const visible = visibleRows(tree, fold);
|
|
45
|
+
const first = visible[0]?.node.path;
|
|
46
|
+
if (path === undefined) return first;
|
|
47
|
+
const chain = nodeChain(tree, path);
|
|
48
|
+
if (chain === null) return first;
|
|
49
|
+
const drawn = new Set(visible.map((row) => row.node.path));
|
|
50
|
+
for (const node of [...chain].reverse()) {
|
|
51
|
+
if (drawn.has(node.path)) return node.path;
|
|
52
|
+
}
|
|
53
|
+
return first;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Moves the cursor `delta` visible rows from where it currently resolves.
|
|
58
|
+
*
|
|
59
|
+
* The result clamps to the first and the last visible row — the cursor does
|
|
60
|
+
* not wrap. Returns undefined only for an empty tree.
|
|
61
|
+
*/
|
|
62
|
+
export function moveSelection(
|
|
63
|
+
tree: readonly TreeNode[],
|
|
64
|
+
fold: FoldState,
|
|
65
|
+
path: string | undefined,
|
|
66
|
+
delta: number,
|
|
67
|
+
): string | undefined {
|
|
68
|
+
const visible = visibleRows(tree, fold);
|
|
69
|
+
if (visible.length === 0) return undefined;
|
|
70
|
+
const current = resolveSelection(tree, fold, path);
|
|
71
|
+
const at = visible.findIndex((row) => row.node.path === current);
|
|
72
|
+
if (at === -1) return visible[0]?.node.path;
|
|
73
|
+
const next = Math.min(visible.length - 1, Math.max(0, at + delta));
|
|
74
|
+
return visible[next]?.node.path;
|
|
75
|
+
}
|
package/src/tree-fold.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { TreeNode } from "./tree-node.ts";
|
|
2
|
+
|
|
3
|
+
/** Explicit user folds, layered over the default policy (ticket 03 writes here). */
|
|
4
|
+
export interface FoldState {
|
|
5
|
+
readonly overrides: ReadonlyMap<string, boolean>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** The fold state before the user folds anything. */
|
|
9
|
+
export function emptyFold(): FoldState {
|
|
10
|
+
return { overrides: new Map() };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether one node draws its children.
|
|
15
|
+
*
|
|
16
|
+
* Default policy: the tree collapses to Agent level, except the paths that
|
|
17
|
+
* lead to live nested work, so running Nested Nodes are always visible
|
|
18
|
+
* (spec §1). An Agent with no running Nested Node below it stays collapsed,
|
|
19
|
+
* even when it is settled or asking. A user override for the node's path wins
|
|
20
|
+
* over the default.
|
|
21
|
+
*/
|
|
22
|
+
export function isExpanded(node: TreeNode, fold: FoldState): boolean {
|
|
23
|
+
const override = fold.overrides.get(node.path);
|
|
24
|
+
if (override !== undefined) return override;
|
|
25
|
+
if (node.children.length === 0) return false;
|
|
26
|
+
return leadsToRunningNested(node);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One visible row: the node plus the depth and ancestry the renderer draws. */
|
|
30
|
+
export interface VisibleRow {
|
|
31
|
+
readonly node: TreeNode;
|
|
32
|
+
readonly depth: number;
|
|
33
|
+
readonly expanded: boolean;
|
|
34
|
+
readonly hasChildren: boolean;
|
|
35
|
+
/** True for the last child at each ancestor level, for `│`/`└` prefixes. */
|
|
36
|
+
readonly lastAtDepth: readonly boolean[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Flattens the tree into the rows the renderer draws, in depth-first order.
|
|
41
|
+
*
|
|
42
|
+
* A collapsed node contributes its own row only. The row order and the
|
|
43
|
+
* `lastAtDepth` flags are a pure function of the tree and the fold state.
|
|
44
|
+
*/
|
|
45
|
+
export function visibleRows(tree: readonly TreeNode[], fold: FoldState): readonly VisibleRow[] {
|
|
46
|
+
const rows: VisibleRow[] = [];
|
|
47
|
+
collect(tree, fold, 0, [], rows);
|
|
48
|
+
return rows;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function collect(
|
|
52
|
+
nodes: readonly TreeNode[],
|
|
53
|
+
fold: FoldState,
|
|
54
|
+
depth: number,
|
|
55
|
+
ancestry: readonly boolean[],
|
|
56
|
+
rows: VisibleRow[],
|
|
57
|
+
): void {
|
|
58
|
+
for (const [position, node] of nodes.entries()) {
|
|
59
|
+
const last = position === nodes.length - 1;
|
|
60
|
+
const lastAtDepth = [...ancestry, last];
|
|
61
|
+
const expanded = isExpanded(node, fold);
|
|
62
|
+
rows.push({
|
|
63
|
+
node,
|
|
64
|
+
depth,
|
|
65
|
+
expanded,
|
|
66
|
+
hasChildren: node.children.length > 0,
|
|
67
|
+
lastAtDepth,
|
|
68
|
+
});
|
|
69
|
+
if (expanded) collect(node.children, fold, depth + 1, lastAtDepth, rows);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function leadsToRunningNested(node: TreeNode): boolean {
|
|
74
|
+
for (const child of node.children) {
|
|
75
|
+
if (child.kind === "nested" && child.state === "running") return true;
|
|
76
|
+
if (leadsToRunningNested(child)) return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { TreeNodeState } from "./tree-node.ts";
|
|
2
|
+
|
|
3
|
+
/** The glyphs the normative tree mockup uses (`.scratch/run-tree-tui/assets`). */
|
|
4
|
+
export const GLYPHS = {
|
|
5
|
+
expanded: "▾",
|
|
6
|
+
collapsed: "▸",
|
|
7
|
+
running: "●",
|
|
8
|
+
ok: "✔",
|
|
9
|
+
failed: "✖",
|
|
10
|
+
idle: "○",
|
|
11
|
+
branch: "└",
|
|
12
|
+
trunk: "│",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
/** The state glyph one tree row draws before its label. */
|
|
16
|
+
export function stateGlyph(state: TreeNodeState): string {
|
|
17
|
+
switch (state) {
|
|
18
|
+
case "running":
|
|
19
|
+
return GLYPHS.running;
|
|
20
|
+
case "exited":
|
|
21
|
+
return GLYPHS.ok;
|
|
22
|
+
case "failed":
|
|
23
|
+
return GLYPHS.failed;
|
|
24
|
+
case "idle":
|
|
25
|
+
return GLYPHS.idle;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The fold glyph one tree row draws: expanded, collapsed, or nothing for a leaf. */
|
|
30
|
+
export function foldGlyph(hasChildren: boolean, expanded: boolean): string {
|
|
31
|
+
if (!hasChildren) return " ";
|
|
32
|
+
return expanded ? GLYPHS.expanded : GLYPHS.collapsed;
|
|
33
|
+
}
|
package/src/tree-keys.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { getKeybindings } from "@earendil-works/pi-tui";
|
|
2
|
+
import { type KeyBinding, type NamedKeybindings, routeKey } from "./key-router.ts";
|
|
3
|
+
|
|
4
|
+
/** One navigation gesture the tree consumes (spec §2). */
|
|
5
|
+
export type TreeAction = "selectUp" | "selectDown" | "collapse" | "expand";
|
|
6
|
+
|
|
7
|
+
/** One row of the tree key table. */
|
|
8
|
+
export type TreeKeyBinding = KeyBinding<TreeAction>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The one key table the Host Session view and the standalone CLI both route
|
|
12
|
+
* through (spec §2).
|
|
13
|
+
*
|
|
14
|
+
* Named entries come first, so a user who remaps `tui.select.down` onto an
|
|
15
|
+
* arrow still gets the move rather than the fold.
|
|
16
|
+
*/
|
|
17
|
+
export const TREE_KEY_TABLE: readonly TreeKeyBinding[] = [
|
|
18
|
+
{ action: "selectUp", named: "tui.select.up", vim: ["k"] },
|
|
19
|
+
{ action: "selectDown", named: "tui.select.down", vim: ["j"] },
|
|
20
|
+
{ action: "collapse", keys: ["left"], vim: ["h"] },
|
|
21
|
+
{ action: "expand", keys: ["right"], vim: ["l"] },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolves one input byte string to a tree action.
|
|
26
|
+
*
|
|
27
|
+
* Returns undefined when the table binds nothing for the input, so the tree
|
|
28
|
+
* consumes only its own keys and shadows no other pi default binding.
|
|
29
|
+
*/
|
|
30
|
+
export function routeTreeKey(data: string, keybindings: NamedKeybindings): TreeAction | undefined {
|
|
31
|
+
return routeKey(data, keybindings, TREE_KEY_TABLE);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The standalone CLI's matcher: pi-tui's process-global keybindings manager,
|
|
36
|
+
* which already carries the user's `keybindings.json` overrides.
|
|
37
|
+
*
|
|
38
|
+
* The Host Session path passes pi's injected manager instead — the global is
|
|
39
|
+
* per pi-tui instance, so a component must never reach for it itself.
|
|
40
|
+
*/
|
|
41
|
+
export function cliKeybindings(): NamedKeybindings {
|
|
42
|
+
return getKeybindings();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type { NamedKeybindings } from "./key-router.ts";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { AgentInfo } from "@yaag/runtime";
|
|
2
|
+
import { costText, tokensText } from "./accounting-text.ts";
|
|
3
|
+
import { activityText } from "./activity-text.ts";
|
|
4
|
+
import type { AskRow } from "./ask-ledger.ts";
|
|
5
|
+
import { durationText } from "./duration-text.ts";
|
|
6
|
+
import { sanitizeTerminalLine } from "./terminal-text.ts";
|
|
7
|
+
import { graftNestedNodes } from "./tree-nested.ts";
|
|
8
|
+
import type { TreeNode } from "./tree-node.ts";
|
|
9
|
+
import type { TreeState } from "./tree-state.ts";
|
|
10
|
+
|
|
11
|
+
/** Render-time inputs the model needs; `now` keeps every builder clock-free. */
|
|
12
|
+
export interface TreeModelOptions {
|
|
13
|
+
readonly now: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Builds the Run's node tree from the folded state.
|
|
18
|
+
*
|
|
19
|
+
* Pure: the same state and options always build the same tree. Agents come in
|
|
20
|
+
* first-observed order, each Agent's children are its Ask rows, and each Ask's
|
|
21
|
+
* children are the Nested Nodes grafted under it by path. Pruned rows become
|
|
22
|
+
* one trailing "and N more finished" roll-up node.
|
|
23
|
+
*/
|
|
24
|
+
export function buildTree(state: TreeState, options: TreeModelOptions): readonly TreeNode[] {
|
|
25
|
+
const nodes: TreeNode[] = [];
|
|
26
|
+
for (const name of state.agentOrder) {
|
|
27
|
+
const agent = state.summary.agents[name];
|
|
28
|
+
if (agent === undefined) continue;
|
|
29
|
+
nodes.push(agentNode(state, name, agent, options.now));
|
|
30
|
+
}
|
|
31
|
+
return nodes;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function agentNode(state: TreeState, name: string, agent: AgentInfo, now: number): TreeNode {
|
|
35
|
+
const ledger = state.ledger(name);
|
|
36
|
+
const nested = graftNestedNodes(name, agent.nodes, now);
|
|
37
|
+
const gist = agent.activity === null ? null : activityText(agent.activity);
|
|
38
|
+
const children: TreeNode[] = ledger.rows.map((row) =>
|
|
39
|
+
askNode(name, row, nested.get(row.index) ?? [], now, gist),
|
|
40
|
+
);
|
|
41
|
+
const finished = agent.finishedNodesPruned + ledger.settledPruned;
|
|
42
|
+
if (finished > 0) children.push(rollup(`${name}#pruned`, `and ${finished} more finished`));
|
|
43
|
+
return {
|
|
44
|
+
path: sanitizeTerminalLine(name),
|
|
45
|
+
kind: "agent",
|
|
46
|
+
label: sanitizeTerminalLine(name),
|
|
47
|
+
state: agentState(agent),
|
|
48
|
+
facts: agentFacts(agent, state.settledAsks(name), now),
|
|
49
|
+
children,
|
|
50
|
+
activityGist: gist,
|
|
51
|
+
startedAt: agent.stateChangedAt,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function askNode(
|
|
56
|
+
agent: string,
|
|
57
|
+
row: AskRow,
|
|
58
|
+
children: readonly TreeNode[],
|
|
59
|
+
now: number,
|
|
60
|
+
activityGist: string | null,
|
|
61
|
+
): TreeNode {
|
|
62
|
+
const live = row.endedAt === null;
|
|
63
|
+
const dwell = live ? now - (row.startedAt ?? now) : row.endedAt - (row.startedAt ?? row.endedAt);
|
|
64
|
+
const facts = [durationText(dwell)];
|
|
65
|
+
if (row.replayed) facts.push("replayed");
|
|
66
|
+
return {
|
|
67
|
+
path: `${sanitizeTerminalLine(agent)}:${row.index}`,
|
|
68
|
+
kind: "ask",
|
|
69
|
+
label: `ask #${row.index + 1}${row.promptGist === "" ? "" : ` “${row.promptGist}”`}`,
|
|
70
|
+
state: live ? "running" : row.ok === false ? "failed" : "exited",
|
|
71
|
+
facts,
|
|
72
|
+
children,
|
|
73
|
+
activityGist: live ? activityGist : null,
|
|
74
|
+
startedAt: row.startedAt,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function rollup(path: string, label: string): TreeNode {
|
|
79
|
+
return {
|
|
80
|
+
path,
|
|
81
|
+
kind: "rollup",
|
|
82
|
+
label,
|
|
83
|
+
state: "idle",
|
|
84
|
+
facts: [],
|
|
85
|
+
children: [],
|
|
86
|
+
activityGist: null,
|
|
87
|
+
startedAt: null,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function agentState(agent: AgentInfo): TreeNode["state"] {
|
|
92
|
+
if (agent.state === "exited") return "exited";
|
|
93
|
+
return agent.state === "asking" ? "running" : "idle";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function agentFacts(agent: AgentInfo, settled: number, now: number): readonly string[] {
|
|
97
|
+
const facts: string[] = [];
|
|
98
|
+
if (agent.state === "asking")
|
|
99
|
+
facts.push(`ask #${agent.askIndex + 1} · ${durationText(now - (agent.askStartedAt ?? now))}`);
|
|
100
|
+
else if (agent.state === "exited")
|
|
101
|
+
facts.push(`exited · ${settled} ask${settled === 1 ? "" : "s"}`);
|
|
102
|
+
else facts.push(`idle · ${durationText(now - (agent.stateChangedAt ?? now))}`);
|
|
103
|
+
facts.push(
|
|
104
|
+
`${costText(agent.cost, agent.incomplete)} · ${tokensText(agent.tokens?.total ?? null)}`,
|
|
105
|
+
);
|
|
106
|
+
return facts;
|
|
107
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { moveSelection, nodeAt, parentOf, resolveSelection } from "./tree-cursor.ts";
|
|
2
|
+
import { emptyFold, type FoldState, isExpanded } from "./tree-fold.ts";
|
|
3
|
+
import type { TreeAction } from "./tree-keys.ts";
|
|
4
|
+
import type { TreeNode } from "./tree-node.ts";
|
|
5
|
+
|
|
6
|
+
/** Where the cursor rests, and which nodes the user folded by hand. */
|
|
7
|
+
export interface TreeView {
|
|
8
|
+
readonly selectedPath?: string;
|
|
9
|
+
readonly fold: FoldState;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The view before the user moves or folds anything. */
|
|
13
|
+
export function emptyView(): TreeView {
|
|
14
|
+
return { fold: emptyFold() };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Applies one navigation action and returns the next view.
|
|
19
|
+
*
|
|
20
|
+
* Pure: it copies the fold overrides rather than writing through them, so a
|
|
21
|
+
* caller keeping an earlier view keeps an unchanged one. `collapse` on an
|
|
22
|
+
* expanded node folds it; on a collapsed node or a leaf it moves the cursor to
|
|
23
|
+
* the parent instead (spec §2). `expand` on a leaf changes nothing.
|
|
24
|
+
*/
|
|
25
|
+
export function applyTreeAction(
|
|
26
|
+
tree: readonly TreeNode[],
|
|
27
|
+
view: TreeView,
|
|
28
|
+
action: TreeAction,
|
|
29
|
+
): TreeView {
|
|
30
|
+
const selected = resolveSelection(tree, view.fold, view.selectedPath);
|
|
31
|
+
switch (action) {
|
|
32
|
+
case "selectUp":
|
|
33
|
+
return { ...view, selectedPath: moveSelection(tree, view.fold, selected, -1) };
|
|
34
|
+
case "selectDown":
|
|
35
|
+
return { ...view, selectedPath: moveSelection(tree, view.fold, selected, 1) };
|
|
36
|
+
case "collapse":
|
|
37
|
+
return collapse(tree, view, selected);
|
|
38
|
+
case "expand":
|
|
39
|
+
return expand(tree, view, selected);
|
|
40
|
+
default: {
|
|
41
|
+
const never: never = action;
|
|
42
|
+
throw new Error(`unhandled tree action: ${JSON.stringify(never)}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function collapse(
|
|
48
|
+
tree: readonly TreeNode[],
|
|
49
|
+
view: TreeView,
|
|
50
|
+
selected: string | undefined,
|
|
51
|
+
): TreeView {
|
|
52
|
+
const node = selectedNode(tree, selected);
|
|
53
|
+
if (node === undefined) return { ...view, selectedPath: selected };
|
|
54
|
+
if (node.children.length > 0 && isExpanded(node, view.fold))
|
|
55
|
+
return { selectedPath: node.path, fold: withOverride(view.fold, node.path, false) };
|
|
56
|
+
return { ...view, selectedPath: parentOf(tree, node.path)?.path ?? node.path };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function expand(tree: readonly TreeNode[], view: TreeView, selected: string | undefined): TreeView {
|
|
60
|
+
const node = selectedNode(tree, selected);
|
|
61
|
+
if (node === undefined || node.children.length === 0) return { ...view, selectedPath: selected };
|
|
62
|
+
return { selectedPath: node.path, fold: withOverride(view.fold, node.path, true) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function selectedNode(
|
|
66
|
+
tree: readonly TreeNode[],
|
|
67
|
+
selected: string | undefined,
|
|
68
|
+
): TreeNode | undefined {
|
|
69
|
+
return selected === undefined ? undefined : nodeAt(tree, selected);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function withOverride(fold: FoldState, path: string, expanded: boolean): FoldState {
|
|
73
|
+
const overrides = new Map(fold.overrides);
|
|
74
|
+
overrides.set(path, expanded);
|
|
75
|
+
return { overrides };
|
|
76
|
+
}
|