@yaag/tui 0.2.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaag/tui",
3
- "version": "0.2.1",
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.2.1"
22
+ "@yaag/runtime": "0.4.0"
23
23
  }
24
24
  }
@@ -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 clamp(
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 clamp(agentLine(agent), width);
57
+ return clampToWidth(agentLine(agent), width);
59
58
  }
60
59
 
61
60
  function agentLine(agent: TreeNode): string {
@@ -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
- clamp(`${stateGlyph(node.state)} ${sanitizeTerminalLine(node.path)}`, options.width),
22
+ clampToWidth(`${stateGlyph(node.state)} ${sanitizeTerminalLine(node.path)}`, options.width),
24
23
  ];
25
24
  if (node.activityGist !== null)
26
- lines.push(clamp(` ${sanitizeTerminalLine(node.activityGist)}`, options.width));
25
+ lines.push(clampToWidth(` ${sanitizeTerminalLine(node.activityGist)}`, options.width));
27
26
  if (node.facts.length > 0)
28
- lines.push(clamp(` ${node.facts.map(sanitizeTerminalLine).join(" · ")}`, options.width));
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(clamp(` last: “${sanitizeTerminalLine(last)}”`, options.width));
32
+ lines.push(clampToWidth(` last: “${sanitizeTerminalLine(last)}”`, options.width));
32
33
  return lines;
33
34
  }
@@ -1,10 +1,11 @@
1
1
  /**
2
- * The drill-in controller: it layers the actions menu and the read-only
3
- * transcript overlay over the Run tree (spec §1).
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, editor, notify, and
6
- * session reads and nothing else, so no code path here can prompt an Agent or
7
- * stop a Run (ADR — a Peek observes, it never sends). It holds no clock either;
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
- /** Content rows the overlay may draw. */
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
- /** Content of the currently open node, keyed by its path, or undefined. */
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.path),
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: before.kind === "transcript" ? this.#linesFor(before.path).length : 0,
106
- rows: this.#host.rows(),
129
+ total: this.#linesFor(before).length,
130
+ rows: this.#pageRows(before),
107
131
  });
108
132
  this.#state = state;
109
- if (state.kind !== "transcript" || state.path !== openPath(before)) this.#invalidate();
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 "openSystemPrompt":
150
- await this.#openSystemPrompt(effect.path);
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
- /** Loaded lines for `nodePath`, or none while its own read is in flight. */
181
- #linesFor(nodePath: string): readonly string[] {
182
- const content = this.#content;
183
- return content !== undefined && content.path === nodePath ? content.lines : [];
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
- async #openSystemPrompt(nodePath: string): Promise<void> {
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
- await this.#host.openEditor(`System prompt ${nodePath}`, prompt ?? NO_SYSTEM_PROMPT);
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 node path of an open transcript overlay, or undefined for other layers. */
228
- function openPath(state: DrillState): string | undefined {
229
- return state.kind === "transcript" ? state.path : undefined;
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 consumes. */
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 transcript overlay's keys. */
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"] },
@@ -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
- * 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).
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: "openSystemPrompt"; readonly path: string }
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 "openSystemPrompt":
116
- return { state, effect: { kind: "openSystemPrompt", path: state.path } };
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
- function fromTranscript(
123
- state: {
124
- readonly kind: "transcript";
125
- readonly path: string;
126
- readonly from: "tree" | "menu";
127
- readonly scroll: Scroll;
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 { sanitizeTerminalLine, sanitizeTerminalText } from "./terminal-text.ts";
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,
@@ -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
- clamp(` … +${hidden} more`, options.width),
55
+ clampToWidth(` … +${hidden} more`, options.width),
56
56
  ];
57
57
  }
58
58
 
@@ -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" | "openSystemPrompt";
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: "openSystemPrompt", label: "Open system prompt in editor" },
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) => clamp(` ▸ ${nodeLine(node)}`, columns));
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(clamp(` and ${hiddenRunning} more running`, columns));
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(clamp(` and ${finished} more finished`, columns));
25
+ if (finished > 0) lines.push(clampToWidth(` and ${finished} more finished`, columns));
27
26
  return lines;
28
27
  }
29
28
 
@@ -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} ${clamp(sanitizeTerminalLine(title), width - 5)} `, inner)
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(clamp(sanitizeTerminalLine(line), room), room);
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}`;
@@ -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));
@@ -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] = clamp(VIEW_FOOTER, width);
168
+ lines[lines.length - 1] = clampToWidth(VIEW_FOOTER, width);
169
169
  const overlay = drill.overlayLines(width);
170
170
  const framed =
171
171
  overlay === undefined
@@ -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
- clamp("Result:", options.width),
49
- ...options.result.split("\n").map((line) => clamp(sanitizeTerminalLine(line), options.width)),
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 clamp(sanitizeTerminalLine(`! checkpoint lost: ${summary.checkpointLost}`), width);
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
+ }
@@ -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);
@@ -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 { clamp, renderRow } from "./tree-rows.ts";
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(clamp("Result:", options.width));
50
+ lines.push(clampToWidth("Result:", options.width));
51
51
  for (const line of result.split("\n"))
52
- lines.push(clamp(` ${sanitizeTerminalLine(line)}`, options.width));
52
+ lines.push(clampToWidth(` ${sanitizeTerminalLine(line)}`, options.width));
53
53
  lines.push(rule);
54
54
  }
55
- lines.push(clamp(FOOTER, options.width));
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(clamp(` and ${hidden} more running`, options.width));
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 clamp(
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 { visibleWidth } from "@earendil-works/pi-tui";
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 = [clamp(facts === "" ? head : `${head} ${facts}`, options.width)];
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
- clamp(
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) {
@@ -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
+ }