@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,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The drill-in layer state machine (spec §1): tree → actions menu → transcript
|
|
3
|
+
* overlay, with `esc` backing out exactly one level.
|
|
4
|
+
*
|
|
5
|
+
* Pure and I/O-free. Every effect it can ask for is read-only — copy a path,
|
|
6
|
+
* open an editor, load a transcript, drop focus — so no gesture in this reducer
|
|
7
|
+
* can send to an Agent or stop a Run (ADR — a Peek observes, it never sends).
|
|
8
|
+
*/
|
|
9
|
+
import type { DrillAction, MenuAction, OverlayAction } from "./drill-keys.ts";
|
|
10
|
+
import { moveMenuCursor, NODE_ACTIONS } from "./node-actions.ts";
|
|
11
|
+
import { initialScroll, type Scroll, scrollBy } from "./overlay-scroll.ts";
|
|
12
|
+
|
|
13
|
+
/** Which layer holds input focus. */
|
|
14
|
+
export type DrillState =
|
|
15
|
+
| { readonly kind: "tree" }
|
|
16
|
+
| { readonly kind: "menu"; readonly path: string; readonly cursor: number }
|
|
17
|
+
| {
|
|
18
|
+
readonly kind: "transcript";
|
|
19
|
+
readonly path: string;
|
|
20
|
+
/** The layer `esc` returns to. */
|
|
21
|
+
readonly from: "tree" | "menu";
|
|
22
|
+
readonly scroll: Scroll;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** A read-only side effect the host performs after a transition. */
|
|
26
|
+
export type DrillEffect =
|
|
27
|
+
| { readonly kind: "copySessionPath"; readonly path: string }
|
|
28
|
+
| { readonly kind: "openSystemPrompt"; readonly path: string }
|
|
29
|
+
| { readonly kind: "loadTranscript"; readonly path: string }
|
|
30
|
+
| { readonly kind: "dropFocus" };
|
|
31
|
+
|
|
32
|
+
/** Everything a transition needs beyond the state: the selection and viewport. */
|
|
33
|
+
export interface DrillContext {
|
|
34
|
+
/** The tree cursor's node path, absent when the tree has no selection. */
|
|
35
|
+
readonly selectedPath: string | undefined;
|
|
36
|
+
/** Content lines currently loaded for the overlay. */
|
|
37
|
+
readonly total: number;
|
|
38
|
+
/** Content rows the overlay draws. */
|
|
39
|
+
readonly rows: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One transition: the next state, plus the effect the host must perform. */
|
|
43
|
+
export interface DrillTransition {
|
|
44
|
+
readonly state: DrillState;
|
|
45
|
+
readonly effect?: DrillEffect;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Any gesture the drill-in consumes, across its three layers. */
|
|
49
|
+
export type AnyDrillAction = DrillAction | MenuAction | OverlayAction;
|
|
50
|
+
|
|
51
|
+
/** The tree layer, with no menu and no overlay open. */
|
|
52
|
+
export function emptyDrill(): DrillState {
|
|
53
|
+
return { kind: "tree" };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Applies one gesture to the drill state.
|
|
58
|
+
*
|
|
59
|
+
* A gesture that belongs to another layer returns the state unchanged and no
|
|
60
|
+
* effect, so a host may route the same byte through every layer safely.
|
|
61
|
+
*/
|
|
62
|
+
export function applyDrillAction(
|
|
63
|
+
state: DrillState,
|
|
64
|
+
action: AnyDrillAction,
|
|
65
|
+
context: DrillContext,
|
|
66
|
+
): DrillTransition {
|
|
67
|
+
switch (state.kind) {
|
|
68
|
+
case "tree":
|
|
69
|
+
return fromTree(action, context);
|
|
70
|
+
case "menu":
|
|
71
|
+
return fromMenu(state, action);
|
|
72
|
+
case "transcript":
|
|
73
|
+
return fromTranscript(state, action, context);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fromTree(action: AnyDrillAction, context: DrillContext): DrillTransition {
|
|
78
|
+
const tree: DrillState = { kind: "tree" };
|
|
79
|
+
if (action === "back") return { state: tree, effect: { kind: "dropFocus" } };
|
|
80
|
+
const path = context.selectedPath;
|
|
81
|
+
if (path === undefined) return { state: tree };
|
|
82
|
+
if (action === "openMenu") return { state: { kind: "menu", path, cursor: 0 } };
|
|
83
|
+
if (action === "openTranscript") return openTranscript(path, "tree");
|
|
84
|
+
return { state: tree };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function fromMenu(
|
|
88
|
+
state: { readonly kind: "menu"; readonly path: string; readonly cursor: number },
|
|
89
|
+
action: AnyDrillAction,
|
|
90
|
+
): DrillTransition {
|
|
91
|
+
switch (action) {
|
|
92
|
+
case "menuUp":
|
|
93
|
+
return { state: { ...state, cursor: moveMenuCursor(state.cursor, -1) } };
|
|
94
|
+
case "menuDown":
|
|
95
|
+
return { state: { ...state, cursor: moveMenuCursor(state.cursor, 1) } };
|
|
96
|
+
case "cancel":
|
|
97
|
+
return { state: { kind: "tree" } };
|
|
98
|
+
case "confirm":
|
|
99
|
+
return confirmMenu(state);
|
|
100
|
+
default:
|
|
101
|
+
return { state };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function confirmMenu(state: {
|
|
106
|
+
readonly kind: "menu";
|
|
107
|
+
readonly path: string;
|
|
108
|
+
readonly cursor: number;
|
|
109
|
+
}): DrillTransition {
|
|
110
|
+
switch (NODE_ACTIONS[state.cursor]?.action) {
|
|
111
|
+
case "viewTranscript":
|
|
112
|
+
return openTranscript(state.path, "menu");
|
|
113
|
+
case "copySessionPath":
|
|
114
|
+
return { state, effect: { kind: "copySessionPath", path: state.path } };
|
|
115
|
+
case "openSystemPrompt":
|
|
116
|
+
return { state, effect: { kind: "openSystemPrompt", path: state.path } };
|
|
117
|
+
default:
|
|
118
|
+
return { state };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function fromTranscript(
|
|
123
|
+
state: {
|
|
124
|
+
readonly kind: "transcript";
|
|
125
|
+
readonly path: string;
|
|
126
|
+
readonly from: "tree" | "menu";
|
|
127
|
+
readonly scroll: Scroll;
|
|
128
|
+
},
|
|
129
|
+
action: AnyDrillAction,
|
|
130
|
+
context: DrillContext,
|
|
131
|
+
): DrillTransition {
|
|
132
|
+
const page = Math.max(1, Math.floor(context.rows));
|
|
133
|
+
switch (action) {
|
|
134
|
+
case "scrollUp":
|
|
135
|
+
return { state: { ...state, scroll: scroll(state, -1, context) } };
|
|
136
|
+
case "scrollDown":
|
|
137
|
+
return { state: { ...state, scroll: scroll(state, 1, context) } };
|
|
138
|
+
case "pageUp":
|
|
139
|
+
return { state: { ...state, scroll: scroll(state, -page, context) } };
|
|
140
|
+
case "pageDown":
|
|
141
|
+
return { state: { ...state, scroll: scroll(state, page, context) } };
|
|
142
|
+
case "copyPath":
|
|
143
|
+
return { state, effect: { kind: "copySessionPath", path: state.path } };
|
|
144
|
+
case "close":
|
|
145
|
+
return {
|
|
146
|
+
state:
|
|
147
|
+
state.from === "menu" ? { kind: "menu", path: state.path, cursor: 0 } : { kind: "tree" },
|
|
148
|
+
};
|
|
149
|
+
default:
|
|
150
|
+
return { state };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function scroll(state: { readonly scroll: Scroll }, delta: number, context: DrillContext): Scroll {
|
|
155
|
+
return scrollBy(state.scroll, delta, context.total, Math.max(1, Math.floor(context.rows)));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function openTranscript(path: string, from: "tree" | "menu"): DrillTransition {
|
|
159
|
+
return {
|
|
160
|
+
state: { kind: "transcript", path, from, scroll: initialScroll() },
|
|
161
|
+
effect: { kind: "loadTranscript", path },
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formats a duration the way the Run tree mockup writes it: `45s` below one
|
|
3
|
+
* minute, `2m14s` above it, with the seconds zero-padded. A negative or
|
|
4
|
+
* non-finite duration formats as `0s`.
|
|
5
|
+
*/
|
|
6
|
+
export function durationText(ms: number): string {
|
|
7
|
+
if (!Number.isFinite(ms) || ms <= 0) return "0s";
|
|
8
|
+
const seconds = Math.floor(ms / 1_000);
|
|
9
|
+
if (seconds < 60) return `${seconds}s`;
|
|
10
|
+
const minutes = Math.floor(seconds / 60);
|
|
11
|
+
return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`;
|
|
12
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export { costText, tokensText } from "./accounting-text.ts";
|
|
2
|
+
export { activityText } from "./activity-text.ts";
|
|
3
|
+
export { AGENT_ASK_LEDGER_MAX, type AskLedger, type AskRow } from "./ask-ledger.ts";
|
|
4
|
+
export {
|
|
5
|
+
type CompactRenderOptions,
|
|
6
|
+
compactAgentLine,
|
|
7
|
+
renderCompact,
|
|
8
|
+
} from "./compact-render.ts";
|
|
9
|
+
export { DrillController, type DrillHost } from "./drill-controller.ts";
|
|
10
|
+
export {
|
|
11
|
+
DRILL_KEY_TABLE,
|
|
12
|
+
type DrillAction,
|
|
13
|
+
MENU_KEY_TABLE,
|
|
14
|
+
type MenuAction,
|
|
15
|
+
OVERLAY_KEY_TABLE,
|
|
16
|
+
type OverlayAction,
|
|
17
|
+
routeDrillKey,
|
|
18
|
+
routeMenuKey,
|
|
19
|
+
routeOverlayKey,
|
|
20
|
+
} from "./drill-keys.ts";
|
|
21
|
+
export {
|
|
22
|
+
type AnyDrillAction,
|
|
23
|
+
applyDrillAction,
|
|
24
|
+
type DrillContext,
|
|
25
|
+
type DrillEffect,
|
|
26
|
+
type DrillState,
|
|
27
|
+
type DrillTransition,
|
|
28
|
+
emptyDrill,
|
|
29
|
+
} from "./drill-state.ts";
|
|
30
|
+
export { durationText } from "./duration-text.ts";
|
|
31
|
+
export { type KeyBinding, type NamedKeybindings, routeKey } from "./key-router.ts";
|
|
32
|
+
export {
|
|
33
|
+
moveMenuCursor,
|
|
34
|
+
NODE_ACTIONS,
|
|
35
|
+
type NodeAction,
|
|
36
|
+
type NodeActionRow,
|
|
37
|
+
renderActionsMenu,
|
|
38
|
+
} from "./node-actions.ts";
|
|
39
|
+
export { agentOfPath, type NodePathSegment, parseNodePath } from "./node-path-parse.ts";
|
|
40
|
+
export { renderNodeTable } from "./node-table.ts";
|
|
41
|
+
export { type FramedBoxOptions, renderFramedBox } from "./overlay-frame.ts";
|
|
42
|
+
export {
|
|
43
|
+
initialScroll,
|
|
44
|
+
maxOffset,
|
|
45
|
+
resolveOffset,
|
|
46
|
+
type Scroll,
|
|
47
|
+
scrollBy,
|
|
48
|
+
} from "./overlay-scroll.ts";
|
|
49
|
+
export {
|
|
50
|
+
createRunTreeView,
|
|
51
|
+
type RunTreeView,
|
|
52
|
+
type RunTreeViewHost,
|
|
53
|
+
type RunTreeViewOptions,
|
|
54
|
+
type RunViewExit,
|
|
55
|
+
type RunViewSurface,
|
|
56
|
+
} from "./run-tree-view.ts";
|
|
57
|
+
export { type RunViewResult, resultText, STDERR_TAIL_LINES } from "./run-view-result.ts";
|
|
58
|
+
export {
|
|
59
|
+
applyRunViewAction,
|
|
60
|
+
livePhase,
|
|
61
|
+
type RunPhase,
|
|
62
|
+
type RunViewAction,
|
|
63
|
+
type RunViewEffect,
|
|
64
|
+
type RunViewOutcome,
|
|
65
|
+
type RunViewTransition,
|
|
66
|
+
} from "./run-view-state.ts";
|
|
67
|
+
export { routeSessionKey, SESSION_KEY_TABLE, type SessionAction } from "./session-keys.ts";
|
|
68
|
+
export {
|
|
69
|
+
extractSystemPrompt,
|
|
70
|
+
parseTranscript,
|
|
71
|
+
type TranscriptItem,
|
|
72
|
+
} from "./session-transcript.ts";
|
|
73
|
+
export { renderSnapshot, type SnapshotRenderOptions } from "./snapshot-render.ts";
|
|
74
|
+
export {
|
|
75
|
+
moveStopCursor,
|
|
76
|
+
renderStopPrompt,
|
|
77
|
+
STOP_PROMPT_CHOICES,
|
|
78
|
+
type StopChoice,
|
|
79
|
+
type StopChoiceRow,
|
|
80
|
+
type StopPromptOptions,
|
|
81
|
+
} from "./stop-prompt.ts";
|
|
82
|
+
export { sanitizeTerminalLine, sanitizeTerminalText } from "./terminal-text.ts";
|
|
83
|
+
export { type NodeTranscriptOptions, nodeTranscriptLines } from "./transcript-content.ts";
|
|
84
|
+
export {
|
|
85
|
+
renderTranscriptOverlay,
|
|
86
|
+
type TranscriptOverlayOptions,
|
|
87
|
+
} from "./transcript-overlay.ts";
|
|
88
|
+
export {
|
|
89
|
+
NO_TRANSCRIPT_STUB,
|
|
90
|
+
renderTranscript,
|
|
91
|
+
type TranscriptViewOptions,
|
|
92
|
+
transcriptHeader,
|
|
93
|
+
} from "./transcript-render.ts";
|
|
94
|
+
export {
|
|
95
|
+
moveSelection,
|
|
96
|
+
nodeAt,
|
|
97
|
+
nodeChain,
|
|
98
|
+
parentOf,
|
|
99
|
+
resolveSelection,
|
|
100
|
+
} from "./tree-cursor.ts";
|
|
101
|
+
export {
|
|
102
|
+
emptyFold,
|
|
103
|
+
type FoldState,
|
|
104
|
+
isExpanded,
|
|
105
|
+
type VisibleRow,
|
|
106
|
+
visibleRows,
|
|
107
|
+
} from "./tree-fold.ts";
|
|
108
|
+
export { GLYPHS } from "./tree-glyphs.ts";
|
|
109
|
+
export {
|
|
110
|
+
cliKeybindings,
|
|
111
|
+
routeTreeKey,
|
|
112
|
+
TREE_KEY_TABLE,
|
|
113
|
+
type TreeAction,
|
|
114
|
+
type TreeKeyBinding,
|
|
115
|
+
} from "./tree-keys.ts";
|
|
116
|
+
export { buildTree, type TreeModelOptions } from "./tree-model.ts";
|
|
117
|
+
export { applyTreeAction, emptyView, type TreeView } from "./tree-navigation.ts";
|
|
118
|
+
export { TreeNavigator, type TreeNavigatorOptions } from "./tree-navigator.ts";
|
|
119
|
+
export type { TreeNode, TreeNodeKind, TreeNodeState } from "./tree-node.ts";
|
|
120
|
+
export { renderTree, type TreeRenderOptions } from "./tree-render.ts";
|
|
121
|
+
export { TreeState, type TreeUpdate } from "./tree-state.ts";
|
|
122
|
+
export {
|
|
123
|
+
renderRunsWidget,
|
|
124
|
+
type WidgetRenderOptions,
|
|
125
|
+
type WidgetRun,
|
|
126
|
+
} from "./widget-render.ts";
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Keybinding, KeyId } from "@earendil-works/pi-tui";
|
|
2
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A named-binding matcher. pi's injected `KeybindingsManager` and pi-tui's
|
|
6
|
+
* `getKeybindings()` both satisfy it, so `packages/tui` needs no dependency on
|
|
7
|
+
* the pi coding agent.
|
|
8
|
+
*/
|
|
9
|
+
export interface NamedKeybindings {
|
|
10
|
+
matches(data: string, binding: string): boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** One row of a key table: a named binding, literal keys, and vim fallbacks. */
|
|
14
|
+
export interface KeyBinding<A extends string> {
|
|
15
|
+
readonly action: A;
|
|
16
|
+
/** The pi keybinding id, when a named action exists for the gesture. */
|
|
17
|
+
readonly named?: Keybinding;
|
|
18
|
+
/** Literal keys for a gesture pi names no action for. */
|
|
19
|
+
readonly keys?: readonly KeyId[];
|
|
20
|
+
/** Vim literals, tried only after every named binding and literal missed. */
|
|
21
|
+
readonly vim?: readonly KeyId[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolves one input byte string to an action of `table`.
|
|
26
|
+
*
|
|
27
|
+
* Returns undefined when the table binds nothing for the input, so a layer
|
|
28
|
+
* consumes only its own keys and shadows no other pi default binding. Named
|
|
29
|
+
* bindings and literal keys resolve in a first pass, so a user keybinding
|
|
30
|
+
* override always wins; vim literals apply only after that pass missed every
|
|
31
|
+
* entry (spec §2). The vim fallbacks guard only the bindings the table names —
|
|
32
|
+
* a vim literal can still collide with a remap of an action it does not name.
|
|
33
|
+
*/
|
|
34
|
+
export function routeKey<A extends string>(
|
|
35
|
+
data: string,
|
|
36
|
+
keybindings: NamedKeybindings,
|
|
37
|
+
table: readonly KeyBinding<A>[],
|
|
38
|
+
): A | undefined {
|
|
39
|
+
for (const entry of table) {
|
|
40
|
+
if (entry.named !== undefined && keybindings.matches(data, entry.named)) return entry.action;
|
|
41
|
+
if (entry.keys?.some((key) => matchesKey(data, key)) === true) return entry.action;
|
|
42
|
+
}
|
|
43
|
+
for (const entry of table) {
|
|
44
|
+
if (entry.vim?.some((key) => matchesKey(data, key)) === true) return entry.action;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-node actions menu of the drill-in (spec §1): the surviving read-only
|
|
3
|
+
* action set of spec §5. A Peek observes and never sends, so no action here can
|
|
4
|
+
* prompt an Agent or stop a Run (ADR — Peek).
|
|
5
|
+
*/
|
|
6
|
+
import { renderFramedBox } from "./overlay-frame.ts";
|
|
7
|
+
|
|
8
|
+
/** One thing a reader may do with the selected node. */
|
|
9
|
+
export type NodeAction = "viewTranscript" | "copySessionPath" | "openSystemPrompt";
|
|
10
|
+
|
|
11
|
+
/** One menu row: the action and the label the reader sees. */
|
|
12
|
+
export interface NodeActionRow {
|
|
13
|
+
readonly action: NodeAction;
|
|
14
|
+
readonly label: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The three read-only actions, in menu order (spec §5). */
|
|
18
|
+
export const NODE_ACTIONS: readonly NodeActionRow[] = [
|
|
19
|
+
{ action: "viewTranscript", label: "View transcript" },
|
|
20
|
+
{ action: "copySessionPath", label: "Copy session file path" },
|
|
21
|
+
{ action: "openSystemPrompt", label: "Open system prompt in editor" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const FOOTER = " ↑↓ move ↵ run esc back";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One actions-menu render request.
|
|
28
|
+
*
|
|
29
|
+
* Contract: `nodePath` titles the menu and is sanitized and truncated to the
|
|
30
|
+
* frame; `cursor` selects the highlighted row; `width` is the exact visible
|
|
31
|
+
* width of every returned line. Failure mode: a `cursor` outside the action
|
|
32
|
+
* list highlights no row, and a narrow or non-finite `width` degrades through
|
|
33
|
+
* `renderFramedBox` rather than throwing.
|
|
34
|
+
*/
|
|
35
|
+
export interface ActionsMenuOptions {
|
|
36
|
+
readonly nodePath: string;
|
|
37
|
+
readonly cursor: number;
|
|
38
|
+
readonly width: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Moves the menu cursor by `delta`, clamped to the action list — no wrap. */
|
|
42
|
+
export function moveMenuCursor(cursor: number, delta: number): number {
|
|
43
|
+
return Math.max(0, Math.min(NODE_ACTIONS.length - 1, cursor + delta));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Draws the framed actions menu, titled with the node path. */
|
|
47
|
+
export function renderActionsMenu(options: ActionsMenuOptions): readonly string[] {
|
|
48
|
+
const rows = NODE_ACTIONS.map(
|
|
49
|
+
(row, index) => `${index === options.cursor ? "❯" : " "} ${row.label}`,
|
|
50
|
+
);
|
|
51
|
+
return renderFramedBox({
|
|
52
|
+
title: `${options.nodePath} ─ actions`,
|
|
53
|
+
lines: rows,
|
|
54
|
+
footer: FOOTER,
|
|
55
|
+
width: options.width,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** One parsed segment of a node path: an agent name, optionally with its Ask index. */
|
|
2
|
+
export interface NodePathSegment {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
/** The 0-based wire Ask index, absent for a bare agent segment. */
|
|
5
|
+
readonly askIndex?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parses `name(:askIndex(/childName…))` into its segments.
|
|
10
|
+
*
|
|
11
|
+
* Returns null for an empty path, a malformed Ask index, or an inner segment
|
|
12
|
+
* that carries no Ask index, so a hostile or truncated `node_update.path`
|
|
13
|
+
* renders nothing rather than a forged row. Names never contain `:` or `/`:
|
|
14
|
+
* `sanitizeNodeName` (runtime) removes them.
|
|
15
|
+
*/
|
|
16
|
+
export function parseNodePath(path: string): readonly NodePathSegment[] | null {
|
|
17
|
+
if (path === "") return null;
|
|
18
|
+
const parts = path.split("/");
|
|
19
|
+
const segments: NodePathSegment[] = [];
|
|
20
|
+
for (const [position, part] of parts.entries()) {
|
|
21
|
+
const last = position === parts.length - 1;
|
|
22
|
+
const segment = parseSegment(part, last);
|
|
23
|
+
if (segment === null) return null;
|
|
24
|
+
segments.push(segment);
|
|
25
|
+
}
|
|
26
|
+
return segments;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseSegment(part: string, last: boolean): NodePathSegment | null {
|
|
30
|
+
const colon = part.indexOf(":");
|
|
31
|
+
if (colon === -1) return last && part !== "" ? { name: part } : null;
|
|
32
|
+
const name = part.slice(0, colon);
|
|
33
|
+
const askIndex = parseAskIndex(part.slice(colon + 1));
|
|
34
|
+
if (name === "" || askIndex === null) return null;
|
|
35
|
+
return { name, askIndex };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseAskIndex(text: string): number | null {
|
|
39
|
+
if (!/^\d+$/.test(text)) return null;
|
|
40
|
+
return Number.parseInt(text, 10);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The Agent that owns a node path — its first segment.
|
|
45
|
+
*
|
|
46
|
+
* A nested node has no session file of its own (it runs with `--no-session`),
|
|
47
|
+
* so its transcript and session path resolve through its owning Agent. Returns
|
|
48
|
+
* the empty string for the empty path; a roll-up marker (`agent#pruned`) keeps
|
|
49
|
+
* only the Agent name.
|
|
50
|
+
*/
|
|
51
|
+
export function agentOfPath(path: string): string {
|
|
52
|
+
const separator = path.search(/[:/#]/);
|
|
53
|
+
return separator === -1 ? path : path.slice(0, separator);
|
|
54
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AgentInfo, NodeInfo } from "@yaag/runtime";
|
|
2
|
+
import { sanitizeTerminalLine } from "./terminal-text.ts";
|
|
3
|
+
import { clamp } from "./tree-rows.ts";
|
|
4
|
+
|
|
5
|
+
/** Running Nested Nodes drawn for one Agent before the roll-up line. */
|
|
6
|
+
const RUNNING_NODE_LINES = 8;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Renders one Agent's Nested Node table as indented terminal lines.
|
|
10
|
+
*
|
|
11
|
+
* Running nodes come first and are capped. Each running node the cap hid is
|
|
12
|
+
* counted in one "and N more running" line, and each settled node, retained or
|
|
13
|
+
* pruned by the Summary, is counted in one "and N more finished" line. The two
|
|
14
|
+
* roll-ups stay separate, so a hidden running node is never reported as
|
|
15
|
+
* finished. Returns no lines for an Agent that owns no nodes.
|
|
16
|
+
*/
|
|
17
|
+
export function renderNodeTable(agent: AgentInfo, columns: number): readonly string[] {
|
|
18
|
+
const running = agent.nodes.filter((node) => node.state === "running");
|
|
19
|
+
const shown = running.slice(0, RUNNING_NODE_LINES);
|
|
20
|
+
const lines = shown.map((node) => clamp(` ▸ ${nodeLine(node)}`, columns));
|
|
21
|
+
const hiddenRunning = running.length - shown.length;
|
|
22
|
+
if (hiddenRunning > 0) {
|
|
23
|
+
lines.push(clamp(` and ${hiddenRunning} more running`, columns));
|
|
24
|
+
}
|
|
25
|
+
const finished = agent.nodes.length - running.length + agent.finishedNodesPruned;
|
|
26
|
+
if (finished > 0) lines.push(clamp(` and ${finished} more finished`, columns));
|
|
27
|
+
return lines;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function nodeLine(node: NodeInfo): string {
|
|
31
|
+
const path = sanitizeTerminalLine(node.path);
|
|
32
|
+
const gist = node.activityGist === null ? "" : sanitizeTerminalLine(node.activityGist);
|
|
33
|
+
return `${path} ${node.state}${gist === "" ? "" : ` · ${gist}`}`;
|
|
34
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { sanitizeTerminalLine } from "./terminal-text.ts";
|
|
3
|
+
import { clamp } from "./tree-rows.ts";
|
|
4
|
+
|
|
5
|
+
/** The box-drawing glyphs the normative overlay mockup uses. */
|
|
6
|
+
const FRAME = {
|
|
7
|
+
topLeft: "┌",
|
|
8
|
+
topRight: "┐",
|
|
9
|
+
bottomLeft: "└",
|
|
10
|
+
bottomRight: "┘",
|
|
11
|
+
side: "│",
|
|
12
|
+
rule: "─",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
/** The narrowest frame that still pads its content away from the borders. */
|
|
16
|
+
const PADDED_WIDTH = 6;
|
|
17
|
+
|
|
18
|
+
/** Used when a caller asks for a width that is not a finite number. */
|
|
19
|
+
const FALLBACK_WIDTH = PADDED_WIDTH;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One framed box request.
|
|
23
|
+
*
|
|
24
|
+
* Contract: `width` is the exact visible column count of every returned line,
|
|
25
|
+
* floored and clamped to at least one column. `title`, `lines`, and `footer`
|
|
26
|
+
* are untrusted text: each is sanitized to a single line and truncated to fit.
|
|
27
|
+
* Failure mode: a width that is not finite cannot be drawn, so it degrades to
|
|
28
|
+
* six columns instead of throwing.
|
|
29
|
+
*/
|
|
30
|
+
export interface FramedBoxOptions {
|
|
31
|
+
readonly title: string;
|
|
32
|
+
readonly lines: readonly string[];
|
|
33
|
+
/** A key-hint row drawn as the last content row; omitted when absent. */
|
|
34
|
+
readonly footer?: string;
|
|
35
|
+
readonly width: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Draws a titled box around already-rendered content lines.
|
|
40
|
+
*
|
|
41
|
+
* Every untrusted string is sanitized to one line and truncated to the frame, so
|
|
42
|
+
* a hostile transcript, prompt gist, or node path can neither forge a row nor
|
|
43
|
+
* emit an escape byte. Each returned line is exactly `width` visible columns
|
|
44
|
+
* wide, including the narrow widths 1 to 5: below six columns the frame drops
|
|
45
|
+
* its inner padding, and at one column it draws the left border only. A width
|
|
46
|
+
* that is not finite degrades to six columns; it never throws.
|
|
47
|
+
*/
|
|
48
|
+
export function renderFramedBox(options: FramedBoxOptions): readonly string[] {
|
|
49
|
+
const width = Number.isFinite(options.width)
|
|
50
|
+
? Math.max(1, Math.floor(options.width))
|
|
51
|
+
: FALLBACK_WIDTH;
|
|
52
|
+
const body = [...options.lines];
|
|
53
|
+
if (options.footer !== undefined) body.push(options.footer);
|
|
54
|
+
return [
|
|
55
|
+
titleLine(options.title, width),
|
|
56
|
+
...body.map((line) => contentLine(line, width)),
|
|
57
|
+
bottomLine(width),
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function titleLine(title: string, width: number): string {
|
|
62
|
+
if (width === 1) return FRAME.topLeft;
|
|
63
|
+
const inner = width - 2;
|
|
64
|
+
const text =
|
|
65
|
+
width >= PADDED_WIDTH
|
|
66
|
+
? fit(`${FRAME.rule} ${clamp(sanitizeTerminalLine(title), width - 5)} `, inner)
|
|
67
|
+
: "";
|
|
68
|
+
const fill = Math.max(0, inner - visibleWidth(text));
|
|
69
|
+
return `${FRAME.topLeft}${text}${FRAME.rule.repeat(fill)}${FRAME.topRight}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function contentLine(line: string, width: number): string {
|
|
73
|
+
if (width === 1) return FRAME.side;
|
|
74
|
+
const inner = width - 2;
|
|
75
|
+
const room = width >= PADDED_WIDTH ? inner - 2 : inner;
|
|
76
|
+
const text = room <= 0 ? "" : fit(clamp(sanitizeTerminalLine(line), room), room);
|
|
77
|
+
const pad = " ".repeat(Math.max(0, room - visibleWidth(text)));
|
|
78
|
+
const middle = width >= PADDED_WIDTH ? ` ${text}${pad} ` : `${text}${pad}`;
|
|
79
|
+
return `${FRAME.side}${middle}${FRAME.side}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function bottomLine(width: number): string {
|
|
83
|
+
if (width === 1) return FRAME.bottomLeft;
|
|
84
|
+
return `${FRAME.bottomLeft}${FRAME.rule.repeat(width - 2)}${FRAME.bottomRight}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Drops trailing characters until the text fits, for wide-glyph safety. */
|
|
88
|
+
function fit(text: string, room: number): string {
|
|
89
|
+
if (visibleWidth(text) <= room) return text;
|
|
90
|
+
let out = "";
|
|
91
|
+
let used = 0;
|
|
92
|
+
for (const character of text) {
|
|
93
|
+
const next = used + visibleWidth(character);
|
|
94
|
+
if (next > room) break;
|
|
95
|
+
out += character;
|
|
96
|
+
used = next;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure scroll model for a drill-in overlay: a follow-tail offset that pins
|
|
3
|
+
* to the bottom of a live transcript, releases when the reader scrolls up, and
|
|
4
|
+
* re-pins on return. Clock-free and I/O-free, so the follow rules are testable
|
|
5
|
+
* without a terminal.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** A viewport over rendered content: a top-line offset and whether it follows tail. */
|
|
9
|
+
export interface Scroll {
|
|
10
|
+
readonly offset: number;
|
|
11
|
+
readonly following: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A viewport that follows the tail of an empty document. */
|
|
15
|
+
export function initialScroll(): Scroll {
|
|
16
|
+
return { offset: 0, following: true };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The largest valid top offset for `rows` of `total` content lines. */
|
|
20
|
+
export function maxOffset(total: number, rows: number): number {
|
|
21
|
+
return Math.max(0, total - Math.max(1, rows));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolves the concrete top offset to render.
|
|
26
|
+
*
|
|
27
|
+
* A following viewport pins to the bottom as the transcript grows; a released
|
|
28
|
+
* one keeps its offset, clamped to the current content, so a shrinking or
|
|
29
|
+
* rewritten session file cannot scroll past the end.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveOffset(state: Scroll, total: number, rows: number): number {
|
|
32
|
+
const limit = maxOffset(total, rows);
|
|
33
|
+
return state.following ? limit : Math.min(state.offset, limit);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Applies a scroll of `delta` lines (negative is up), re-deriving follow state.
|
|
38
|
+
*
|
|
39
|
+
* Reaching the bottom resumes following; scrolling anywhere above it pauses, so
|
|
40
|
+
* a reader inspecting history is never yanked back down by a host refresh.
|
|
41
|
+
*/
|
|
42
|
+
export function scrollBy(state: Scroll, delta: number, total: number, rows: number): Scroll {
|
|
43
|
+
const limit = maxOffset(total, rows);
|
|
44
|
+
const from = state.following ? limit : Math.min(state.offset, limit);
|
|
45
|
+
const offset = Math.max(0, Math.min(limit, from + delta));
|
|
46
|
+
return { offset, following: offset >= limit };
|
|
47
|
+
}
|