@yaag/tui 0.3.0 → 0.4.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 +2 -2
- package/src/compact-render.ts +3 -4
- package/src/details-pane.ts +7 -6
- package/src/drill-controller.ts +86 -23
- package/src/drill-keys.ts +4 -3
- package/src/drill-state.ts +57 -18
- package/src/index.ts +5 -1
- package/src/inline-render.ts +2 -2
- package/src/node-actions.ts +2 -2
- package/src/node-table.ts +4 -5
- package/src/overlay-frame.ts +3 -4
- package/src/overlay-scroll.ts +5 -0
- package/src/run-tree-view.ts +2 -2
- package/src/snapshot-render.ts +6 -5
- package/src/system-prompt-overlay.ts +110 -0
- package/src/terminal-text.ts +39 -0
- package/src/tree-render.ts +7 -7
- package/src/tree-rows.ts +3 -25
- package/src/wrap-text.ts +75 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yaag/tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -19,6 +19,6 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@earendil-works/pi-tui": "^0.84.0",
|
|
22
|
-
"@yaag/runtime": "0.
|
|
22
|
+
"@yaag/runtime": "0.4.0"
|
|
23
23
|
}
|
|
24
24
|
}
|
package/src/compact-render.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { costText } from "./accounting-text.ts";
|
|
2
2
|
import { durationText } from "./duration-text.ts";
|
|
3
|
-
import { sanitizeTerminalLine } from "./terminal-text.ts";
|
|
3
|
+
import { clampToWidth, sanitizeTerminalLine } from "./terminal-text.ts";
|
|
4
4
|
import { GLYPHS, stateGlyph } from "./tree-glyphs.ts";
|
|
5
5
|
import { buildTree } from "./tree-model.ts";
|
|
6
6
|
import type { TreeNode } from "./tree-node.ts";
|
|
7
|
-
import { clamp } from "./tree-rows.ts";
|
|
8
7
|
import type { TreeState } from "./tree-state.ts";
|
|
9
8
|
|
|
10
9
|
/** Everything the compact background view needs; `now` keeps it clock-free. */
|
|
@@ -37,7 +36,7 @@ export function compactHeaderLine(state: TreeState, options: CompactRenderOption
|
|
|
37
36
|
const asks = `${summary.asksSettled} ask${summary.asksSettled === 1 ? "" : "s"}`;
|
|
38
37
|
const label = sanitizeTerminalLine(options.label ?? "yaag");
|
|
39
38
|
const program = sanitizeTerminalLine(summary.program === "" ? "Run" : summary.program);
|
|
40
|
-
return
|
|
39
|
+
return clampToWidth(
|
|
41
40
|
`${label} ▸ ${program} ${status} ${durationText(elapsed)} ${costText(summary.cost, summary.incomplete)} · ${asks}`,
|
|
42
41
|
options.width,
|
|
43
42
|
);
|
|
@@ -55,7 +54,7 @@ export function compactAgentLines(
|
|
|
55
54
|
|
|
56
55
|
/** The one Agent line of the compact view, drawn for one Agent node. */
|
|
57
56
|
export function compactAgentLine(agent: TreeNode, width: number): string {
|
|
58
|
-
return
|
|
57
|
+
return clampToWidth(agentLine(agent), width);
|
|
59
58
|
}
|
|
60
59
|
|
|
61
60
|
function agentLine(agent: TreeNode): string {
|
package/src/details-pane.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { sanitizeTerminalLine } from "./terminal-text.ts";
|
|
1
|
+
import { clampToWidth, sanitizeTerminalLine } from "./terminal-text.ts";
|
|
2
2
|
import { stateGlyph } from "./tree-glyphs.ts";
|
|
3
3
|
import type { TreeNode } from "./tree-node.ts";
|
|
4
|
-
import { clamp } from "./tree-rows.ts";
|
|
5
4
|
|
|
6
5
|
/** The bounded last-output lines the pane draws under the selected node. */
|
|
7
6
|
export interface DetailsPaneOptions {
|
|
@@ -20,14 +19,16 @@ export function renderDetailsPane(
|
|
|
20
19
|
): readonly string[] {
|
|
21
20
|
if (node === undefined) return [];
|
|
22
21
|
const lines = [
|
|
23
|
-
|
|
22
|
+
clampToWidth(`${stateGlyph(node.state)} ${sanitizeTerminalLine(node.path)}`, options.width),
|
|
24
23
|
];
|
|
25
24
|
if (node.activityGist !== null)
|
|
26
|
-
lines.push(
|
|
25
|
+
lines.push(clampToWidth(` ${sanitizeTerminalLine(node.activityGist)}`, options.width));
|
|
27
26
|
if (node.facts.length > 0)
|
|
28
|
-
lines.push(
|
|
27
|
+
lines.push(
|
|
28
|
+
clampToWidth(` ${node.facts.map(sanitizeTerminalLine).join(" · ")}`, options.width),
|
|
29
|
+
);
|
|
29
30
|
const last = options.outputTail.at(-1);
|
|
30
31
|
if (last !== undefined && last !== "")
|
|
31
|
-
lines.push(
|
|
32
|
+
lines.push(clampToWidth(` last: “${sanitizeTerminalLine(last)}”`, options.width));
|
|
32
33
|
return lines;
|
|
33
34
|
}
|
package/src/drill-controller.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The drill-in controller: it layers the actions menu
|
|
3
|
-
*
|
|
2
|
+
* The drill-in controller: it layers the actions menu, the read-only transcript
|
|
3
|
+
* overlay, and the read-only system-prompt modal over the Run tree (spec §1).
|
|
4
4
|
*
|
|
5
|
-
* Read-only by construction: `DrillHost` exposes copy,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Read-only by construction: `DrillHost` exposes copy, notify, and session
|
|
6
|
+
* reads and nothing else, so no code path here can prompt an Agent or stop a
|
|
7
|
+
* Run (ADR — a Peek observes, it never sends). It has no dialog capability
|
|
8
|
+
* either, so no layer can hand focus to pi (ADR-0034). It holds no clock;
|
|
8
9
|
* a host that tails a live session file calls `refreshTranscript` on its own
|
|
9
10
|
* cadence.
|
|
10
11
|
*/
|
|
@@ -21,6 +22,11 @@ import type { NamedKeybindings } from "./key-router.ts";
|
|
|
21
22
|
import { renderActionsMenu } from "./node-actions.ts";
|
|
22
23
|
import { agentOfPath } from "./node-path-parse.ts";
|
|
23
24
|
import { extractSystemPrompt } from "./session-transcript.ts";
|
|
25
|
+
import {
|
|
26
|
+
renderSystemPromptOverlay,
|
|
27
|
+
systemPromptContentRows,
|
|
28
|
+
systemPromptLines,
|
|
29
|
+
} from "./system-prompt-overlay.ts";
|
|
24
30
|
import { nodeTranscriptLines } from "./transcript-content.ts";
|
|
25
31
|
import { renderTranscriptOverlay } from "./transcript-overlay.ts";
|
|
26
32
|
import { NO_TRANSCRIPT_STUB } from "./transcript-render.ts";
|
|
@@ -29,6 +35,9 @@ import type { TreeNavigator } from "./tree-navigator.ts";
|
|
|
29
35
|
/** Shown when a session file records no system prompt (spec §5). */
|
|
30
36
|
const NO_SYSTEM_PROMPT = "(no system prompt recorded in this session file)";
|
|
31
37
|
|
|
38
|
+
/** Assumed frame width until the first render reports the real one. */
|
|
39
|
+
const DEFAULT_WIDTH = 80;
|
|
40
|
+
|
|
32
41
|
/** Every capability the drill-in needs from its host — all of them read-only. */
|
|
33
42
|
export interface DrillHost {
|
|
34
43
|
/** The tree's keyboard router; the drill layers sit above it. */
|
|
@@ -42,10 +51,9 @@ export interface DrillHost {
|
|
|
42
51
|
/** An Agent's session file path, or null when the spawn reported none. */
|
|
43
52
|
sessionPath(agent: string): string | null;
|
|
44
53
|
copyPath(text: string): Promise<void>;
|
|
45
|
-
openEditor(title: string, body: string): Promise<void>;
|
|
46
54
|
notify(message: string, level: "info" | "warning" | "error"): void;
|
|
47
55
|
requestRender(): void;
|
|
48
|
-
/**
|
|
56
|
+
/** The row budget of a layer; each layer states how it spends it. */
|
|
49
57
|
rows(): number;
|
|
50
58
|
/** Releases input focus when `esc` closes the last layer. */
|
|
51
59
|
dropFocus?(): void;
|
|
@@ -62,10 +70,17 @@ export interface DrillHost {
|
|
|
62
70
|
export class DrillController {
|
|
63
71
|
readonly #host: DrillHost;
|
|
64
72
|
#state: DrillState = emptyDrill();
|
|
65
|
-
/**
|
|
73
|
+
/** Transcript lines of the open node, or undefined. */
|
|
66
74
|
#content: { readonly path: string; readonly lines: readonly string[] } | undefined;
|
|
75
|
+
/** The open node's raw system prompt; the modal wraps it per render width. */
|
|
76
|
+
#prompt: { readonly path: string; readonly body: string } | undefined;
|
|
67
77
|
/** Bumped by every open, refresh, and close, to void in-flight reads. */
|
|
68
78
|
#generation = 0;
|
|
79
|
+
/**
|
|
80
|
+
* Width of the last frame drawn. The modal wraps its prompt to the panel, so
|
|
81
|
+
* the reducer must clamp scrolling against the geometry the reader sees.
|
|
82
|
+
*/
|
|
83
|
+
#renderWidth = DEFAULT_WIDTH;
|
|
69
84
|
|
|
70
85
|
constructor(host: DrillHost) {
|
|
71
86
|
this.#host = host;
|
|
@@ -78,6 +93,7 @@ export class DrillController {
|
|
|
78
93
|
|
|
79
94
|
/** The framed overlay lines, or undefined while the tree holds focus. */
|
|
80
95
|
overlayLines(width: number): readonly string[] | undefined {
|
|
96
|
+
this.#renderWidth = width;
|
|
81
97
|
const state = this.#state;
|
|
82
98
|
switch (state.kind) {
|
|
83
99
|
case "tree":
|
|
@@ -87,7 +103,15 @@ export class DrillController {
|
|
|
87
103
|
case "transcript":
|
|
88
104
|
return renderTranscriptOverlay({
|
|
89
105
|
nodePath: state.path,
|
|
90
|
-
content: this.#linesFor(state
|
|
106
|
+
content: this.#linesFor(state),
|
|
107
|
+
scroll: state.scroll,
|
|
108
|
+
rows: this.#host.rows(),
|
|
109
|
+
width,
|
|
110
|
+
});
|
|
111
|
+
case "systemPrompt":
|
|
112
|
+
return renderSystemPromptOverlay({
|
|
113
|
+
nodePath: state.path,
|
|
114
|
+
content: this.#linesFor(state),
|
|
91
115
|
scroll: state.scroll,
|
|
92
116
|
rows: this.#host.rows(),
|
|
93
117
|
width,
|
|
@@ -102,11 +126,13 @@ export class DrillController {
|
|
|
102
126
|
const before = this.#state;
|
|
103
127
|
const { state, effect } = applyDrillAction(before, action, {
|
|
104
128
|
selectedPath: this.#host.navigator.selectedPath,
|
|
105
|
-
total:
|
|
106
|
-
rows: this.#
|
|
129
|
+
total: this.#linesFor(before).length,
|
|
130
|
+
rows: this.#pageRows(before),
|
|
107
131
|
});
|
|
108
132
|
this.#state = state;
|
|
109
|
-
if (state
|
|
133
|
+
if (contentKey(state) === undefined || contentKey(state) !== contentKey(before)) {
|
|
134
|
+
this.#invalidate();
|
|
135
|
+
}
|
|
110
136
|
if (effect !== undefined) void this.#perform(effect);
|
|
111
137
|
if (state !== before || effect !== undefined) this.#host.requestRender();
|
|
112
138
|
return true;
|
|
@@ -125,6 +151,9 @@ export class DrillController {
|
|
|
125
151
|
#route(data: string): AnyDrillAction | undefined {
|
|
126
152
|
const keybindings = this.#host.keybindings;
|
|
127
153
|
switch (this.#state.kind) {
|
|
154
|
+
// The modal reuses the overlay key table: the same scroll, copy, and
|
|
155
|
+
// close gestures apply.
|
|
156
|
+
case "systemPrompt":
|
|
128
157
|
case "transcript":
|
|
129
158
|
return routeOverlayKey(data, keybindings);
|
|
130
159
|
case "menu":
|
|
@@ -146,8 +175,8 @@ export class DrillController {
|
|
|
146
175
|
case "copySessionPath":
|
|
147
176
|
await this.#copySessionPath(effect.path);
|
|
148
177
|
return;
|
|
149
|
-
case "
|
|
150
|
-
await this.#
|
|
178
|
+
case "loadSystemPrompt":
|
|
179
|
+
await this.#loadSystemPrompt(effect.path, this.#nextGeneration());
|
|
151
180
|
return;
|
|
152
181
|
case "dropFocus":
|
|
153
182
|
this.#host.dropFocus?.();
|
|
@@ -177,15 +206,38 @@ export class DrillController {
|
|
|
177
206
|
this.#host.requestRender();
|
|
178
207
|
}
|
|
179
208
|
|
|
180
|
-
/**
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
209
|
+
/**
|
|
210
|
+
* The scroll document of one layer, or none while its own read is in flight.
|
|
211
|
+
*
|
|
212
|
+
* The modal's document is the prompt wrapped to the panel, so the line count
|
|
213
|
+
* the reducer clamps against is the count the panel draws.
|
|
214
|
+
*/
|
|
215
|
+
#linesFor(state: DrillState): readonly string[] {
|
|
216
|
+
switch (state.kind) {
|
|
217
|
+
case "transcript":
|
|
218
|
+
return this.#content?.path === state.path ? this.#content.lines : [];
|
|
219
|
+
case "systemPrompt":
|
|
220
|
+
return this.#prompt?.path === state.path
|
|
221
|
+
? systemPromptLines(this.#prompt.body, this.#renderWidth)
|
|
222
|
+
: [];
|
|
223
|
+
default:
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The page height the open layer scrolls by. The system-prompt modal spends
|
|
230
|
+
* three rows on its frame, so its page is smaller than the row budget.
|
|
231
|
+
*/
|
|
232
|
+
#pageRows(state: DrillState): number {
|
|
233
|
+
const rows = this.#host.rows();
|
|
234
|
+
return state.kind === "systemPrompt" ? systemPromptContentRows(rows) : rows;
|
|
184
235
|
}
|
|
185
236
|
|
|
186
237
|
/** Drops retained content and voids every in-flight read. */
|
|
187
238
|
#invalidate(): void {
|
|
188
239
|
this.#content = undefined;
|
|
240
|
+
this.#prompt = undefined;
|
|
189
241
|
this.#generation += 1;
|
|
190
242
|
}
|
|
191
243
|
|
|
@@ -208,10 +260,19 @@ export class DrillController {
|
|
|
208
260
|
}
|
|
209
261
|
}
|
|
210
262
|
|
|
211
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Reads one node's system prompt and commits it under the same
|
|
265
|
+
* generation guard as `#loadTranscript`, so a slow read cannot land under a
|
|
266
|
+
* closed or replaced modal.
|
|
267
|
+
*/
|
|
268
|
+
async #loadSystemPrompt(nodePath: string, generation: number): Promise<void> {
|
|
212
269
|
const session = await this.#readSession(agentOfPath(nodePath));
|
|
213
270
|
const prompt = session === null ? null : extractSystemPrompt(session);
|
|
214
|
-
|
|
271
|
+
if (generation !== this.#generation) return;
|
|
272
|
+
const state = this.#state;
|
|
273
|
+
if (state.kind !== "systemPrompt" || state.path !== nodePath) return;
|
|
274
|
+
this.#prompt = { path: nodePath, body: prompt ?? NO_SYSTEM_PROMPT };
|
|
275
|
+
this.#host.requestRender();
|
|
215
276
|
}
|
|
216
277
|
|
|
217
278
|
/** A session file that cannot be read degrades to the labelled stub. */
|
|
@@ -224,9 +285,11 @@ export class DrillController {
|
|
|
224
285
|
}
|
|
225
286
|
}
|
|
226
287
|
|
|
227
|
-
/** The
|
|
228
|
-
function
|
|
229
|
-
return state.kind === "transcript"
|
|
288
|
+
/** The content key of a layer that loads content, or undefined for the others. */
|
|
289
|
+
function contentKey(state: DrillState): string | undefined {
|
|
290
|
+
return state.kind === "transcript" || state.kind === "systemPrompt"
|
|
291
|
+
? `${state.kind}:${state.path}`
|
|
292
|
+
: undefined;
|
|
230
293
|
}
|
|
231
294
|
|
|
232
295
|
function message(error: unknown): string {
|
package/src/drill-keys.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The three key tables of the drill-in (spec §2): the tree's drill gestures, the
|
|
3
|
-
* actions menu, and the transcript overlay
|
|
3
|
+
* actions menu, and the scrolling layers — the transcript overlay and the
|
|
4
|
+
* system-prompt modal share one table.
|
|
4
5
|
*
|
|
5
6
|
* Every table routes through `routeKey`, so a user's named-binding override
|
|
6
7
|
* always beats a vim literal. `back`/`close` resolve `tui.select.cancel` before
|
|
@@ -15,7 +16,7 @@ export type DrillAction = "openMenu" | "openTranscript" | "back";
|
|
|
15
16
|
/** A gesture the actions menu consumes. */
|
|
16
17
|
export type MenuAction = "menuUp" | "menuDown" | "confirm" | "cancel";
|
|
17
18
|
|
|
18
|
-
/** A gesture the transcript overlay
|
|
19
|
+
/** A gesture a scrolling layer consumes: the transcript overlay or the modal. */
|
|
19
20
|
export type OverlayAction =
|
|
20
21
|
| "scrollUp"
|
|
21
22
|
| "scrollDown"
|
|
@@ -42,7 +43,7 @@ export const MENU_KEY_TABLE: readonly KeyBinding<MenuAction>[] = [
|
|
|
42
43
|
{ action: "cancel", named: "tui.select.cancel", keys: ["escape"] },
|
|
43
44
|
];
|
|
44
45
|
|
|
45
|
-
/** The
|
|
46
|
+
/** The keys of both scrolling layers. */
|
|
46
47
|
export const OVERLAY_KEY_TABLE: readonly KeyBinding<OverlayAction>[] = [
|
|
47
48
|
{ action: "scrollUp", named: "tui.select.up", vim: ["k"] },
|
|
48
49
|
{ action: "scrollDown", named: "tui.select.down", vim: ["j"] },
|
package/src/drill-state.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The drill-in layer state machine (spec §1): tree → actions menu → transcript
|
|
3
|
-
* overlay, with `esc` backing out exactly one level.
|
|
3
|
+
* overlay or system-prompt modal, with `esc` backing out exactly one level.
|
|
4
4
|
*
|
|
5
5
|
* Pure and I/O-free. Every effect it can ask for is read-only — copy a path,
|
|
6
|
-
*
|
|
7
|
-
* can send to an Agent or stop a Run (ADR — a Peek observes, it never
|
|
6
|
+
* load a transcript, load a system prompt, drop focus — so no gesture in this
|
|
7
|
+
* reducer can send to an Agent or stop a Run (ADR — a Peek observes, it never
|
|
8
|
+
* sends). No layer hands focus to a pi dialog: a pi dialog opened under a
|
|
9
|
+
* non-overlay `ctx.ui.custom()` view removes the view and stops pi's input
|
|
10
|
+
* loop, so the system prompt is a layer of this machine (ADR-0034).
|
|
8
11
|
*/
|
|
9
12
|
import type { DrillAction, MenuAction, OverlayAction } from "./drill-keys.ts";
|
|
10
13
|
import { moveMenuCursor, NODE_ACTIONS } from "./node-actions.ts";
|
|
11
|
-
import { initialScroll, type Scroll, scrollBy } from "./overlay-scroll.ts";
|
|
14
|
+
import { initialScroll, type Scroll, scrollBy, topScroll } from "./overlay-scroll.ts";
|
|
12
15
|
|
|
13
16
|
/** Which layer holds input focus. */
|
|
14
17
|
export type DrillState =
|
|
@@ -20,12 +23,19 @@ export type DrillState =
|
|
|
20
23
|
/** The layer `esc` returns to. */
|
|
21
24
|
readonly from: "tree" | "menu";
|
|
22
25
|
readonly scroll: Scroll;
|
|
26
|
+
}
|
|
27
|
+
| {
|
|
28
|
+
readonly kind: "systemPrompt";
|
|
29
|
+
readonly path: string;
|
|
30
|
+
/** The layer `esc` returns to; the modal opens from the menu only. */
|
|
31
|
+
readonly from: "menu";
|
|
32
|
+
readonly scroll: Scroll;
|
|
23
33
|
};
|
|
24
34
|
|
|
25
35
|
/** A read-only side effect the host performs after a transition. */
|
|
26
36
|
export type DrillEffect =
|
|
27
37
|
| { readonly kind: "copySessionPath"; readonly path: string }
|
|
28
|
-
| { readonly kind: "
|
|
38
|
+
| { readonly kind: "loadSystemPrompt"; readonly path: string }
|
|
29
39
|
| { readonly kind: "loadTranscript"; readonly path: string }
|
|
30
40
|
| { readonly kind: "dropFocus" };
|
|
31
41
|
|
|
@@ -71,6 +81,8 @@ export function applyDrillAction(
|
|
|
71
81
|
return fromMenu(state, action);
|
|
72
82
|
case "transcript":
|
|
73
83
|
return fromTranscript(state, action, context);
|
|
84
|
+
case "systemPrompt":
|
|
85
|
+
return fromSystemPrompt(state, action, context);
|
|
74
86
|
}
|
|
75
87
|
}
|
|
76
88
|
|
|
@@ -112,22 +124,29 @@ function confirmMenu(state: {
|
|
|
112
124
|
return openTranscript(state.path, "menu");
|
|
113
125
|
case "copySessionPath":
|
|
114
126
|
return { state, effect: { kind: "copySessionPath", path: state.path } };
|
|
115
|
-
case "
|
|
116
|
-
return {
|
|
127
|
+
case "viewSystemPrompt":
|
|
128
|
+
return {
|
|
129
|
+
state: { kind: "systemPrompt", path: state.path, from: "menu", scroll: topScroll() },
|
|
130
|
+
effect: { kind: "loadSystemPrompt", path: state.path },
|
|
131
|
+
};
|
|
117
132
|
default:
|
|
118
133
|
return { state };
|
|
119
134
|
}
|
|
120
135
|
}
|
|
121
136
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
137
|
+
/** A scrolling read-only layer: the transcript overlay or the prompt modal. */
|
|
138
|
+
type ScrollLayer = Extract<DrillState, { readonly scroll: Scroll }>;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The shared gestures of both scrolling layers: line and page scroll, copy the
|
|
142
|
+
* session path, and close to the layer the caller names. One reducer keeps the
|
|
143
|
+
* transcript overlay and the system-prompt modal from drifting apart.
|
|
144
|
+
*/
|
|
145
|
+
function fromScrollLayer<S extends ScrollLayer>(
|
|
146
|
+
state: S,
|
|
129
147
|
action: AnyDrillAction,
|
|
130
148
|
context: DrillContext,
|
|
149
|
+
closeTo: DrillState,
|
|
131
150
|
): DrillTransition {
|
|
132
151
|
const page = Math.max(1, Math.floor(context.rows));
|
|
133
152
|
switch (action) {
|
|
@@ -142,15 +161,35 @@ function fromTranscript(
|
|
|
142
161
|
case "copyPath":
|
|
143
162
|
return { state, effect: { kind: "copySessionPath", path: state.path } };
|
|
144
163
|
case "close":
|
|
145
|
-
return {
|
|
146
|
-
state:
|
|
147
|
-
state.from === "menu" ? { kind: "menu", path: state.path, cursor: 0 } : { kind: "tree" },
|
|
148
|
-
};
|
|
164
|
+
return { state: closeTo };
|
|
149
165
|
default:
|
|
150
166
|
return { state };
|
|
151
167
|
}
|
|
152
168
|
}
|
|
153
169
|
|
|
170
|
+
/** The transcript overlay closes to the layer it was opened from. */
|
|
171
|
+
function fromTranscript(
|
|
172
|
+
state: Extract<DrillState, { readonly kind: "transcript" }>,
|
|
173
|
+
action: AnyDrillAction,
|
|
174
|
+
context: DrillContext,
|
|
175
|
+
): DrillTransition {
|
|
176
|
+
return fromScrollLayer(
|
|
177
|
+
state,
|
|
178
|
+
action,
|
|
179
|
+
context,
|
|
180
|
+
state.from === "menu" ? { kind: "menu", path: state.path, cursor: 0 } : { kind: "tree" },
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The system-prompt modal always closes back to the actions menu. */
|
|
185
|
+
function fromSystemPrompt(
|
|
186
|
+
state: Extract<DrillState, { readonly kind: "systemPrompt" }>,
|
|
187
|
+
action: AnyDrillAction,
|
|
188
|
+
context: DrillContext,
|
|
189
|
+
): DrillTransition {
|
|
190
|
+
return fromScrollLayer(state, action, context, { kind: "menu", path: state.path, cursor: 0 });
|
|
191
|
+
}
|
|
192
|
+
|
|
154
193
|
function scroll(state: { readonly scroll: Scroll }, delta: number, context: DrillContext): Scroll {
|
|
155
194
|
return scrollBy(state.scroll, delta, context.total, Math.max(1, Math.floor(context.rows)));
|
|
156
195
|
}
|
package/src/index.ts
CHANGED
|
@@ -78,7 +78,11 @@ export {
|
|
|
78
78
|
type StopChoiceRow,
|
|
79
79
|
type StopPromptOptions,
|
|
80
80
|
} from "./stop-prompt.ts";
|
|
81
|
-
export {
|
|
81
|
+
export {
|
|
82
|
+
renderSystemPromptOverlay,
|
|
83
|
+
type SystemPromptOverlayOptions,
|
|
84
|
+
} from "./system-prompt-overlay.ts";
|
|
85
|
+
export { clampToWidth, sanitizeTerminalLine, sanitizeTerminalText } from "./terminal-text.ts";
|
|
82
86
|
export { type NodeTranscriptOptions, nodeTranscriptLines } from "./transcript-content.ts";
|
|
83
87
|
export {
|
|
84
88
|
renderTranscriptOverlay,
|
package/src/inline-render.ts
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* rolls the rest into one `… +N more` line.
|
|
11
11
|
*/
|
|
12
12
|
import { compactAgentLine, compactHeaderLine } from "./compact-render.ts";
|
|
13
|
+
import { clampToWidth } from "./terminal-text.ts";
|
|
13
14
|
import { buildTree } from "./tree-model.ts";
|
|
14
15
|
import type { TreeNode } from "./tree-node.ts";
|
|
15
|
-
import { clamp } from "./tree-rows.ts";
|
|
16
16
|
import type { TreeState } from "./tree-state.ts";
|
|
17
17
|
|
|
18
18
|
/** Default line budget; pi truncates a widget past 10 lines. */
|
|
@@ -52,7 +52,7 @@ export function renderInlineRun(state: TreeState, options: InlineRenderOptions):
|
|
|
52
52
|
return [
|
|
53
53
|
header,
|
|
54
54
|
...kept.map((agent) => compactAgentLine(agent, options.width)),
|
|
55
|
-
|
|
55
|
+
clampToWidth(` … +${hidden} more`, options.width),
|
|
56
56
|
];
|
|
57
57
|
}
|
|
58
58
|
|
package/src/node-actions.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { renderFramedBox } from "./overlay-frame.ts";
|
|
7
7
|
|
|
8
8
|
/** One thing a reader may do with the selected node. */
|
|
9
|
-
export type NodeAction = "viewTranscript" | "copySessionPath" | "
|
|
9
|
+
export type NodeAction = "viewTranscript" | "copySessionPath" | "viewSystemPrompt";
|
|
10
10
|
|
|
11
11
|
/** One menu row: the action and the label the reader sees. */
|
|
12
12
|
export interface NodeActionRow {
|
|
@@ -18,7 +18,7 @@ export interface NodeActionRow {
|
|
|
18
18
|
export const NODE_ACTIONS: readonly NodeActionRow[] = [
|
|
19
19
|
{ action: "viewTranscript", label: "View transcript" },
|
|
20
20
|
{ action: "copySessionPath", label: "Copy session file path" },
|
|
21
|
-
{ action: "
|
|
21
|
+
{ action: "viewSystemPrompt", label: "View system prompt" },
|
|
22
22
|
];
|
|
23
23
|
|
|
24
24
|
const FOOTER = " ↑↓ move ↵ run esc back";
|
package/src/node-table.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { AgentInfo, NodeInfo } from "@yaag/runtime";
|
|
2
|
-
import { sanitizeTerminalLine } from "./terminal-text.ts";
|
|
3
|
-
import { clamp } from "./tree-rows.ts";
|
|
2
|
+
import { clampToWidth, sanitizeTerminalLine } from "./terminal-text.ts";
|
|
4
3
|
|
|
5
4
|
/** Running Nested Nodes drawn for one Agent before the roll-up line. */
|
|
6
5
|
const RUNNING_NODE_LINES = 8;
|
|
@@ -17,13 +16,13 @@ const RUNNING_NODE_LINES = 8;
|
|
|
17
16
|
export function renderNodeTable(agent: AgentInfo, columns: number): readonly string[] {
|
|
18
17
|
const running = agent.nodes.filter((node) => node.state === "running");
|
|
19
18
|
const shown = running.slice(0, RUNNING_NODE_LINES);
|
|
20
|
-
const lines = shown.map((node) =>
|
|
19
|
+
const lines = shown.map((node) => clampToWidth(` ▸ ${nodeLine(node)}`, columns));
|
|
21
20
|
const hiddenRunning = running.length - shown.length;
|
|
22
21
|
if (hiddenRunning > 0) {
|
|
23
|
-
lines.push(
|
|
22
|
+
lines.push(clampToWidth(` and ${hiddenRunning} more running`, columns));
|
|
24
23
|
}
|
|
25
24
|
const finished = agent.nodes.length - running.length + agent.finishedNodesPruned;
|
|
26
|
-
if (finished > 0) lines.push(
|
|
25
|
+
if (finished > 0) lines.push(clampToWidth(` and ${finished} more finished`, columns));
|
|
27
26
|
return lines;
|
|
28
27
|
}
|
|
29
28
|
|
package/src/overlay-frame.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
-
import { sanitizeTerminalLine } from "./terminal-text.ts";
|
|
3
|
-
import { clamp } from "./tree-rows.ts";
|
|
2
|
+
import { clampToWidth, sanitizeTerminalLine } from "./terminal-text.ts";
|
|
4
3
|
|
|
5
4
|
/** The box-drawing glyphs the normative overlay mockup uses. */
|
|
6
5
|
const FRAME = {
|
|
@@ -63,7 +62,7 @@ function titleLine(title: string, width: number): string {
|
|
|
63
62
|
const inner = width - 2;
|
|
64
63
|
const text =
|
|
65
64
|
width >= PADDED_WIDTH
|
|
66
|
-
? fit(`${FRAME.rule} ${
|
|
65
|
+
? fit(`${FRAME.rule} ${clampToWidth(sanitizeTerminalLine(title), width - 5)} `, inner)
|
|
67
66
|
: "";
|
|
68
67
|
const fill = Math.max(0, inner - visibleWidth(text));
|
|
69
68
|
return `${FRAME.topLeft}${text}${FRAME.rule.repeat(fill)}${FRAME.topRight}`;
|
|
@@ -73,7 +72,7 @@ function contentLine(line: string, width: number): string {
|
|
|
73
72
|
if (width === 1) return FRAME.side;
|
|
74
73
|
const inner = width - 2;
|
|
75
74
|
const room = width >= PADDED_WIDTH ? inner - 2 : inner;
|
|
76
|
-
const text = room <= 0 ? "" : fit(
|
|
75
|
+
const text = room <= 0 ? "" : fit(clampToWidth(sanitizeTerminalLine(line), room), room);
|
|
77
76
|
const pad = " ".repeat(Math.max(0, room - visibleWidth(text)));
|
|
78
77
|
const middle = width >= PADDED_WIDTH ? ` ${text}${pad} ` : `${text}${pad}`;
|
|
79
78
|
return `${FRAME.side}${middle}${FRAME.side}`;
|
package/src/overlay-scroll.ts
CHANGED
|
@@ -16,6 +16,11 @@ export function initialScroll(): Scroll {
|
|
|
16
16
|
return { offset: 0, following: true };
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** A viewport pinned to the first content line, released from follow-tail. */
|
|
20
|
+
export function topScroll(): Scroll {
|
|
21
|
+
return { offset: 0, following: false };
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
/** The largest valid top offset for `rows` of `total` content lines. */
|
|
20
25
|
export function maxOffset(total: number, rows: number): number {
|
|
21
26
|
return Math.max(0, total - Math.max(1, rows));
|
package/src/run-tree-view.ts
CHANGED
|
@@ -29,10 +29,10 @@ import {
|
|
|
29
29
|
} from "./run-view-state.ts";
|
|
30
30
|
import { routeSessionKey } from "./session-keys.ts";
|
|
31
31
|
import { renderStopPrompt, STOP_PROMPT_CHOICES } from "./stop-prompt.ts";
|
|
32
|
+
import { clampToWidth } from "./terminal-text.ts";
|
|
32
33
|
import { buildTree } from "./tree-model.ts";
|
|
33
34
|
import { TreeNavigator } from "./tree-navigator.ts";
|
|
34
35
|
import { renderTree } from "./tree-render.ts";
|
|
35
|
-
import { clamp } from "./tree-rows.ts";
|
|
36
36
|
import type { TreeState } from "./tree-state.ts";
|
|
37
37
|
|
|
38
38
|
// At tree level `esc` closes the view, live or settled, so the view replaces
|
|
@@ -165,7 +165,7 @@ export function createRunTreeView(options: RunTreeViewOptions): RunTreeView {
|
|
|
165
165
|
...(result === undefined ? {} : { result: resultText(result) }),
|
|
166
166
|
}),
|
|
167
167
|
];
|
|
168
|
-
lines[lines.length - 1] =
|
|
168
|
+
lines[lines.length - 1] = clampToWidth(VIEW_FOOTER, width);
|
|
169
169
|
const overlay = drill.overlayLines(width);
|
|
170
170
|
const framed =
|
|
171
171
|
overlay === undefined
|
package/src/snapshot-render.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import type { RunSummary } from "@yaag/runtime";
|
|
2
2
|
import { compactAgentLine, compactHeaderLine } from "./compact-render.ts";
|
|
3
3
|
import { renderNodeTable } from "./node-table.ts";
|
|
4
|
-
import { sanitizeTerminalLine } from "./terminal-text.ts";
|
|
4
|
+
import { clampToWidth, sanitizeTerminalLine } from "./terminal-text.ts";
|
|
5
5
|
import { buildTree } from "./tree-model.ts";
|
|
6
|
-
import { clamp } from "./tree-rows.ts";
|
|
7
6
|
import type { TreeState } from "./tree-state.ts";
|
|
8
7
|
|
|
9
8
|
/** Everything the model-facing snapshot needs; `now` keeps it clock-free. */
|
|
@@ -45,8 +44,10 @@ export function renderSnapshot(
|
|
|
45
44
|
return [
|
|
46
45
|
...lines,
|
|
47
46
|
"",
|
|
48
|
-
|
|
49
|
-
...options.result
|
|
47
|
+
clampToWidth("Result:", options.width),
|
|
48
|
+
...options.result
|
|
49
|
+
.split("\n")
|
|
50
|
+
.map((line) => clampToWidth(sanitizeTerminalLine(line), options.width)),
|
|
50
51
|
];
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -56,7 +57,7 @@ export function renderSnapshot(
|
|
|
56
57
|
*/
|
|
57
58
|
function checkpointLostLine(summary: RunSummary, width: number): string | undefined {
|
|
58
59
|
if (summary.runState !== "ended" || summary.checkpointLost === undefined) return undefined;
|
|
59
|
-
return
|
|
60
|
+
return clampToWidth(sanitizeTerminalLine(`! checkpoint lost: ${summary.checkpointLost}`), width);
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
function labelOf(options: SnapshotRenderOptions): { readonly label?: string } {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read-only, near-fullscreen system-prompt modal of the drill-in
|
|
3
|
+
* (ADR-0034).
|
|
4
|
+
*
|
|
5
|
+
* It replaces the pi editor the menu used to open: pi keeps no dialog stack for
|
|
6
|
+
* editor-container UI, so a pi editor opened under a non-overlay
|
|
7
|
+
* `ctx.ui.custom()` view removes that view and stops pi's input loop
|
|
8
|
+
* (ADR-0034). The Run view draws the prompt itself instead.
|
|
9
|
+
*
|
|
10
|
+
* Pure: two calls with the same content, scroll, and geometry return equal
|
|
11
|
+
* arrays. It holds no clock and no I/O.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { renderFramedBox } from "./overlay-frame.ts";
|
|
15
|
+
import { resolveOffset, type Scroll } from "./overlay-scroll.ts";
|
|
16
|
+
import { wrapLines } from "./wrap-text.ts";
|
|
17
|
+
|
|
18
|
+
const FOOTER = " ↑↓ scroll c copy path esc back";
|
|
19
|
+
|
|
20
|
+
/** Fraction of the terminal width the centered panel takes. */
|
|
21
|
+
const WIDTH_FRACTION = 0.9;
|
|
22
|
+
|
|
23
|
+
/** Frame rows the panel spends on its title, footer, and bottom border. */
|
|
24
|
+
const CHROME_ROWS = 3;
|
|
25
|
+
|
|
26
|
+
/** Frame columns one content row loses to the borders and their padding. */
|
|
27
|
+
const CHROME_COLUMNS = 4;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One system-prompt modal render request.
|
|
31
|
+
*
|
|
32
|
+
* Contract: `content` is the prompt already wrapped by `systemPromptLines` for
|
|
33
|
+
* this same `width`; `scroll` picks the visible slice; `rows` is the panel
|
|
34
|
+
* height, chrome included, and the panel always draws exactly that height, so
|
|
35
|
+
* a short prompt still fills the frame. `width` is the terminal width the panel
|
|
36
|
+
* is centered in; every returned line is exactly `width` columns. Failure mode:
|
|
37
|
+
* empty `content`, a `rows` or `width` of zero or less, and a scroll offset
|
|
38
|
+
* past the end all draw a valid panel instead of throwing.
|
|
39
|
+
*/
|
|
40
|
+
export interface SystemPromptOverlayOptions {
|
|
41
|
+
/** The node path the modal is titled with (spec §1 node-path grammar). */
|
|
42
|
+
readonly nodePath: string;
|
|
43
|
+
/** The prompt body, one entry per display line. */
|
|
44
|
+
readonly content: readonly string[];
|
|
45
|
+
readonly scroll: Scroll;
|
|
46
|
+
/** Panel height, chrome included; a value under four draws one content row. */
|
|
47
|
+
readonly rows: number;
|
|
48
|
+
/** The terminal width; the panel is narrower and centered inside it. */
|
|
49
|
+
readonly width: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The content rows a panel of `rows` total height draws. */
|
|
53
|
+
export function systemPromptContentRows(rows: number): number {
|
|
54
|
+
return Math.max(1, floor(rows) - CHROME_ROWS);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Wraps a prompt body to the panel drawn at `width`.
|
|
59
|
+
*
|
|
60
|
+
* The panel has no horizontal scroll, so the wrap is what makes a long
|
|
61
|
+
* paragraph reachable. The caller keeps the result as the scroll document, so
|
|
62
|
+
* the line count the reducer clamps against is the count the panel draws.
|
|
63
|
+
*/
|
|
64
|
+
export function systemPromptLines(body: string, width: number): readonly string[] {
|
|
65
|
+
return wrapLines(body.split("\n"), contentWidth(width));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Draws the centered, scrolled modal for one node's system prompt. */
|
|
69
|
+
export function renderSystemPromptOverlay(options: SystemPromptOverlayOptions): readonly string[] {
|
|
70
|
+
const width = frameWidth(options.width);
|
|
71
|
+
const panel = panelWidth(width);
|
|
72
|
+
const margin = " ".repeat(Math.max(0, Math.floor((width - panel) / 2)));
|
|
73
|
+
const rows = systemPromptContentRows(options.rows);
|
|
74
|
+
const offset = resolveOffset(options.scroll, options.content.length, rows);
|
|
75
|
+
const visible = options.content.slice(offset, offset + rows);
|
|
76
|
+
return renderFramedBox({
|
|
77
|
+
title: `System prompt — ${options.nodePath}`,
|
|
78
|
+
// The panel keeps its height, so the tree never shows through under a
|
|
79
|
+
// short prompt and the reader's eye keeps one frame.
|
|
80
|
+
lines: [...visible, ...blankRows(rows - visible.length)],
|
|
81
|
+
footer: FOOTER,
|
|
82
|
+
width: panel,
|
|
83
|
+
}).map((line) => `${margin}${line}${padding(width - margin.length - panel)}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Content columns one panel row holds at terminal width `width`. */
|
|
87
|
+
function contentWidth(width: number): number {
|
|
88
|
+
return Math.max(1, panelWidth(frameWidth(width)) - CHROME_COLUMNS);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function panelWidth(width: number): number {
|
|
92
|
+
return Math.max(1, Math.min(width, Math.round(width * WIDTH_FRACTION)));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function frameWidth(width: number): number {
|
|
96
|
+
return Number.isFinite(width) ? Math.max(1, Math.floor(width)) : 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `count` empty content rows, or none when the content already fills them. */
|
|
100
|
+
function blankRows(count: number): readonly string[] {
|
|
101
|
+
return Array.from({ length: Math.max(0, count) }, () => "");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function padding(columns: number): string {
|
|
105
|
+
return " ".repeat(Math.max(0, columns));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function floor(value: number): number {
|
|
109
|
+
return Math.floor(Number.isFinite(value) ? value : 0);
|
|
110
|
+
}
|
package/src/terminal-text.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Removes terminal control sequences from untrusted event text while retaining
|
|
3
5
|
* printable Unicode and newlines used by Ask output tails.
|
|
@@ -30,6 +32,43 @@ export function sanitizeTerminalLine(value: string): string {
|
|
|
30
32
|
return sanitizeTerminalText(value).replace(/\n+/g, " ");
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Truncates one already sanitized line to a terminal column count, measuring
|
|
37
|
+
* columns rather than code points so a CJK or emoji label cannot overflow.
|
|
38
|
+
*
|
|
39
|
+
* Sanitize the value first ({@link sanitizeTerminalLine}): this function
|
|
40
|
+
* measures and cuts, and it removes no control sequence. The result never
|
|
41
|
+
* exceeds `columnLimit(width)` display columns, and it ends with
|
|
42
|
+
* `…` when the value was cut. Iteration is by code point, so the result holds
|
|
43
|
+
* no half of a surrogate pair and no replacement character. A grapheme cluster
|
|
44
|
+
* such as a ZWJ emoji sequence can still split into its parts at the cut
|
|
45
|
+
* point, which is acceptable for a truncated label.
|
|
46
|
+
*
|
|
47
|
+
* A width that is not a finite number cannot be measured against, so it
|
|
48
|
+
* degrades to one column instead of throwing or returning an unbounded line.
|
|
49
|
+
*
|
|
50
|
+
* pi-tui's `truncateToWidth` is deliberately not used: it appends a colour
|
|
51
|
+
* reset sequence, and this package emits unstyled lines only.
|
|
52
|
+
*/
|
|
53
|
+
export function clampToWidth(value: string, width: number): string {
|
|
54
|
+
const limit = columnLimit(width);
|
|
55
|
+
if (visibleWidth(value) <= limit) return value;
|
|
56
|
+
let text = "";
|
|
57
|
+
let used = 0;
|
|
58
|
+
for (const character of value) {
|
|
59
|
+
const next = used + visibleWidth(character);
|
|
60
|
+
if (next > limit - 1) break;
|
|
61
|
+
text += character;
|
|
62
|
+
used = next;
|
|
63
|
+
}
|
|
64
|
+
return `${text}…`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function columnLimit(width: number): number {
|
|
68
|
+
if (!Number.isFinite(width)) return 1;
|
|
69
|
+
return Math.max(1, Math.floor(width));
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
function skipEscape(value: string, index: number): number {
|
|
34
73
|
const next = value.charCodeAt(index + 1);
|
|
35
74
|
if (next === 91) return skipCsi(value, index + 2);
|
package/src/tree-render.ts
CHANGED
|
@@ -2,11 +2,11 @@ import { costText } from "./accounting-text.ts";
|
|
|
2
2
|
import { renderDetailsPane } from "./details-pane.ts";
|
|
3
3
|
import { durationText } from "./duration-text.ts";
|
|
4
4
|
import { agentOfPath } from "./node-path-parse.ts";
|
|
5
|
-
import { sanitizeTerminalLine } from "./terminal-text.ts";
|
|
5
|
+
import { clampToWidth, sanitizeTerminalLine } from "./terminal-text.ts";
|
|
6
6
|
import { emptyFold, type FoldState, type VisibleRow, visibleRows } from "./tree-fold.ts";
|
|
7
7
|
import { buildTree } from "./tree-model.ts";
|
|
8
8
|
import type { TreeNode } from "./tree-node.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { renderRow } from "./tree-rows.ts";
|
|
10
10
|
import type { TreeState } from "./tree-state.ts";
|
|
11
11
|
|
|
12
12
|
/** Tree rows drawn before the renderer rolls the remainder into one line. */
|
|
@@ -47,12 +47,12 @@ export function renderTree(state: TreeState, options: TreeRenderOptions): readon
|
|
|
47
47
|
if (details.length > 0) lines.push(...details, rule);
|
|
48
48
|
const result = options.result ?? state.result;
|
|
49
49
|
if (result !== undefined) {
|
|
50
|
-
lines.push(
|
|
50
|
+
lines.push(clampToWidth("Result:", options.width));
|
|
51
51
|
for (const line of result.split("\n"))
|
|
52
|
-
lines.push(
|
|
52
|
+
lines.push(clampToWidth(` ${sanitizeTerminalLine(line)}`, options.width));
|
|
53
53
|
lines.push(rule);
|
|
54
54
|
}
|
|
55
|
-
lines.push(
|
|
55
|
+
lines.push(clampToWidth(FOOTER, options.width));
|
|
56
56
|
return lines;
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -62,7 +62,7 @@ function treeLines(rows: readonly VisibleRow[], options: TreeRenderOptions): rea
|
|
|
62
62
|
renderRow(row, { width: options.width, selectedPath: options.selectedPath }),
|
|
63
63
|
);
|
|
64
64
|
const hidden = rows.length - shown.length;
|
|
65
|
-
if (hidden > 0) lines.push(
|
|
65
|
+
if (hidden > 0) lines.push(clampToWidth(` and ${hidden} more running`, options.width));
|
|
66
66
|
return lines;
|
|
67
67
|
}
|
|
68
68
|
|
|
@@ -76,7 +76,7 @@ function headerLine(state: TreeState, options: TreeRenderOptions): string {
|
|
|
76
76
|
const asks = `${summary.asksSettled} ask${summary.asksSettled === 1 ? "" : "s"} settled`;
|
|
77
77
|
const label = sanitizeTerminalLine(options.label ?? "yaag");
|
|
78
78
|
const program = sanitizeTerminalLine(summary.program === "" ? "Run" : summary.program);
|
|
79
|
-
return
|
|
79
|
+
return clampToWidth(
|
|
80
80
|
`${label} ▸ ${program} ${status} ${durationText(elapsed)} ${costText(summary.cost, summary.incomplete)} · ${asks}`,
|
|
81
81
|
options.width,
|
|
82
82
|
);
|
package/src/tree-rows.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { sanitizeTerminalLine } from "./terminal-text.ts";
|
|
1
|
+
import { clampToWidth, sanitizeTerminalLine } from "./terminal-text.ts";
|
|
3
2
|
import type { VisibleRow } from "./tree-fold.ts";
|
|
4
3
|
import { foldGlyph, GLYPHS, stateGlyph } from "./tree-glyphs.ts";
|
|
5
4
|
|
|
@@ -21,11 +20,11 @@ export function renderRow(row: VisibleRow, options: TreeRowOptions): readonly st
|
|
|
21
20
|
const selected = options.selectedPath === row.node.path;
|
|
22
21
|
const facts = row.node.facts.map(sanitizeTerminalLine).join(" · ");
|
|
23
22
|
const head = `${selected ? "❯" : " "}${prefix}${foldGlyph(row.hasChildren, row.expanded)} ${stateGlyph(row.node.state)} ${sanitizeTerminalLine(row.node.label)}`;
|
|
24
|
-
const lines = [
|
|
23
|
+
const lines = [clampToWidth(facts === "" ? head : `${head} ${facts}`, options.width)];
|
|
25
24
|
const gist = row.node.activityGist;
|
|
26
25
|
if (gist !== null && !row.expanded) {
|
|
27
26
|
lines.push(
|
|
28
|
-
|
|
27
|
+
clampToWidth(
|
|
29
28
|
` ${prefix} ${GLYPHS.branch} ${GLYPHS.running} ${sanitizeTerminalLine(gist)}`,
|
|
30
29
|
options.width,
|
|
31
30
|
),
|
|
@@ -34,27 +33,6 @@ export function renderRow(row: VisibleRow, options: TreeRowOptions): readonly st
|
|
|
34
33
|
return lines;
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
/**
|
|
38
|
-
* Truncates one already sanitized line to a terminal column count, measuring
|
|
39
|
-
* columns rather than code points so a CJK or emoji label cannot overflow.
|
|
40
|
-
*
|
|
41
|
-
* pi-tui's `truncateToWidth` is deliberately not used: it appends a colour
|
|
42
|
-
* reset sequence, and this package emits unstyled lines only.
|
|
43
|
-
*/
|
|
44
|
-
export function clamp(value: string, width: number): string {
|
|
45
|
-
const limit = Math.max(1, Math.floor(width));
|
|
46
|
-
if (visibleWidth(value) <= limit) return value;
|
|
47
|
-
let text = "";
|
|
48
|
-
let used = 0;
|
|
49
|
-
for (const character of value) {
|
|
50
|
-
const next = used + visibleWidth(character);
|
|
51
|
-
if (next > limit - 1) break;
|
|
52
|
-
text += character;
|
|
53
|
-
used = next;
|
|
54
|
-
}
|
|
55
|
-
return `${text}…`;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
36
|
function ancestryPrefix(row: VisibleRow): string {
|
|
59
37
|
let prefix = "";
|
|
60
38
|
for (let depth = 0; depth < row.depth; depth += 1) {
|
package/src/wrap-text.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display-width word wrap for read-only panels.
|
|
3
|
+
*
|
|
4
|
+
* A framed panel truncates a line that does not fit (`overlay-frame.ts`), and a
|
|
5
|
+
* panel has no horizontal scroll, so text that must stay readable is wrapped
|
|
6
|
+
* before it is framed. Pure and I/O-free.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wraps every line to `width` display columns.
|
|
13
|
+
*
|
|
14
|
+
* Words are kept whole while they fit, and a word wider than the whole line is
|
|
15
|
+
* broken at the column limit, so no word is dropped. Whitespace is kept where
|
|
16
|
+
* it fits, so a line's indentation and its aligned columns survive the wrap;
|
|
17
|
+
* whitespace at the end of an emitted line is dropped, as a wrap always does. An
|
|
18
|
+
* empty line is kept, because a prompt's paragraph breaks carry meaning. A
|
|
19
|
+
* `width` under one column cannot be drawn, so it degrades to one column
|
|
20
|
+
* instead of throwing.
|
|
21
|
+
*/
|
|
22
|
+
export function wrapLines(lines: readonly string[], width: number): readonly string[] {
|
|
23
|
+
const limit = Number.isFinite(width) ? Math.max(1, Math.floor(width)) : 1;
|
|
24
|
+
return lines.flatMap((line) => wrapLine(line, limit));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function wrapLine(line: string, limit: number): string[] {
|
|
28
|
+
if (visibleWidth(line) <= limit) return [line];
|
|
29
|
+
const out: string[] = [];
|
|
30
|
+
let current = "";
|
|
31
|
+
// Whitespace is a token of its own, so a run of spaces is carried verbatim
|
|
32
|
+
// while it fits and dropped only where the line breaks.
|
|
33
|
+
for (const token of line.split(/(\s+)/)) {
|
|
34
|
+
if (token === "") continue;
|
|
35
|
+
if (/^\s+$/.test(token)) {
|
|
36
|
+
if (visibleWidth(current + token) <= limit) current += token;
|
|
37
|
+
else {
|
|
38
|
+
out.push(current.trimEnd());
|
|
39
|
+
current = "";
|
|
40
|
+
}
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
for (const piece of breakWord(token, limit)) {
|
|
44
|
+
if (visibleWidth(current + piece) <= limit) {
|
|
45
|
+
current += piece;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (current !== "") out.push(current.trimEnd());
|
|
49
|
+
current = piece;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const last = current.trimEnd();
|
|
53
|
+
if (last !== "" || out.length === 0) out.push(last);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Splits one word into pieces of at most `limit` columns each. */
|
|
58
|
+
function breakWord(word: string, limit: number): string[] {
|
|
59
|
+
if (visibleWidth(word) <= limit) return [word];
|
|
60
|
+
const pieces: string[] = [];
|
|
61
|
+
let piece = "";
|
|
62
|
+
let used = 0;
|
|
63
|
+
for (const character of word) {
|
|
64
|
+
const size = visibleWidth(character);
|
|
65
|
+
if (used + size > limit) {
|
|
66
|
+
pieces.push(piece);
|
|
67
|
+
piece = "";
|
|
68
|
+
used = 0;
|
|
69
|
+
}
|
|
70
|
+
piece += character;
|
|
71
|
+
used += size;
|
|
72
|
+
}
|
|
73
|
+
if (piece !== "") pieces.push(piece);
|
|
74
|
+
return pieces;
|
|
75
|
+
}
|