@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,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Run view (spec §4): the interactive tree a blocking `yaag_run` opens in
|
|
3
|
+
* the Host Session, and the same component `/yaag` opens over a background
|
|
4
|
+
* Run, plus the Run's lifecycle over it.
|
|
5
|
+
*
|
|
6
|
+
* It layers the session gestures over the read-only drill-in: the stop prompt
|
|
7
|
+
* first while it is open, then the drill overlay or menu, then `q`/Ctrl-C, then
|
|
8
|
+
* the tree. Two rules are load-bearing:
|
|
9
|
+
*
|
|
10
|
+
* - `esc` never stops the Run. It reaches the session layer through no path:
|
|
11
|
+
* the session table is literal `q`/`ctrl+c` only (`session-keys.ts`), and the
|
|
12
|
+
* drill layers own `esc` before it. In the foreground surface `esc` never
|
|
13
|
+
* closes the view either; in the background surface `esc` at tree level
|
|
14
|
+
* closes a *settled* view, because there is nothing left to watch and no tool
|
|
15
|
+
* call to return. In a live background view `esc` still only backs out of
|
|
16
|
+
* overlays and focus, and `q` opens the same three-way prompt, where
|
|
17
|
+
* `Detach (keep running)` means "close the view, the Run keeps running".
|
|
18
|
+
* - `ctrl+c` is best-effort. Pi binds `app.clear` to `ctrl+c` at app level, so a
|
|
19
|
+
* Host Session may consume the byte before this component sees it; `q` is the
|
|
20
|
+
* gesture to document to users.
|
|
21
|
+
*
|
|
22
|
+
* The view owns no clock and no I/O of its own: every capability, including the
|
|
23
|
+
* reap-ladder stop, arrives through `RunTreeViewHost`.
|
|
24
|
+
*/
|
|
25
|
+
import { DrillController, type DrillHost } from "./drill-controller.ts";
|
|
26
|
+
import { routeDrillKey, routeMenuKey } from "./drill-keys.ts";
|
|
27
|
+
import { type RunViewResult, resultText } from "./run-view-result.ts";
|
|
28
|
+
import {
|
|
29
|
+
applyRunViewAction,
|
|
30
|
+
livePhase,
|
|
31
|
+
type RunPhase,
|
|
32
|
+
type RunViewEffect,
|
|
33
|
+
} from "./run-view-state.ts";
|
|
34
|
+
import { routeSessionKey } from "./session-keys.ts";
|
|
35
|
+
import { renderStopPrompt, STOP_PROMPT_CHOICES } from "./stop-prompt.ts";
|
|
36
|
+
import { buildTree } from "./tree-model.ts";
|
|
37
|
+
import { TreeNavigator } from "./tree-navigator.ts";
|
|
38
|
+
import { renderTree } from "./tree-render.ts";
|
|
39
|
+
import { clamp } from "./tree-rows.ts";
|
|
40
|
+
import type { TreeState } from "./tree-state.ts";
|
|
41
|
+
|
|
42
|
+
/** Which surface opened the view; it decides only the settled-`esc` rule. */
|
|
43
|
+
export type RunViewSurface = "foreground" | "background";
|
|
44
|
+
|
|
45
|
+
const SETTLED_FOOTER_KEYS = " ↑↓ move ←→ fold ↵ actions t transcript ";
|
|
46
|
+
|
|
47
|
+
// At tree level a settled background view closes on `esc`, so `esc back` would
|
|
48
|
+
// contradict `esc close`: the surface picks exactly one of them (spec §4).
|
|
49
|
+
function settledFooter(surface: RunViewSurface): string {
|
|
50
|
+
return `${SETTLED_FOOTER_KEYS}${surface === "background" ? "esc close q done" : "esc back q done"}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** How the reader left the view. */
|
|
54
|
+
export type RunViewExit = "dismissed" | "detached";
|
|
55
|
+
|
|
56
|
+
/** Every capability the foreground view needs from its host. */
|
|
57
|
+
export interface RunTreeViewHost extends Omit<DrillHost, "navigator" | "dropFocus"> {
|
|
58
|
+
/** Runs the reap ladder for this Run (ADR-0008). `esc` never reaches it. */
|
|
59
|
+
stop(): void;
|
|
60
|
+
/** Converts the Run to a background Run; the view then closes. */
|
|
61
|
+
detach(): void;
|
|
62
|
+
/** Resolves the `ctx.ui.custom()` promise with how the view ended. */
|
|
63
|
+
done(exit: RunViewExit): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What the foreground view needs to exist.
|
|
68
|
+
*
|
|
69
|
+
* The caller owns the `TreeState`; the view only reads it, so a redraw never
|
|
70
|
+
* changes the projection.
|
|
71
|
+
*/
|
|
72
|
+
export interface RunTreeViewOptions {
|
|
73
|
+
/** Ingested from Run start by the caller, so no early Ask is missed. */
|
|
74
|
+
readonly state: TreeState;
|
|
75
|
+
readonly host: RunTreeViewHost;
|
|
76
|
+
/** The Run id, e.g. `r1`; heads the tree. */
|
|
77
|
+
readonly label?: string;
|
|
78
|
+
/** Which surface opened the view; defaults to `"foreground"`. */
|
|
79
|
+
readonly surface?: RunViewSurface;
|
|
80
|
+
now?(): number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The component `ctx.ui.custom()` renders, plus the handles the tool drives. */
|
|
84
|
+
export interface RunTreeView {
|
|
85
|
+
render(width: number): string[];
|
|
86
|
+
handleInput(data: string): void;
|
|
87
|
+
invalidate(): void;
|
|
88
|
+
dispose(): void;
|
|
89
|
+
/** The Run settled: freeze the tree and show the Result region. */
|
|
90
|
+
settle(result: RunViewResult): void;
|
|
91
|
+
/** A pushed fd 3 update landed in the TreeState; redraw. */
|
|
92
|
+
touch(): void;
|
|
93
|
+
/**
|
|
94
|
+
* SIGINT (or the Run's AbortSignal) takes the same path as `q`: the first
|
|
95
|
+
* one opens the stop prompt, a second confirms Stop Run (spec §4). While the
|
|
96
|
+
* stop already runs, and on a settled view, it does nothing — a settled view
|
|
97
|
+
* closes on a keystroke only.
|
|
98
|
+
*/
|
|
99
|
+
interrupt(): void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Builds the foreground Run view over a `TreeState` the caller owns.
|
|
104
|
+
*
|
|
105
|
+
* `host.done` is called exactly once, on the reader's dismissal or detach; a
|
|
106
|
+
* host that closes the view for its own reason (an abort) calls `done` itself.
|
|
107
|
+
* Nothing here can prompt an Agent — the drill layer stays read-only, and the
|
|
108
|
+
* only write capability is the Run's own `stop` (spec §4).
|
|
109
|
+
*/
|
|
110
|
+
export function createRunTreeView(options: RunTreeViewOptions): RunTreeView {
|
|
111
|
+
const host = options.host;
|
|
112
|
+
const state = options.state;
|
|
113
|
+
const now = options.now ?? Date.now;
|
|
114
|
+
const surface: RunViewSurface = options.surface ?? "foreground";
|
|
115
|
+
const navigator = new TreeNavigator({
|
|
116
|
+
snapshot: () => buildTree(state, { now: now() }),
|
|
117
|
+
keybindings: host.keybindings,
|
|
118
|
+
});
|
|
119
|
+
// `esc` at tree level backs out of nothing in the foreground: it must not
|
|
120
|
+
// stop the Run and must not close the view (spec §4).
|
|
121
|
+
const drill = new DrillController({ ...host, navigator, dropFocus: () => {} });
|
|
122
|
+
let phase: RunPhase = livePhase();
|
|
123
|
+
let result: RunViewResult | undefined;
|
|
124
|
+
let exited = false;
|
|
125
|
+
|
|
126
|
+
const perform = (effect: RunViewEffect): void => {
|
|
127
|
+
switch (effect) {
|
|
128
|
+
case "stop":
|
|
129
|
+
host.stop();
|
|
130
|
+
return;
|
|
131
|
+
case "detach":
|
|
132
|
+
finish("detached");
|
|
133
|
+
return;
|
|
134
|
+
case "close":
|
|
135
|
+
finish("dismissed");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const finish = (exit: RunViewExit): void => {
|
|
141
|
+
if (exited) return;
|
|
142
|
+
exited = true;
|
|
143
|
+
if (exit === "detached") host.detach();
|
|
144
|
+
host.done(exit);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const apply = (action: Parameters<typeof applyRunViewAction>[1]): void => {
|
|
148
|
+
const transition = applyRunViewAction(phase, action);
|
|
149
|
+
const changed = transition.phase !== phase;
|
|
150
|
+
phase = transition.phase;
|
|
151
|
+
if (transition.effect !== undefined) perform(transition.effect);
|
|
152
|
+
if (changed || transition.effect !== undefined) host.requestRender();
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const promptInput = (data: string): boolean => {
|
|
156
|
+
if (phase.kind !== "confirm") return false;
|
|
157
|
+
const action = routeMenuKey(data, host.keybindings);
|
|
158
|
+
switch (action) {
|
|
159
|
+
case "menuUp":
|
|
160
|
+
apply({ kind: "move", delta: -1 });
|
|
161
|
+
return true;
|
|
162
|
+
case "menuDown":
|
|
163
|
+
apply({ kind: "move", delta: 1 });
|
|
164
|
+
return true;
|
|
165
|
+
case "cancel":
|
|
166
|
+
apply({ kind: "cancel" });
|
|
167
|
+
return true;
|
|
168
|
+
case "confirm": {
|
|
169
|
+
const choice = STOP_PROMPT_CHOICES[phase.cursor];
|
|
170
|
+
if (choice !== undefined) apply({ kind: "choose", choice: choice.action });
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
default:
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
render(width: number): string[] {
|
|
180
|
+
const lines = [
|
|
181
|
+
...renderTree(state, {
|
|
182
|
+
now: now(),
|
|
183
|
+
width,
|
|
184
|
+
...(options.label === undefined ? {} : { label: options.label }),
|
|
185
|
+
...(navigator.selectedPath === undefined ? {} : { selectedPath: navigator.selectedPath }),
|
|
186
|
+
fold: navigator.fold,
|
|
187
|
+
...(result === undefined ? {} : { result: resultText(result) }),
|
|
188
|
+
}),
|
|
189
|
+
];
|
|
190
|
+
if (phase.kind === "settled") lines[lines.length - 1] = clamp(settledFooter(surface), width);
|
|
191
|
+
const overlay = drill.overlayLines(width);
|
|
192
|
+
const framed =
|
|
193
|
+
overlay === undefined
|
|
194
|
+
? lines
|
|
195
|
+
: [...lines.slice(0, Math.max(0, lines.length - overlay.length)), ...overlay];
|
|
196
|
+
return phase.kind === "confirm"
|
|
197
|
+
? [...framed, ...renderStopPrompt({ cursor: phase.cursor, width })]
|
|
198
|
+
: framed;
|
|
199
|
+
},
|
|
200
|
+
handleInput(data: string): void {
|
|
201
|
+
if (promptInput(data)) return;
|
|
202
|
+
if (drill.layer.kind !== "tree") {
|
|
203
|
+
drill.handleInput(data);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (routeSessionKey(data, host.keybindings) === "quit") {
|
|
207
|
+
apply({ kind: "quit" });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (
|
|
211
|
+
surface === "background" &&
|
|
212
|
+
phase.kind === "settled" &&
|
|
213
|
+
routeDrillKey(data, host.keybindings) === "back"
|
|
214
|
+
) {
|
|
215
|
+
finish("dismissed");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
drill.handleInput(data);
|
|
219
|
+
},
|
|
220
|
+
invalidate(): void {},
|
|
221
|
+
dispose(): void {},
|
|
222
|
+
settle(settled: RunViewResult): void {
|
|
223
|
+
result = settled;
|
|
224
|
+
// `apply` requests the redraw for the phase change; one settlement is one
|
|
225
|
+
// render request.
|
|
226
|
+
apply({ kind: "settle", outcome: settled.kind });
|
|
227
|
+
},
|
|
228
|
+
touch(): void {
|
|
229
|
+
host.requestRender();
|
|
230
|
+
},
|
|
231
|
+
interrupt(): void {
|
|
232
|
+
if (phase.kind === "live") apply({ kind: "quit" });
|
|
233
|
+
else if (phase.kind === "confirm") apply({ kind: "choose", choice: "stop" });
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Result region's text policy for a settled Run (spec §1 "End state"): the
|
|
3
|
+
* Run's value, or the error plus a bounded stderr tail.
|
|
4
|
+
*
|
|
5
|
+
* Pure, and the only place that policy lives, so the component and the CLI show
|
|
6
|
+
* the same end state.
|
|
7
|
+
*/
|
|
8
|
+
import { sanitizeTerminalText } from "./terminal-text.ts";
|
|
9
|
+
|
|
10
|
+
/** How many trailing stderr lines a failed Run shows below its error. */
|
|
11
|
+
export const STDERR_TAIL_LINES = 12;
|
|
12
|
+
|
|
13
|
+
/** Shown for a Run that fulfilled with no printed value. */
|
|
14
|
+
const NO_VALUE = "(no value)";
|
|
15
|
+
|
|
16
|
+
/** How a Run ended, with everything the Result region needs to draw it. */
|
|
17
|
+
export type RunViewResult =
|
|
18
|
+
| { readonly kind: "fulfilled"; readonly result: string }
|
|
19
|
+
| { readonly kind: "failed"; readonly error: string; readonly stderrTail?: string };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Builds the `result` string for `renderTree`.
|
|
23
|
+
*
|
|
24
|
+
* Every part is sanitized, so a hostile Run value or stderr tail can neither
|
|
25
|
+
* emit an escape byte nor forge a region; the stderr tail is cut to its last
|
|
26
|
+
* `STDERR_TAIL_LINES` lines. Never throws.
|
|
27
|
+
*/
|
|
28
|
+
export function resultText(result: RunViewResult): string {
|
|
29
|
+
if (result.kind === "fulfilled") {
|
|
30
|
+
const value = sanitizeTerminalText(result.result).trimEnd();
|
|
31
|
+
return value === "" ? NO_VALUE : value;
|
|
32
|
+
}
|
|
33
|
+
const error = sanitizeTerminalText(result.error).trimEnd();
|
|
34
|
+
const tail = boundedTail(result.stderrTail);
|
|
35
|
+
return tail === "" ? error : `${error}\n${tail}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function boundedTail(stderrTail: string | undefined): string {
|
|
39
|
+
if (stderrTail === undefined) return "";
|
|
40
|
+
const text = sanitizeTerminalText(stderrTail).trimEnd();
|
|
41
|
+
if (text.trim() === "") return "";
|
|
42
|
+
return text.split("\n").slice(-STDERR_TAIL_LINES).join("\n");
|
|
43
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The foreground Run view's lifecycle reducer (spec §4): live → stop prompt →
|
|
3
|
+
* stopping → settled.
|
|
4
|
+
*
|
|
5
|
+
* Pure and I/O-free. `cancel` — the `esc` gesture — can only close the prompt:
|
|
6
|
+
* no action reachable from it produces `stop` or `close`, so `esc` can never
|
|
7
|
+
* stop a Run and can never dismiss the view.
|
|
8
|
+
*/
|
|
9
|
+
import { moveStopCursor, type StopChoice } from "./stop-prompt.ts";
|
|
10
|
+
|
|
11
|
+
/** How the Run ended, as the view shows it. */
|
|
12
|
+
export type RunViewOutcome = "fulfilled" | "failed";
|
|
13
|
+
|
|
14
|
+
/** Which lifecycle phase the view is in. */
|
|
15
|
+
export type RunPhase =
|
|
16
|
+
| { readonly kind: "live" }
|
|
17
|
+
| { readonly kind: "confirm"; readonly cursor: number }
|
|
18
|
+
| { readonly kind: "stopping" }
|
|
19
|
+
| { readonly kind: "settled"; readonly outcome: RunViewOutcome };
|
|
20
|
+
|
|
21
|
+
/** One gesture or event the session layer applies to the phase. */
|
|
22
|
+
export type RunViewAction =
|
|
23
|
+
| { readonly kind: "quit" }
|
|
24
|
+
| { readonly kind: "cancel" }
|
|
25
|
+
| { readonly kind: "move"; readonly delta: number }
|
|
26
|
+
| { readonly kind: "choose"; readonly choice: StopChoice }
|
|
27
|
+
| { readonly kind: "settle"; readonly outcome: RunViewOutcome };
|
|
28
|
+
|
|
29
|
+
/** What the host must do after a transition. */
|
|
30
|
+
export type RunViewEffect = "stop" | "detach" | "close";
|
|
31
|
+
|
|
32
|
+
/** One transition: the next phase, plus the effect the host must perform. */
|
|
33
|
+
export interface RunViewTransition {
|
|
34
|
+
readonly phase: RunPhase;
|
|
35
|
+
readonly effect?: RunViewEffect;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The live view, with no prompt open. */
|
|
39
|
+
export function livePhase(): RunPhase {
|
|
40
|
+
return { kind: "live" };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Applies one action to the view phase.
|
|
45
|
+
*
|
|
46
|
+
* A `settle` always wins, from any phase, so an open prompt cannot ask to stop a
|
|
47
|
+
* Run that already ended. An action that belongs to another phase returns the
|
|
48
|
+
* phase unchanged and no effect.
|
|
49
|
+
*/
|
|
50
|
+
export function applyRunViewAction(phase: RunPhase, action: RunViewAction): RunViewTransition {
|
|
51
|
+
if (action.kind === "settle") return { phase: { kind: "settled", outcome: action.outcome } };
|
|
52
|
+
switch (phase.kind) {
|
|
53
|
+
case "live":
|
|
54
|
+
return action.kind === "quit" ? { phase: { kind: "confirm", cursor: 0 } } : { phase };
|
|
55
|
+
case "confirm":
|
|
56
|
+
return fromConfirm(phase, action);
|
|
57
|
+
case "stopping":
|
|
58
|
+
return { phase };
|
|
59
|
+
case "settled":
|
|
60
|
+
return action.kind === "quit" ? { phase, effect: "close" } : { phase };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function fromConfirm(
|
|
65
|
+
phase: { readonly kind: "confirm"; readonly cursor: number },
|
|
66
|
+
action: RunViewAction,
|
|
67
|
+
): RunViewTransition {
|
|
68
|
+
switch (action.kind) {
|
|
69
|
+
case "move":
|
|
70
|
+
return { phase: { kind: "confirm", cursor: moveStopCursor(phase.cursor, action.delta) } };
|
|
71
|
+
case "cancel":
|
|
72
|
+
return { phase: livePhase() };
|
|
73
|
+
case "choose":
|
|
74
|
+
return fromChoice(action.choice);
|
|
75
|
+
default:
|
|
76
|
+
return { phase };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function fromChoice(choice: StopChoice): RunViewTransition {
|
|
81
|
+
switch (choice) {
|
|
82
|
+
case "stop":
|
|
83
|
+
return { phase: { kind: "stopping" }, effect: "stop" };
|
|
84
|
+
case "detach":
|
|
85
|
+
return { phase: livePhase(), effect: "detach" };
|
|
86
|
+
case "resume":
|
|
87
|
+
return { phase: livePhase() };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session layer's key table (spec §2): the one gesture that can end a
|
|
3
|
+
* foreground Run view.
|
|
4
|
+
*
|
|
5
|
+
* Literal keys only, with no `named` binding. `esc` must never stop a Run, and
|
|
6
|
+
* pi binds `tui.select.cancel` to `escape, ctrl+c`; a named entry here would
|
|
7
|
+
* let that binding — or any user remap of it — reach the quit path. The literal
|
|
8
|
+
* table makes that structurally impossible.
|
|
9
|
+
*/
|
|
10
|
+
import { type KeyBinding, type NamedKeybindings, routeKey } from "./key-router.ts";
|
|
11
|
+
|
|
12
|
+
/** The one gesture the session layer consumes. */
|
|
13
|
+
export type SessionAction = "quit";
|
|
14
|
+
|
|
15
|
+
/** `q` and Ctrl-C, and nothing else. */
|
|
16
|
+
export const SESSION_KEY_TABLE: readonly KeyBinding<SessionAction>[] = [
|
|
17
|
+
{ action: "quit", keys: ["q", "ctrl+c"] },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolves one input byte string to the session `quit` gesture.
|
|
22
|
+
*
|
|
23
|
+
* Returns undefined for every other byte, `escape` included, so no gesture
|
|
24
|
+
* routed here can stop a Run by accident (spec §4).
|
|
25
|
+
*/
|
|
26
|
+
export function routeSessionKey(
|
|
27
|
+
data: string,
|
|
28
|
+
keybindings: NamedKeybindings,
|
|
29
|
+
): SessionAction | undefined {
|
|
30
|
+
return routeKey(data, keybindings, SESSION_KEY_TABLE);
|
|
31
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A defensive reader for one Agent's pi session file (JSONL on disk).
|
|
3
|
+
*
|
|
4
|
+
* A Peek renders this to show an Agent's transcript; it is never a wire
|
|
5
|
+
* protocol, so an unknown entry type or a malformed line is skipped rather than
|
|
6
|
+
* thrown — the same ethos as `event-reader.ts`. The reader owns only decoding;
|
|
7
|
+
* truncation and layout belong to the renderer.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** One rendered turn of an Agent's conversation, normalised across pi versions. */
|
|
11
|
+
export type TranscriptItem =
|
|
12
|
+
| { readonly kind: "user"; readonly text: string }
|
|
13
|
+
| { readonly kind: "assistant"; readonly text: string }
|
|
14
|
+
| { readonly kind: "thinking"; readonly text: string }
|
|
15
|
+
| { readonly kind: "tool-call"; readonly name: string; readonly args: string }
|
|
16
|
+
| {
|
|
17
|
+
readonly kind: "tool-result";
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly text: string;
|
|
20
|
+
readonly isError: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Decodes a pi session file body into ordered transcript items.
|
|
25
|
+
*
|
|
26
|
+
* Never throws: a non-JSON line, an entry that is not a `message`, or a part
|
|
27
|
+
* with an unknown shape is dropped. An empty or wholly-unreadable body yields an
|
|
28
|
+
* empty array, which the renderer presents as a "no transcript available" stub.
|
|
29
|
+
*/
|
|
30
|
+
export function parseTranscript(content: string): readonly TranscriptItem[] {
|
|
31
|
+
const items: TranscriptItem[] = [];
|
|
32
|
+
for (const line of content.split("\n")) {
|
|
33
|
+
const message = messageOf(line);
|
|
34
|
+
if (message !== null) collectMessage(message, items);
|
|
35
|
+
}
|
|
36
|
+
return items;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Extracts an Agent's system prompt from its session file, when one is present.
|
|
41
|
+
*
|
|
42
|
+
* pi does not persist the system prompt in every session version, so this
|
|
43
|
+
* returns null whenever no recorded prompt is found rather than inventing one.
|
|
44
|
+
*/
|
|
45
|
+
export function extractSystemPrompt(content: string): string | null {
|
|
46
|
+
for (const line of content.split("\n")) {
|
|
47
|
+
const record = parseLine(line);
|
|
48
|
+
if (record === null) continue;
|
|
49
|
+
const direct = stringField(record, "systemPrompt");
|
|
50
|
+
if (direct !== null) return direct;
|
|
51
|
+
const message = recordField(record, "message");
|
|
52
|
+
if (message === null) continue;
|
|
53
|
+
const prompt = stringField(message, "systemPrompt");
|
|
54
|
+
if (prompt !== null) return prompt;
|
|
55
|
+
if (message.role === "system") {
|
|
56
|
+
const text = joinText(arrayField(message, "content"));
|
|
57
|
+
if (text !== "") return text;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function messageOf(line: string): Record<string, unknown> | null {
|
|
64
|
+
const record = parseLine(line);
|
|
65
|
+
if (record === null || record.type !== "message") return null;
|
|
66
|
+
return recordField(record, "message");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function collectMessage(message: Record<string, unknown>, items: TranscriptItem[]): void {
|
|
70
|
+
const parts = arrayField(message, "content");
|
|
71
|
+
switch (message.role) {
|
|
72
|
+
case "user":
|
|
73
|
+
pushText(items, "user", joinText(parts));
|
|
74
|
+
return;
|
|
75
|
+
case "assistant":
|
|
76
|
+
collectAssistant(parts, items);
|
|
77
|
+
return;
|
|
78
|
+
case "toolResult":
|
|
79
|
+
items.push({
|
|
80
|
+
kind: "tool-result",
|
|
81
|
+
name: stringField(message, "toolName") ?? "tool",
|
|
82
|
+
text: joinText(parts),
|
|
83
|
+
isError: message.isError === true,
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
default:
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function collectAssistant(parts: readonly unknown[], items: TranscriptItem[]): void {
|
|
92
|
+
for (const part of parts) {
|
|
93
|
+
if (!isRecord(part)) continue;
|
|
94
|
+
if (part.type === "thinking") pushText(items, "thinking", asString(part.thinking));
|
|
95
|
+
else if (part.type === "text") pushText(items, "assistant", asString(part.text));
|
|
96
|
+
else if (part.type === "toolCall") {
|
|
97
|
+
items.push({
|
|
98
|
+
kind: "tool-call",
|
|
99
|
+
name: asString(part.name) || "tool",
|
|
100
|
+
args: stringifyArgs(part.arguments),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function pushText(
|
|
107
|
+
items: TranscriptItem[],
|
|
108
|
+
kind: "user" | "assistant" | "thinking",
|
|
109
|
+
text: string,
|
|
110
|
+
): void {
|
|
111
|
+
if (text !== "") items.push({ kind, text });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function joinText(parts: readonly unknown[]): string {
|
|
115
|
+
return parts
|
|
116
|
+
.filter(isRecord)
|
|
117
|
+
.filter((part) => part.type === "text")
|
|
118
|
+
.map((part) => asString(part.text))
|
|
119
|
+
.join("")
|
|
120
|
+
.trim();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function stringifyArgs(args: unknown): string {
|
|
124
|
+
if (args === undefined) return "";
|
|
125
|
+
try {
|
|
126
|
+
return JSON.stringify(args);
|
|
127
|
+
} catch {
|
|
128
|
+
return String(args);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseLine(line: string): Record<string, unknown> | null {
|
|
133
|
+
if (line.trim() === "") return null;
|
|
134
|
+
let value: unknown;
|
|
135
|
+
try {
|
|
136
|
+
value = JSON.parse(line);
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
return isRecord(value) ? value : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function recordField(record: Record<string, unknown>, key: string): Record<string, unknown> | null {
|
|
144
|
+
const value = record[key];
|
|
145
|
+
return isRecord(value) ? value : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function arrayField(record: Record<string, unknown>, key: string): readonly unknown[] {
|
|
149
|
+
const value = record[key];
|
|
150
|
+
return Array.isArray(value) ? value : [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function stringField(record: Record<string, unknown>, key: string): string | null {
|
|
154
|
+
const value = record[key];
|
|
155
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function asString(value: unknown): string {
|
|
159
|
+
return typeof value === "string" ? value : "";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
163
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
164
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { compactAgentLine, compactHeaderLine } from "./compact-render.ts";
|
|
2
|
+
import { renderNodeTable } from "./node-table.ts";
|
|
3
|
+
import { sanitizeTerminalLine } from "./terminal-text.ts";
|
|
4
|
+
import { buildTree } from "./tree-model.ts";
|
|
5
|
+
import { clamp } from "./tree-rows.ts";
|
|
6
|
+
import type { TreeState } from "./tree-state.ts";
|
|
7
|
+
|
|
8
|
+
/** Everything the model-facing snapshot needs; `now` keeps it clock-free. */
|
|
9
|
+
export interface SnapshotRenderOptions {
|
|
10
|
+
readonly now: number;
|
|
11
|
+
readonly width: number;
|
|
12
|
+
/** Header text before the Program name; usually the Run id. */
|
|
13
|
+
readonly label?: string;
|
|
14
|
+
/** The settled Run's result value, printed under a `Result:` line. */
|
|
15
|
+
readonly result?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Renders the model-facing snapshot frame: one Run header line, then one line
|
|
20
|
+
* per Agent with that Agent's Nested Node table, then the Result block when
|
|
21
|
+
* the Run settled with a value.
|
|
22
|
+
*
|
|
23
|
+
* It draws no key footer and reads no input, so every line is content a model
|
|
24
|
+
* can parse. Pure over the state; every untrusted string is sanitized and
|
|
25
|
+
* clamped to `width`.
|
|
26
|
+
*/
|
|
27
|
+
export function renderSnapshot(
|
|
28
|
+
state: TreeState,
|
|
29
|
+
options: SnapshotRenderOptions,
|
|
30
|
+
): readonly string[] {
|
|
31
|
+
const header = { now: options.now, width: options.width, ...labelOf(options) };
|
|
32
|
+
const lines: string[] = [compactHeaderLine(state, header)];
|
|
33
|
+
const agents = buildTree(state, { now: options.now });
|
|
34
|
+
const names = state.agentOrder.filter((name) => state.summary.agents[name] !== undefined);
|
|
35
|
+
agents.forEach((agent, index) => {
|
|
36
|
+
lines.push(compactAgentLine(agent, options.width));
|
|
37
|
+
const info = state.summary.agents[names[index] ?? ""];
|
|
38
|
+
if (info !== undefined) lines.push(...renderNodeTable(info, options.width));
|
|
39
|
+
});
|
|
40
|
+
if (options.result === undefined) return lines;
|
|
41
|
+
return [
|
|
42
|
+
...lines,
|
|
43
|
+
"",
|
|
44
|
+
clamp("Result:", options.width),
|
|
45
|
+
...options.result.split("\n").map((line) => clamp(sanitizeTerminalLine(line), options.width)),
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function labelOf(options: SnapshotRenderOptions): { readonly label?: string } {
|
|
50
|
+
return options.label === undefined ? {} : { label: options.label };
|
|
51
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The foreground Run view's stop prompt (spec §4): the three-way choice `q` and
|
|
3
|
+
* Ctrl-C raise over a live Run.
|
|
4
|
+
*
|
|
5
|
+
* The spec's key table names no key for Detach, and the component consumes only
|
|
6
|
+
* the keys of that table, so Detach lives here as a prompt choice rather than as
|
|
7
|
+
* a new binding.
|
|
8
|
+
*/
|
|
9
|
+
import { renderFramedBox } from "./overlay-frame.ts";
|
|
10
|
+
|
|
11
|
+
/** What the reader may do with a live Run. */
|
|
12
|
+
export type StopChoice = "stop" | "detach" | "resume";
|
|
13
|
+
|
|
14
|
+
/** One prompt row: the choice and the label the reader sees. */
|
|
15
|
+
export interface StopChoiceRow {
|
|
16
|
+
readonly action: StopChoice;
|
|
17
|
+
readonly label: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The three choices, in prompt order. */
|
|
21
|
+
export const STOP_PROMPT_CHOICES: readonly StopChoiceRow[] = [
|
|
22
|
+
{ action: "stop", label: "Stop Run" },
|
|
23
|
+
{ action: "detach", label: "Detach (keep running)" },
|
|
24
|
+
{ action: "resume", label: "Keep watching" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const FOOTER = " ↑↓ choose ↵ confirm esc back";
|
|
28
|
+
|
|
29
|
+
/** One stop-prompt render request. */
|
|
30
|
+
export interface StopPromptOptions {
|
|
31
|
+
readonly cursor: number;
|
|
32
|
+
readonly width: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Moves the prompt cursor by `delta`, wrapping at both ends of the list. */
|
|
36
|
+
export function moveStopCursor(cursor: number, delta: number): number {
|
|
37
|
+
const count = STOP_PROMPT_CHOICES.length;
|
|
38
|
+
return (((cursor + delta) % count) + count) % count;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Draws the framed stop prompt.
|
|
43
|
+
*
|
|
44
|
+
* Every returned line is exactly `width` visible columns; a cursor outside the
|
|
45
|
+
* choice list highlights no row, and a width that is not finite degrades through
|
|
46
|
+
* `renderFramedBox` rather than throwing.
|
|
47
|
+
*/
|
|
48
|
+
export function renderStopPrompt(options: StopPromptOptions): readonly string[] {
|
|
49
|
+
const rows = STOP_PROMPT_CHOICES.map(
|
|
50
|
+
(row, index) => `${index === options.cursor ? "❯" : " "} ${row.label}`,
|
|
51
|
+
);
|
|
52
|
+
return renderFramedBox({
|
|
53
|
+
title: "Stop Run?",
|
|
54
|
+
lines: rows,
|
|
55
|
+
footer: FOOTER,
|
|
56
|
+
width: options.width,
|
|
57
|
+
});
|
|
58
|
+
}
|