@yaag/tui 0.3.0 → 0.5.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.3.0",
3
+ "version": "0.5.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.3.0"
22
+ "@yaag/runtime": "0.5.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
  */
@@ -20,7 +21,9 @@ import {
20
21
  import type { NamedKeybindings } from "./key-router.ts";
21
22
  import { renderActionsMenu } from "./node-actions.ts";
22
23
  import { agentOfPath } from "./node-path-parse.ts";
24
+ import { overlayContentRows, overlayPanelRows } from "./overlay-geometry.ts";
23
25
  import { extractSystemPrompt } from "./session-transcript.ts";
26
+ import { renderSystemPromptOverlay, systemPromptLines } from "./system-prompt-overlay.ts";
24
27
  import { nodeTranscriptLines } from "./transcript-content.ts";
25
28
  import { renderTranscriptOverlay } from "./transcript-overlay.ts";
26
29
  import { NO_TRANSCRIPT_STUB } from "./transcript-render.ts";
@@ -29,6 +32,9 @@ import type { TreeNavigator } from "./tree-navigator.ts";
29
32
  /** Shown when a session file records no system prompt (spec §5). */
30
33
  const NO_SYSTEM_PROMPT = "(no system prompt recorded in this session file)";
31
34
 
35
+ /** Assumed frame width until the first render reports the real one. */
36
+ const DEFAULT_WIDTH = 80;
37
+
32
38
  /** Every capability the drill-in needs from its host — all of them read-only. */
33
39
  export interface DrillHost {
34
40
  /** The tree's keyboard router; the drill layers sit above it. */
@@ -39,13 +45,18 @@ export interface DrillHost {
39
45
  agentInfo(agent: string): AgentInfo | undefined;
40
46
  /** Reads an Agent's session file body, or null when it is missing. */
41
47
  readSession(agent: string): Promise<string | null>;
48
+ /**
49
+ * Reads the system-prompt sidecar the Agent's own pi wrote, or null when
50
+ * there is none — an Agent of a replayed Run, or a session that predates the
51
+ * sidecar.
52
+ */
53
+ readSystemPromptSidecar(agent: string): Promise<string | null>;
42
54
  /** An Agent's session file path, or null when the spawn reported none. */
43
55
  sessionPath(agent: string): string | null;
44
56
  copyPath(text: string): Promise<void>;
45
- openEditor(title: string, body: string): Promise<void>;
46
57
  notify(message: string, level: "info" | "warning" | "error"): void;
47
58
  requestRender(): void;
48
- /** Content rows the overlay may draw. */
59
+ /** The row budget of a layer; each layer states how it spends it. */
49
60
  rows(): number;
50
61
  /** Releases input focus when `esc` closes the last layer. */
51
62
  dropFocus?(): void;
@@ -62,10 +73,17 @@ export interface DrillHost {
62
73
  export class DrillController {
63
74
  readonly #host: DrillHost;
64
75
  #state: DrillState = emptyDrill();
65
- /** Content of the currently open node, keyed by its path, or undefined. */
76
+ /** Transcript lines of the open node, or undefined. */
66
77
  #content: { readonly path: string; readonly lines: readonly string[] } | undefined;
78
+ /** The open node's raw system prompt; the modal wraps it per render width. */
79
+ #prompt: { readonly path: string; readonly body: string } | undefined;
67
80
  /** Bumped by every open, refresh, and close, to void in-flight reads. */
68
81
  #generation = 0;
82
+ /**
83
+ * Width of the last frame drawn. The modal wraps its prompt to the panel, so
84
+ * the reducer must clamp scrolling against the geometry the reader sees.
85
+ */
86
+ #renderWidth = DEFAULT_WIDTH;
69
87
 
70
88
  constructor(host: DrillHost) {
71
89
  this.#host = host;
@@ -78,6 +96,7 @@ export class DrillController {
78
96
 
79
97
  /** The framed overlay lines, or undefined while the tree holds focus. */
80
98
  overlayLines(width: number): readonly string[] | undefined {
99
+ this.#renderWidth = width;
81
100
  const state = this.#state;
82
101
  switch (state.kind) {
83
102
  case "tree":
@@ -87,14 +106,30 @@ export class DrillController {
87
106
  case "transcript":
88
107
  return renderTranscriptOverlay({
89
108
  nodePath: state.path,
90
- content: this.#linesFor(state.path),
109
+ content: this.#linesFor(state),
91
110
  scroll: state.scroll,
92
- rows: this.#host.rows(),
111
+ rows: this.#panelRows(),
112
+ width,
113
+ });
114
+ case "systemPrompt":
115
+ return renderSystemPromptOverlay({
116
+ nodePath: state.path,
117
+ content: this.#linesFor(state),
118
+ scroll: state.scroll,
119
+ rows: this.#panelRows(),
93
120
  width,
94
121
  });
95
122
  }
96
123
  }
97
124
 
125
+ /**
126
+ * The total panel height both overlays draw at, derived from the host's row
127
+ * budget by the shared geometry so pi's own chrome keeps its rows.
128
+ */
129
+ #panelRows(): number {
130
+ return overlayPanelRows(this.#host.rows());
131
+ }
132
+
98
133
  /** Routes one input byte string; returns whether a layer consumed it. */
99
134
  handleInput(data: string): boolean {
100
135
  const action = this.#route(data);
@@ -102,11 +137,13 @@ export class DrillController {
102
137
  const before = this.#state;
103
138
  const { state, effect } = applyDrillAction(before, action, {
104
139
  selectedPath: this.#host.navigator.selectedPath,
105
- total: before.kind === "transcript" ? this.#linesFor(before.path).length : 0,
106
- rows: this.#host.rows(),
140
+ total: this.#linesFor(before).length,
141
+ rows: this.#pageRows(before),
107
142
  });
108
143
  this.#state = state;
109
- if (state.kind !== "transcript" || state.path !== openPath(before)) this.#invalidate();
144
+ if (contentKey(state) === undefined || contentKey(state) !== contentKey(before)) {
145
+ this.#invalidate();
146
+ }
110
147
  if (effect !== undefined) void this.#perform(effect);
111
148
  if (state !== before || effect !== undefined) this.#host.requestRender();
112
149
  return true;
@@ -125,6 +162,9 @@ export class DrillController {
125
162
  #route(data: string): AnyDrillAction | undefined {
126
163
  const keybindings = this.#host.keybindings;
127
164
  switch (this.#state.kind) {
165
+ // The modal reuses the overlay key table: the same scroll, copy, and
166
+ // close gestures apply.
167
+ case "systemPrompt":
128
168
  case "transcript":
129
169
  return routeOverlayKey(data, keybindings);
130
170
  case "menu":
@@ -146,8 +186,8 @@ export class DrillController {
146
186
  case "copySessionPath":
147
187
  await this.#copySessionPath(effect.path);
148
188
  return;
149
- case "openSystemPrompt":
150
- await this.#openSystemPrompt(effect.path);
189
+ case "loadSystemPrompt":
190
+ await this.#loadSystemPrompt(effect.path, this.#nextGeneration());
151
191
  return;
152
192
  case "dropFocus":
153
193
  this.#host.dropFocus?.();
@@ -177,15 +217,40 @@ export class DrillController {
177
217
  this.#host.requestRender();
178
218
  }
179
219
 
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 : [];
220
+ /**
221
+ * The scroll document of one layer, or none while its own read is in flight.
222
+ *
223
+ * The modal's document is the prompt wrapped to the panel, so the line count
224
+ * the reducer clamps against is the count the panel draws.
225
+ */
226
+ #linesFor(state: DrillState): readonly string[] {
227
+ switch (state.kind) {
228
+ case "transcript":
229
+ return this.#content?.path === state.path ? this.#content.lines : [];
230
+ case "systemPrompt":
231
+ return this.#prompt?.path === state.path
232
+ ? systemPromptLines(this.#prompt.body, this.#renderWidth)
233
+ : [];
234
+ default:
235
+ return [];
236
+ }
237
+ }
238
+
239
+ /**
240
+ * The page height the open layer scrolls by. Both overlays draw the same
241
+ * panel and spend the same frame rows, so both page by its content rows and
242
+ * scrolling matches what is drawn.
243
+ */
244
+ #pageRows(state: DrillState): number {
245
+ return state.kind === "tree" || state.kind === "menu"
246
+ ? this.#host.rows()
247
+ : overlayContentRows(this.#panelRows());
184
248
  }
185
249
 
186
250
  /** Drops retained content and voids every in-flight read. */
187
251
  #invalidate(): void {
188
252
  this.#content = undefined;
253
+ this.#prompt = undefined;
189
254
  this.#generation += 1;
190
255
  }
191
256
 
@@ -208,10 +273,42 @@ export class DrillController {
208
273
  }
209
274
  }
210
275
 
211
- async #openSystemPrompt(nodePath: string): Promise<void> {
212
- const session = await this.#readSession(agentOfPath(nodePath));
213
- const prompt = session === null ? null : extractSystemPrompt(session);
214
- await this.#host.openEditor(`System prompt ${nodePath}`, prompt ?? NO_SYSTEM_PROMPT);
276
+ /**
277
+ * Reads one node's system prompt and commits it under the same
278
+ * generation guard as `#loadTranscript`, so a slow read cannot land under a
279
+ * closed or replaced modal.
280
+ */
281
+ async #loadSystemPrompt(nodePath: string, generation: number): Promise<void> {
282
+ const agent = agentOfPath(nodePath);
283
+ const prompt = await this.#readPrompt(agent);
284
+ if (generation !== this.#generation) return;
285
+ const state = this.#state;
286
+ if (state.kind !== "systemPrompt" || state.path !== nodePath) return;
287
+ this.#prompt = { path: nodePath, body: prompt ?? NO_SYSTEM_PROMPT };
288
+ this.#host.requestRender();
289
+ }
290
+
291
+ /**
292
+ * The Agent's system prompt, from the sidecar its own pi wrote, or else from
293
+ * the session file, or null when neither records one.
294
+ *
295
+ * The sidecar wins because it holds the prompt pi really assembled; the
296
+ * session file is read only for a session written before the sidecar existed.
297
+ */
298
+ async #readPrompt(agent: string): Promise<string | null> {
299
+ const sidecar = await this.#readSidecar(agent);
300
+ if (sidecar !== null && sidecar !== "") return sidecar;
301
+ const session = await this.#readSession(agent);
302
+ return session === null ? null : extractSystemPrompt(session);
303
+ }
304
+
305
+ /** A sidecar that cannot be read degrades to the session file. */
306
+ async #readSidecar(agent: string): Promise<string | null> {
307
+ try {
308
+ return await this.#host.readSystemPromptSidecar(agent);
309
+ } catch {
310
+ return null;
311
+ }
215
312
  }
216
313
 
217
314
  /** A session file that cannot be read degrades to the labelled stub. */
@@ -224,9 +321,11 @@ export class DrillController {
224
321
  }
225
322
  }
226
323
 
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;
324
+ /** The content key of a layer that loads content, or undefined for the others. */
325
+ function contentKey(state: DrillState): string | undefined {
326
+ return state.kind === "transcript" || state.kind === "systemPrompt"
327
+ ? `${state.kind}:${state.path}`
328
+ : undefined;
230
329
  }
231
330
 
232
331
  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
@@ -40,6 +40,11 @@ export {
40
40
  export { agentOfPath, type NodePathSegment, parseNodePath } from "./node-path-parse.ts";
41
41
  export { renderNodeTable } from "./node-table.ts";
42
42
  export { type FramedBoxOptions, renderFramedBox } from "./overlay-frame.ts";
43
+ export {
44
+ OVERLAY_CHROME_ROWS,
45
+ overlayContentRows,
46
+ overlayPanelRows,
47
+ } from "./overlay-geometry.ts";
43
48
  export {
44
49
  initialScroll,
45
50
  maxOffset,
@@ -78,7 +83,11 @@ export {
78
83
  type StopChoiceRow,
79
84
  type StopPromptOptions,
80
85
  } from "./stop-prompt.ts";
81
- export { sanitizeTerminalLine, sanitizeTerminalText } from "./terminal-text.ts";
86
+ export {
87
+ renderSystemPromptOverlay,
88
+ type SystemPromptOverlayOptions,
89
+ } from "./system-prompt-overlay.ts";
90
+ export { clampToWidth, sanitizeTerminalLine, sanitizeTerminalText } from "./terminal-text.ts";
82
91
  export { type NodeTranscriptOptions, nodeTranscriptLines } from "./transcript-content.ts";
83
92
  export {
84
93
  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}`;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared height geometry for the drill-in overlays (ADR-0034).
3
+ *
4
+ * The Run view is a non-overlay `ctx.ui.custom()` component: pi draws its own
5
+ * chrome — the editor box and the status line — below the component, and it
6
+ * gives the component no height budget. A panel as tall as the terminal
7
+ * therefore pushes that chrome off the screen. These two pure helpers give
8
+ * every overlay one contract: `overlayPanelRows` turns a terminal row budget
9
+ * into the TOTAL panel height, and `overlayContentRows` turns that total into
10
+ * the content rows the panel can draw.
11
+ */
12
+
13
+ /** Frame rows a panel spends on its title, its footer, and its bottom border. */
14
+ export const OVERLAY_CHROME_ROWS = 3;
15
+
16
+ /** Fraction of the row budget a panel takes on a tall terminal. */
17
+ const HEIGHT_FRACTION = 0.9;
18
+
19
+ /** Rows kept free for pi chrome below the component, on a short terminal. */
20
+ const HOST_CHROME_ROWS = 6;
21
+
22
+ /** The shortest panel that still draws one content row. */
23
+ const MIN_PANEL_ROWS = 4;
24
+
25
+ /**
26
+ * The total panel height for a terminal row budget of `budget`.
27
+ *
28
+ * The fraction keeps a margin on a tall terminal; the absolute reserve keeps
29
+ * one on a short terminal, where a fraction alone leaves too little. A budget
30
+ * smaller than both floors to the shortest drawable panel. Failure mode: a
31
+ * budget that is not a finite number degrades to that same floor.
32
+ */
33
+ export function overlayPanelRows(budget: number): number {
34
+ if (!Number.isFinite(budget)) return MIN_PANEL_ROWS;
35
+ const rows = Math.floor(budget);
36
+ const fitted = Math.min(Math.round(rows * HEIGHT_FRACTION), rows - HOST_CHROME_ROWS);
37
+ return Math.max(MIN_PANEL_ROWS, fitted);
38
+ }
39
+
40
+ /** The content rows a panel of `panelRows` total height draws. */
41
+ export function overlayContentRows(panelRows: number): number {
42
+ const rows = Number.isFinite(panelRows) ? Math.floor(panelRows) : 0;
43
+ return Math.max(1, rows - OVERLAY_CHROME_ROWS);
44
+ }
@@ -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,99 @@
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 { overlayContentRows } from "./overlay-geometry.ts";
16
+ import { resolveOffset, type Scroll } from "./overlay-scroll.ts";
17
+ import { wrapLines } from "./wrap-text.ts";
18
+
19
+ const FOOTER = " ↑↓ scroll c copy path esc back";
20
+
21
+ /** Fraction of the terminal width the centered panel takes. */
22
+ const WIDTH_FRACTION = 0.9;
23
+
24
+ /** Frame columns one content row loses to the borders and their padding. */
25
+ const CHROME_COLUMNS = 4;
26
+
27
+ /**
28
+ * One system-prompt modal render request.
29
+ *
30
+ * Contract: `content` is the prompt already wrapped by `systemPromptLines` for
31
+ * this same `width`; `scroll` picks the visible slice; `rows` is the panel
32
+ * height, chrome included, and the panel always draws exactly that height, so
33
+ * a short prompt still fills the frame. `width` is the terminal width the panel
34
+ * is centered in; every returned line is exactly `width` columns. Failure mode:
35
+ * empty `content`, a `rows` or `width` of zero or less, and a scroll offset
36
+ * past the end all draw a valid panel instead of throwing.
37
+ */
38
+ export interface SystemPromptOverlayOptions {
39
+ /** The node path the modal is titled with (spec §1 node-path grammar). */
40
+ readonly nodePath: string;
41
+ /** The prompt body, one entry per display line. */
42
+ readonly content: readonly string[];
43
+ readonly scroll: Scroll;
44
+ /** Panel height, chrome included; a value under four draws one content row. */
45
+ readonly rows: number;
46
+ /** The terminal width; the panel is narrower and centered inside it. */
47
+ readonly width: number;
48
+ }
49
+
50
+ /**
51
+ * Wraps a prompt body to the panel drawn at `width`.
52
+ *
53
+ * The panel has no horizontal scroll, so the wrap is what makes a long
54
+ * paragraph reachable. The caller keeps the result as the scroll document, so
55
+ * the line count the reducer clamps against is the count the panel draws.
56
+ */
57
+ export function systemPromptLines(body: string, width: number): readonly string[] {
58
+ return wrapLines(body.split("\n"), contentWidth(width));
59
+ }
60
+
61
+ /** Draws the centered, scrolled modal for one node's system prompt. */
62
+ export function renderSystemPromptOverlay(options: SystemPromptOverlayOptions): readonly string[] {
63
+ const width = frameWidth(options.width);
64
+ const panel = panelWidth(width);
65
+ const margin = " ".repeat(Math.max(0, Math.floor((width - panel) / 2)));
66
+ const rows = overlayContentRows(options.rows);
67
+ const offset = resolveOffset(options.scroll, options.content.length, rows);
68
+ const visible = options.content.slice(offset, offset + rows);
69
+ return renderFramedBox({
70
+ title: `System prompt — ${options.nodePath}`,
71
+ // The panel keeps its height, so the tree never shows through under a
72
+ // short prompt and the reader's eye keeps one frame.
73
+ lines: [...visible, ...blankRows(rows - visible.length)],
74
+ footer: FOOTER,
75
+ width: panel,
76
+ }).map((line) => `${margin}${line}${padding(width - margin.length - panel)}`);
77
+ }
78
+
79
+ /** Content columns one panel row holds at terminal width `width`. */
80
+ function contentWidth(width: number): number {
81
+ return Math.max(1, panelWidth(frameWidth(width)) - CHROME_COLUMNS);
82
+ }
83
+
84
+ function panelWidth(width: number): number {
85
+ return Math.max(1, Math.min(width, Math.round(width * WIDTH_FRACTION)));
86
+ }
87
+
88
+ function frameWidth(width: number): number {
89
+ return Number.isFinite(width) ? Math.max(1, Math.floor(width)) : 1;
90
+ }
91
+
92
+ /** `count` empty content rows, or none when the content already fills them. */
93
+ function blankRows(count: number): readonly string[] {
94
+ return Array.from({ length: Math.max(0, count) }, () => "");
95
+ }
96
+
97
+ function padding(columns: number): string {
98
+ return " ".repeat(Math.max(0, columns));
99
+ }
@@ -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);
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { renderFramedBox } from "./overlay-frame.ts";
9
+ import { overlayContentRows } from "./overlay-geometry.ts";
9
10
  import { resolveOffset, type Scroll } from "./overlay-scroll.ts";
10
11
 
11
12
  const FOOTER = " ↑↓ scroll c copy path esc back";
@@ -14,7 +15,8 @@ const FOOTER = " ↑↓ scroll c copy path esc back";
14
15
  * One transcript-overlay render request.
15
16
  *
16
17
  * Contract: `content` and `scroll` decide the visible slice; `rows` is the
17
- * content row budget; `width` is the exact visible width of every returned
18
+ * TOTAL panel height, frame chrome included, and the overlay derives its
19
+ * content rows from it; `width` is the exact visible width of every returned
18
20
  * line. Failure mode: empty `content`, a `rows` of zero or less, and a scroll
19
21
  * offset past the end all draw a valid frame instead of throwing.
20
22
  */
@@ -24,14 +26,14 @@ export interface TranscriptOverlayOptions {
24
26
  /** Already-rendered content lines, from `nodeTranscriptLines`. */
25
27
  readonly content: readonly string[];
26
28
  readonly scroll: Scroll;
27
- /** Content rows the overlay may draw; a zero or negative value draws one. */
29
+ /** Panel height, chrome included; a value under four draws one content row. */
28
30
  readonly rows: number;
29
31
  readonly width: number;
30
32
  }
31
33
 
32
34
  /** Draws the framed, scrolled overlay for one node's transcript. */
33
35
  export function renderTranscriptOverlay(options: TranscriptOverlayOptions): readonly string[] {
34
- const rows = Math.max(1, Math.floor(options.rows));
36
+ const rows = overlayContentRows(options.rows);
35
37
  const offset = resolveOffset(options.scroll, options.content.length, rows);
36
38
  return renderFramedBox({
37
39
  title: `${options.nodePath} ─ transcript`,
@@ -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
+ }