@vincemakes/kiso-tui 0.1.35 → 0.1.37

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.
@@ -15,7 +15,7 @@
15
15
  * (untouched); render.ts supplies the original text (palette, escape,
16
16
  * tint, fold wording).
17
17
  */
18
- import { foldThinking, foldResult, renderToolSummary } from "./render.js";
18
+ import { foldThinking, foldResult, renderToolSummary, type ResumeMeta } from "./render.js";
19
19
  /** The spinner glyphs, cycled by the compositor's on-demand tick. */
20
20
  export declare const SPINNER: string[];
21
21
  /** The frame context the compositor passes down — the pieces of time
@@ -24,6 +24,10 @@ export declare const SPINNER: string[];
24
24
  export interface FrameCtx {
25
25
  readonly spinnerI: number;
26
26
  readonly now: number;
27
+ /** The terminal height (rows) — the banner cell's tier input (W1:
28
+ * the tier table reads H, so a resize RE-TIERS instead of
29
+ * re-folding frozen rows). */
30
+ readonly height: number;
27
31
  }
28
32
  /** ONE screen line a component emits (raw, SGR included). */
29
33
  export type RenderLine = string;
@@ -43,7 +47,19 @@ export declare function visibleWidth(line: string): number;
43
47
  export interface Component {
44
48
  render(width: number, ctx: FrameCtx): string[];
45
49
  }
46
- /** The containervertical concatenation of its children. */
50
+ /** The W11 spacing formula "a row gets one blank line above it when
51
+ * the row is itself a block, or when the previous sibling was taller
52
+ * than one row". One-row siblings pack tight; anything multi-row
53
+ * breathes on both sides. The FIRST cell never gets the blank (it sits
54
+ * at the body's top — the banner would otherwise start one row down).
55
+ * `prev` is the previous sibling's OWN rows (raw — a cell's own blank
56
+ * must never count toward its height). The blank is a JOIN artifact:
57
+ * the cell's own render stays blank-free, so per-cell accounting
58
+ * (heights, the fold cache) never sees a fake row. */
59
+ export declare function bodySpacing(prev: readonly string[] | null, rows: readonly string[]): string[];
60
+ /** The container — vertical concatenation with the W11 formula. No
61
+ * component decides its own spacing: every blank in the body is the
62
+ * container's. */
47
63
  export declare class Container implements Component {
48
64
  private readonly children;
49
65
  constructor(children: Component[]);
@@ -61,6 +77,7 @@ export type BodyCell = {
61
77
  kind: "tool";
62
78
  name: string;
63
79
  input: string;
80
+ childRoles: string[];
64
81
  state: "pending" | "approval" | "running" | "done";
65
82
  isError: boolean;
66
83
  resultText: string;
@@ -78,6 +95,12 @@ export type BodyCell = {
78
95
  kind: "notice";
79
96
  text: string;
80
97
  done: true;
98
+ } | {
99
+ kind: "banner";
100
+ version: string;
101
+ extensionsText: string;
102
+ resume: ResumeMeta[];
103
+ done: true;
81
104
  } | {
82
105
  kind: "raw";
83
106
  lines: string[];
@@ -105,7 +128,7 @@ export declare function cellComponent(cell: BodyCell): Component;
105
128
  * the hint CUT FIRST when the width is short (the #16g rule); when
106
129
  * the STATUS ITSELF cannot fit, it cuts with a "…" — the last resort,
107
130
  * enforced by invariant ① (the old code let the status soft-wrap). */
108
- export declare function statusLine(status: string, tail: string, question: boolean, W: number): string;
131
+ export declare function statusLine(status: string, tail: string, question: boolean, W: number, hint?: string): string;
109
132
  /** The footer — the ONE dotted row (the old two-row chrome is gone;
110
133
  * the wall cannot return by construction). */
111
134
  export declare function footerLine(W: number): string;
@@ -16,7 +16,7 @@
16
16
  * tint, fold wording).
17
17
  */
18
18
  import { displayWidth } from "./editor.js";
19
- import { escapeTerminal, foldThinking, foldResult, colorInlineCode, renderTerminalGap, renderToolSummary, palette, } from "./render.js";
19
+ import { bannerLines, escapeTerminal, foldThinking, foldResult, colorInlineCode, renderTerminalGap, renderToolSummary, palette, } from "./render.js";
20
20
  /** The spinner glyphs, cycled by the compositor's on-demand tick. */
21
21
  export const SPINNER = ["▖", "▘", "▝", "▗"];
22
22
  /**
@@ -106,14 +106,39 @@ export function visibleWidth(line) {
106
106
  }
107
107
  return w;
108
108
  }
109
- /** The containervertical concatenation of its children. */
109
+ /** The W11 spacing formula "a row gets one blank line above it when
110
+ * the row is itself a block, or when the previous sibling was taller
111
+ * than one row". One-row siblings pack tight; anything multi-row
112
+ * breathes on both sides. The FIRST cell never gets the blank (it sits
113
+ * at the body's top — the banner would otherwise start one row down).
114
+ * `prev` is the previous sibling's OWN rows (raw — a cell's own blank
115
+ * must never count toward its height). The blank is a JOIN artifact:
116
+ * the cell's own render stays blank-free, so per-cell accounting
117
+ * (heights, the fold cache) never sees a fake row. */
118
+ export function bodySpacing(prev, rows) {
119
+ if (rows.length === 0 || prev === null || prev.length === 0)
120
+ return rows;
121
+ if (rows.length > 1 || prev.length > 1)
122
+ return ["", ...rows];
123
+ return rows;
124
+ }
125
+ /** The container — vertical concatenation with the W11 formula. No
126
+ * component decides its own spacing: every blank in the body is the
127
+ * container's. */
110
128
  export class Container {
111
129
  children;
112
130
  constructor(children) {
113
131
  this.children = children;
114
132
  }
115
133
  render(width, ctx) {
116
- return this.children.flatMap((c) => c.render(width, ctx));
134
+ const out = [];
135
+ let prev = null;
136
+ for (const c of this.children) {
137
+ const rows = c.render(width, ctx);
138
+ out.push(...bodySpacing(prev, rows));
139
+ prev = rows;
140
+ }
141
+ return out;
117
142
  }
118
143
  }
119
144
  const TOOL_SUMMARY_MAX = 60; // the tool line's parameter summary, chars
@@ -131,6 +156,8 @@ export function cellComponent(cell) {
131
156
  return new AssistantMessage(cell);
132
157
  case "notice":
133
158
  return new ErrorLine(cell);
159
+ case "banner":
160
+ return new Banner(cell);
134
161
  case "raw":
135
162
  return new RawBlock(cell);
136
163
  case "terminal":
@@ -141,10 +168,18 @@ export function cellComponent(cell) {
141
168
  }
142
169
  /**
143
170
  * The user message — the left rail (bright-white BOLD ▍ per row, the
144
- * v4.1 design). The text folds at W−2 (the rail + space) so every row
145
- * carries the rail and NO row exceeds the width (the v5 code split on
146
- * "\n" only a long line soft-wrapped and its continuation row had no
147
- * rail).
171
+ * v4.1 design) + the W16 inset chip. The chip folds the text at W−6
172
+ * (the rail + the indent + the side pads), then pads EVERY row to the
173
+ * longest row's DISPLAY width + one space each side, indented two: the
174
+ * block is only as wide as what was said (never the full-width band —
175
+ * a short message like /think would paint a bar across the terminal).
176
+ * The padding is by cells (charWidth is the width authority), so a CJK
177
+ * row pads by width, never by chars, and the chip never overruns its
178
+ * fold. SGR 7 closed with SGR 27 — never SGR 0, the chip composes
179
+ * with a surrounding span — and NEVER dim: reverse video inverts the
180
+ * CURRENT colours, so dimmed text would invert into a dimmed block
181
+ * with no contrast. The ▍ rail stays: SGR is an emphasis on top, the
182
+ * rail is the structural fallback that survives a pipe.
148
183
  */
149
184
  class UserMessage {
150
185
  cell;
@@ -154,12 +189,15 @@ class UserMessage {
154
189
  render(W, _ctx) {
155
190
  const p = palette();
156
191
  const rail = `${p.bold}▍${p.reset} `;
157
- const textW = Math.max(1, W - 2);
192
+ const chipW = Math.max(1, W - 6);
158
193
  const rows = [];
159
194
  for (const para of this.cell.text.split("\n")) {
160
- const folded = foldLine(escapeTerminal(para), textW);
161
- for (const row of folded)
162
- rows.push(`${rail}${row}`);
195
+ const folded = foldLine(escapeTerminal(para), chipW);
196
+ const inner = Math.max(...folded.map((r) => displayWidth(r)));
197
+ for (const row of folded) {
198
+ const pad = inner - displayWidth(row);
199
+ rows.push(`${rail} ${p.rv} ${row}${" ".repeat(pad)} ${p.rvEnd}`);
200
+ }
163
201
  }
164
202
  return rows.length > 0 ? rows : [rail.trimEnd()];
165
203
  }
@@ -168,7 +206,8 @@ class UserMessage {
168
206
  * rides the fold's own row (the #17 fix's slice, componentized). The
169
207
  * slice is DISPLAY-WIDTH-based (the char-based slice overflowed with
170
208
  * CJK — 2 cells per char — and tripped invariant ① on a real
171
- * Chinese session). */
209
+ * Chinese session). W2: the leading ⋯ is the thinking gutter — the
210
+ * midline mark (the state), never the text ellipsis (the truncation). */
172
211
  class ThinkingFold {
173
212
  cell;
174
213
  constructor(cell) {
@@ -182,14 +221,102 @@ class ThinkingFold {
182
221
  // UNFOLDED and tripped invariant ① (the crash class still live on
183
222
  // npm for short /think blocks after a resize).
184
223
  if (trimmed.length <= 100)
185
- return [`${palette().dim}…${widthCut(trimmed, Math.max(1, W - 1))}${palette().reset}`];
224
+ return [`${palette().dim}⋯${widthCut(trimmed, Math.max(1, W - 1))}${palette().reset}`];
186
225
  const suffix = ` (${block.length} chars · /think)`;
187
226
  const slice = Math.max(1, W - 1 - suffix.length);
188
- return [`${palette().dim}…${widthCut(trimmed, slice)}${suffix}${palette().reset}`];
227
+ return [`${palette().dim}⋯${widthCut(trimmed, slice)}${suffix}${palette().reset}`];
189
228
  }
190
229
  }
191
- /** The tool execution line + the approval mini-diff every state is
192
- * its own render; the lines fold (the summary gives way first). */
230
+ /** Fold a line's CONTENT at W−2 and prefix EVERY row with the gutter
231
+ * (W2: a wrapped tool row keeps its state mark the left edge alone
232
+ * distinguishes the states at --plain; the UserMessage rail precedent,
233
+ * v5 #16f). The gutter carries its own SGR (e.g. the bold ✓). */
234
+ function gutterFold(gutter, line, W) {
235
+ const textW = Math.max(1, W - 2);
236
+ return foldLine(line, textW).map((r) => `${gutter}${r}`);
237
+ }
238
+ /** Lines without the phantom empty line after a trailing newline. */
239
+ function countLines(text) {
240
+ if (text === "")
241
+ return 0;
242
+ const parts = text.split("\n");
243
+ return parts[parts.length - 1] === "" ? parts.length - 1 : parts.length;
244
+ }
245
+ /** W4: the settled-row metadata — the human summary in parentheses. The
246
+ * separation NEVER relies on dim: a pipe drops the SGR, and the shapes
247
+ * below read at full strength with the palette off. read → the line
248
+ * count ("912 lines"; "200 of 3412 lines" when the tool cut it — the
249
+ * note names the remainder; ≥1000 k-formats, "2.4k lines"); write/edit
250
+ * → the ± diff stats (the approval diff's counts — an auto-allowed
251
+ * write never computed one, so the input's own counts fall back, then
252
+ * the result's line count); shell → the exit code (parsed from the
253
+ * failure text — the tool names it — 0 on success); a non-shell error
254
+ * → the error text's first line; anything else → the result's line
255
+ * count. */
256
+ function settledMeta(c) {
257
+ if (c.isError) {
258
+ // a shell EXECUTION failure names its code first ("exit 1: …") —
259
+ // that IS the metadata, and the body shows the full text. A
260
+ // shell without the code (a denial, a precondition) is not an
261
+ // exit failure: the first line stays the metadata, exactly like
262
+ // any other error.
263
+ if (c.name === "shell" && /^exit \d+/.test(c.resultText))
264
+ return `exit ${/^exit (\d+)/.exec(c.resultText)[1]}`;
265
+ return c.resultText.split("\n")[0].slice(0, 60);
266
+ }
267
+ if (c.name === "read_file") {
268
+ const noteAt = c.resultText.lastIndexOf("\n… ");
269
+ const shown = countLines(noteAt >= 0 ? c.resultText.slice(0, noteAt) : c.resultText);
270
+ const more = noteAt >= 0 ? /(\d+) more lines?/.exec(c.resultText.slice(noteAt)) : null;
271
+ const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k` : String(n));
272
+ if (more !== null) {
273
+ const total = shown + Number(more[1]);
274
+ return `${shown} of ${total} line${total === 1 ? "" : "s"}`;
275
+ }
276
+ return `${k(shown)} line${shown === 1 ? "" : "s"}`;
277
+ }
278
+ if (c.name === "write_file" || c.name === "edit_file") {
279
+ if (c.added + c.removed > 0)
280
+ return `+${c.added} -${c.removed}`;
281
+ // no approval diff (an auto-allowed write): the input summary may
282
+ // be sliced at TOOL_SUMMARY_MAX — best-effort, then the last resort
283
+ let parsed = null;
284
+ try {
285
+ parsed = JSON.parse(c.input);
286
+ }
287
+ catch {
288
+ parsed = null;
289
+ }
290
+ if (c.name === "write_file" && parsed !== null && typeof parsed.content === "string") {
291
+ return `+${countLines(parsed.content)}`;
292
+ }
293
+ if (c.name === "edit_file" && parsed !== null && typeof parsed.search === "string" && typeof parsed.replace === "string") {
294
+ const added = countLines(parsed.replace);
295
+ const removed = countLines(parsed.search);
296
+ if (added + removed > 0)
297
+ return `+${added} -${removed}`;
298
+ }
299
+ }
300
+ if (c.name === "shell")
301
+ return "exit 0";
302
+ const n = countLines(c.resultText);
303
+ return `${n} line${n === 1 ? "" : "s"}`;
304
+ }
305
+ /** The tool execution line + the bounded block — every state is its
306
+ * own render; the lines fold (the summary gives way first). W7 (the
307
+ * flow contract): the block's BODY (the rows below the header) is
308
+ * capped in SCREEN rows AFTER the fold, at the current width — the
309
+ * renderer-cut row (`└ +N … · ctrl+r`) sits INSIDE the cap (a
310
+ * truncated block is cap−1 output rows + the cut row); the TOOL-cut
311
+ * row (`└ capped by …` — the tool's OWN truncation note, W10) is a
312
+ * DIFFERENT fact, never counted in the output cap. W3: the verb is
313
+ * stripped of its "_file" suffix and padded to 5 columns — the target
314
+ * paths line up (the pipe path strips the same suffix, render.ts —
315
+ * both paths print the same verb; a verb ≥ 5 columns is not padded).
316
+ * The block's cut note keeps the RAW name (it names the tool the
317
+ * model should call again). W4: the settled row's parentheses hold
318
+ * the human metadata (settledMeta) — the input summary lived in the
319
+ * running row; the OUTCOME is what the settled row says. */
193
320
  class ToolExecution {
194
321
  cell;
195
322
  constructor(cell) {
@@ -198,35 +325,241 @@ class ToolExecution {
198
325
  render(W, ctx) {
199
326
  const p = palette();
200
327
  const c = this.cell;
201
- const name = escapeTerminal(c.name);
328
+ const verb = escapeTerminal(c.name.replace("_file", ""));
329
+ const verbCol = verb.length < 5 ? `${verb}${" ".repeat(5 - verb.length)}` : verb;
202
330
  const summary = escapeTerminal(c.input);
203
331
  if (c.state === "done") {
204
332
  const elapsed = c.startedAt !== null && c.doneAt !== null ? ((c.doneAt - c.startedAt) / 1000).toFixed(1) : "?";
205
- const line = c.isError
206
- ? `${p.red}✗ ${name} (${escapeTerminal(c.resultText.split("\n")[0].slice(0, 60))}, ${elapsed}s)${p.reset}`
207
- : `${p.bold}✓ ${name}${p.reset} (${summary}${c.added + c.removed > 0 ? `, +${c.added} -${c.removed}` : ""}, ${elapsed}s)`;
208
- return foldLine(line, W);
333
+ const meta = escapeTerminal(settledMeta(c));
334
+ const out = c.isError
335
+ ? gutterFold(`${p.red}✗${p.reset} `, `${p.red}${verbCol} (${meta}, ${elapsed}s)${p.reset}`, W)
336
+ : gutterFold(`${p.bold}✓${p.reset} `, `${verbCol} (${meta}, ${elapsed}s)`, W);
337
+ out.push(...toolBlockBody(c, W));
338
+ return out;
209
339
  }
210
340
  if (c.state === "approval") {
211
- const lines = foldLine(`→ ${name} ${summary} ${p.bold}⏸${p.reset}`, W);
212
- if (c.diff !== null) {
213
- for (const d of c.diff) {
214
- const body = d.kind === "-"
215
- ? `${p.red}- ${escapeTerminal(d.text)}${p.reset}`
216
- : d.kind === "+"
217
- ? `${p.green}+ ${escapeTerminal(d.text)}${p.reset}`
218
- : `${p.dim} ${escapeTerminal(d.text)}${p.reset}`;
219
- lines.push(...foldLine(`${p.bold}▎${p.reset}${body}`, W));
220
- }
221
- }
222
- return lines;
341
+ // W2: the ⏸ is the GUTTER (the left edge), never the line's tail
342
+ const out = gutterFold(`${p.bold}⏸${p.reset} `, `${verbCol} ${summary}`, W);
343
+ out.push(...toolBlockBody(c, W));
344
+ return out;
223
345
  }
224
346
  if (c.state === "running") {
347
+ // W2: the spinner IS the gutter (the left edge); the elapsed
348
+ // rides the summary's tail
225
349
  const elapsed = c.startedAt !== null ? Math.max(1, Math.round((ctx.now - c.startedAt) / 1000)) : 1;
226
- return foldLine(`→ ${name} ${summary} ${p.bold}${SPINNER[ctx.spinnerI % SPINNER.length]}${p.reset} ${elapsed}s`, W);
350
+ const out = gutterFold(`${p.bold}${SPINNER[ctx.spinnerI % SPINNER.length]}${p.reset} `, `${verbCol} ${summary} ${elapsed}s`, W);
351
+ out.push(...toolBlockBody(c, W));
352
+ return out;
227
353
  }
228
- return foldLine(`→ ${name} ${summary}`, W);
354
+ // W2: replaces → for QUEUED — · is the separator inside every
355
+ // metadata group; a queued marker that is also the separator
356
+ // glyph reads as noise
357
+ return gutterFold(`${p.dim}◦${p.reset} `, `${verbCol} ${summary}`, W);
358
+ }
359
+ }
360
+ // ---- the bounded-block flow contract (W7, W8, W10) ----
361
+ /** The caps — screen rows counted AFTER the fold, at the current width
362
+ * (the W7 table). The renderer-cut row is inside the cap. */
363
+ const CAP_SHELL_SETTLED = 5; // the shell output tail, settled
364
+ const CAP_LIVE_WINDOW = 3; // the running tool's FIXED window (W8)
365
+ const CAP_DIFF = 12; // the approval diff: head + the named middle + tail
366
+ const CAP_ERROR = 3; // the error text head
367
+ /** The block body rows' prefixes (W2's gutter table): │ a bounded
368
+ * block's body, └ the block's last row — what was cut, where the rest
369
+ * is — at the LEFT EDGE (the gutter column: the left edge alone
370
+ * distinguishes the states at --plain). Structural (constraint 1). */
371
+ const BODY_ROW = "│ ";
372
+ const CUT_ROW = "└ ";
373
+ const blockMemo = new WeakMap();
374
+ /** The block's body rows below the header (memoized, W9). */
375
+ function toolBlockBody(c, W) {
376
+ const memo = blockMemo.get(c);
377
+ const state = `${c.state}:${c.isError}:${c.name}`;
378
+ const content = c.state === "approval" ? (c.diff ?? null) : c.resultText;
379
+ if (memo !== undefined && memo.width === W && memo.state === state && memo.content === content)
380
+ return memo.rows;
381
+ const p = palette();
382
+ const rows = c.state === "done"
383
+ ? c.isError
384
+ ? errorBody(c, W)
385
+ : c.name === "delegate"
386
+ ? delegateSettled(c, W)
387
+ : c.name.startsWith("shell")
388
+ ? shellTail(c.resultText, W)
389
+ : []
390
+ : c.state === "running"
391
+ ? c.name === "delegate"
392
+ ? delegateRunning(c, W)
393
+ : liveWindow(c.resultText, W)
394
+ : c.state === "approval"
395
+ ? diffBody(c.diff, W)
396
+ : [];
397
+ const note = toolCutNote(c.name, c.resultText);
398
+ if (note !== null)
399
+ rows.push(...foldLine(`${p.dim}${CUT_ROW}${note}${p.reset}`, W));
400
+ blockMemo.set(c, { width: W, state, content, rows });
401
+ return rows;
402
+ }
403
+ /** Fold result text into dim body rows (the BODY_ROW prefix): escape,
404
+ * split, fold each line at W−prefix; trailing empty rows (the result's
405
+ * final newline) drop. */
406
+ function blockRows(text, W) {
407
+ const p = palette();
408
+ const textW = Math.max(1, W - visibleWidth(BODY_ROW));
409
+ const rows = [];
410
+ for (const raw of escapeTerminal(text).split("\n")) {
411
+ for (const row of foldLine(raw, textW))
412
+ rows.push(`${p.dim}${BODY_ROW}${row}${p.reset}`);
413
+ }
414
+ while (rows.length > 0 && visibleWidth(rows[rows.length - 1]) === visibleWidth(BODY_ROW))
415
+ rows.pop();
416
+ return rows;
417
+ }
418
+ /** The shell output tail, settled: the LAST rows, capped at 5 — the
419
+ * renderer cut at the block's bottom ("earlier rows" — the conclusion
420
+ * is at the end, pi's truncateToVisualLines direction). */
421
+ function shellTail(text, W) {
422
+ const p = palette();
423
+ const rows = blockRows(text, W);
424
+ if (rows.length <= CAP_SHELL_SETTLED)
425
+ return rows;
426
+ const kept = CAP_SHELL_SETTLED - 1;
427
+ const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - kept} earlier rows · ctrl+r${p.reset}`, W);
428
+ return [...rows.slice(rows.length - kept), ...cut];
429
+ }
430
+ /** The error text head: the FIRST rows, capped at 3 — the answer is at
431
+ * the start (opencode's collapseToolOutput direction). The header row
432
+ * already summarizes the first line, so the body starts at line 2. */
433
+ function errorBody(c, W) {
434
+ const p = palette();
435
+ // W4: a shell EXECUTION failure's line 0 ("exit 1: …") no longer
436
+ // rides the header — the parsed code does — so the body keeps the
437
+ // FULL text. Any other error keeps the pre-W4 split: line 0 is the
438
+ // header's metadata, the body shows the rest.
439
+ const skipFirst = c.name === "shell" && /^exit \d+/.test(c.resultText) ? 0 : 1;
440
+ const rows = blockRows(c.resultText.split("\n").slice(skipFirst).join("\n"), W);
441
+ if (rows.length <= CAP_ERROR)
442
+ return rows;
443
+ const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - (CAP_ERROR - 1)} more · ctrl+r${p.reset}`, W);
444
+ return [...rows.slice(0, CAP_ERROR - 1), ...cut];
445
+ }
446
+ /** The running tool's FIXED-height window (W8): exactly 3 rows from
447
+ * the FIRST frame — blank-padded before output arrives, the renderer
448
+ * cut inside the window. The height changes exactly once, at settle —
449
+ * a cell that grows mid-list would shift every row after it on every
450
+ * delta (the parallel-tools jitter). */
451
+ function liveWindow(text, W) {
452
+ const p = palette();
453
+ if (text === "") {
454
+ return [`${p.dim}${BODY_ROW}${p.reset}`, `${p.dim}${BODY_ROW}${p.reset}`, `${p.dim}${CUT_ROW}waiting for output${p.reset}`];
455
+ }
456
+ const rows = blockRows(text, W);
457
+ if (rows.length <= CAP_LIVE_WINDOW) {
458
+ while (rows.length < CAP_LIVE_WINDOW)
459
+ rows.push(`${p.dim}${BODY_ROW}${p.reset}`);
460
+ return rows;
229
461
  }
462
+ const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - (CAP_LIVE_WINDOW - 1)} earlier rows · ctrl+r${p.reset}`, W);
463
+ return [...rows.slice(rows.length - (CAP_LIVE_WINDOW - 1)), ...cut];
464
+ }
465
+ /** W12: the delegate's child sessions collapse to the tool row plus ONE
466
+ * line — the height NEVER changes (running → settled replaces the row
467
+ * in place). The running row derives from the INPUT: the parent has no
468
+ * live channel to a running child (ToolContext carries only
469
+ * signal/sessionId; execute returns ONE result), so the roles are the
470
+ * honest current data — the spec's "<child's current tool>" has no
471
+ * event source. The settled row parses the extension's summary marker
472
+ * (the blob's first line) — its absence falls back to no body (an old
473
+ * extension's output still renders). The one-line shape is shared with
474
+ * W18's status row (the work order: "implement them with one helper"). */
475
+ function delegateRunning(c, W) {
476
+ const p = palette();
477
+ const n = c.childRoles.length;
478
+ const text = n === 0 ? "children running…" : `${n === 1 ? "1 child" : `${n} children`} · ${c.childRoles.join(" · ")}`;
479
+ return [oneLineRow(p, text, W)];
480
+ }
481
+ function delegateSettled(c, W) {
482
+ const p = palette();
483
+ const m = /^summary: (.+)$/m.exec(c.resultText);
484
+ if (m === null)
485
+ return [];
486
+ return [oneLineRow(p, `${m[1]} · /last for the report`, W)];
487
+ }
488
+ /** ONE row at the left gutter, truncated to fit the width — never a
489
+ * fold (a fold would wrap into TWO rows and break the one-line height
490
+ * contract). */
491
+ function oneLineRow(p, text, W) {
492
+ const esc = escapeTerminal(text);
493
+ if (visibleWidth(`${p.dim}${CUT_ROW}${esc}${p.reset}`) <= W)
494
+ return `${p.dim}${CUT_ROW}${esc}${p.reset}`;
495
+ const w = Math.max(1, W - visibleWidth(`${p.dim}${CUT_ROW}${p.reset}`));
496
+ return `${p.dim}${CUT_ROW}${esc.slice(0, w - 1)}…${p.reset}`;
497
+ }
498
+ /** The approval mini-diff (W7): capped at 12 folded rows — the head +
499
+ * the named middle (the renderer cut — what was cut, how to expand) +
500
+ * the tail. The rows are folded at the current width BEFORE the cap —
501
+ * the R1 measured bug: truncateDiff capped at 40 ENTRIES while the
502
+ * fold turned them into 73 SCREEN rows at W≤80 (a 44-row terminal's
503
+ * content cap is H−4 = 40 — the approval force-committed a third of
504
+ * the screen into scrollback inside one frame).
505
+ * W17: the cap is a ROW budget at every width — the └ cut is ONE line
506
+ * (a folded cut pushed the total past 12 at narrow widths), and below
507
+ * a floor of 3 SOURCE lines visible the head/tail pair is noise (each
508
+ * fragment a sliver of a long line): drop to the head only — the head
509
+ * takes the whole budget — and the └ row carries the rest. */
510
+ function diffBody(diff, W) {
511
+ const p = palette();
512
+ if (diff === null)
513
+ return [];
514
+ const rows = [];
515
+ // W17: each line's fold START row (the running total) — the pair
516
+ // floor reads it for the head/tail SOURCE-line counts below.
517
+ const starts = [0];
518
+ for (const d of diff) {
519
+ const body = d.kind === "-"
520
+ ? `${p.red}- ${escapeTerminal(d.text)}${p.reset}`
521
+ : d.kind === "+"
522
+ ? `${p.green}+ ${escapeTerminal(d.text)}${p.reset}`
523
+ : `${p.dim} ${escapeTerminal(d.text)}${p.reset}`;
524
+ // W2: the diff body is a bounded block's body — the │ gutter
525
+ // (dim), never the old bold ▎ rail (the table lists no ▎); the
526
+ // +/- marks and their colors ride the content
527
+ rows.push(...gutterFold(`${p.dim}│${p.reset} `, body, W));
528
+ starts.push(rows.length);
529
+ }
530
+ if (rows.length <= CAP_DIFF)
531
+ return rows;
532
+ const head = Math.floor((CAP_DIFF - 1) / 2);
533
+ const tail = CAP_DIFF - 1 - head;
534
+ // W17: the └ cut is ONE row at every width — the count leads, the
535
+ // expand affordances are cuttable (the same one-line shape as W12's
536
+ // delegate row and W18's status row).
537
+ const cut = (n) => oneLineRow(p, `+${n} rows · ctrl+r to expand · /last for the full diff`, W);
538
+ // W17: the floor — the head window shows the lines whose fold starts
539
+ // before `head` rows; the tail window the lines whose fold ENDS after
540
+ // `rows.length - tail` (starts[i+1] is line i's end). When the pair
541
+ // shows fewer than 3 SOURCE lines together, it is noise at this width
542
+ // (each fragment a sliver of a long line): drop to the head only —
543
+ // the head takes the whole budget, the └ row carries the rest.
544
+ if (starts.filter((s) => s < head).length + starts.slice(1).filter((s) => s > rows.length - tail).length < 3)
545
+ return [...rows.slice(0, CAP_DIFF - 1), cut(rows.length - (CAP_DIFF - 1))];
546
+ return [...rows.slice(0, head), cut(rows.length - head - tail), ...rows.slice(rows.length - tail)];
547
+ }
548
+ /** The TOOL's OWN truncation note (W10) — a different fact from the
549
+ * renderer's cut: the tools truncate and append a continuation note
550
+ * (packages/tools-node/src/index.ts — read_file's "call again with
551
+ * offset=N", the output cap, list_dir's entry cap). The note reaches
552
+ * the MODEL and never the human — this row surfaces it. Detected in
553
+ * the result's TAIL (the note is appended at the end); returns null
554
+ * when the tool did not truncate. */
555
+ function toolCutNote(name, resultText) {
556
+ const tail = resultText.slice(-300);
557
+ const m = /offset=(\d+)/.exec(tail);
558
+ if (m !== null)
559
+ return `capped by ${escapeTerminal(name)} · offset=${m[1]} for the rest`;
560
+ if (/…\[truncated\]/.test(tail) || /… \+?\d+ more (?:lines|entries)/.test(tail))
561
+ return `capped by ${escapeTerminal(name)} · /last for the rest`;
562
+ return null;
230
563
  }
231
564
  /** The assistant body text — wrapped at W, the inline-code tint per
232
565
  * row (the #16e rule: a span never matches across rows). */
@@ -264,17 +597,32 @@ class RawBlock {
264
597
  return this.cell.lines.flatMap((l) => foldLine(l, W));
265
598
  }
266
599
  }
267
- /** The terminal label + the status line + the rhythm gap blank. */
600
+ /** The terminal label + the status line. W11: the rhythm gap blank is
601
+ * gone — the container's formula breathes below a multi-row cell (the
602
+ * terminal is always multi-row when labelled), never the component. */
268
603
  class TerminalBlock {
269
604
  cell;
270
605
  constructor(cell) {
271
606
  this.cell = cell;
272
607
  }
273
608
  render(W, _ctx) {
274
- const lines = [...foldLine(this.cell.label, W), ...foldLine(this.cell.line, W)];
275
- if (this.cell.label !== "")
276
- lines.push("");
277
- return lines;
609
+ return [...foldLine(this.cell.label, W), ...foldLine(this.cell.line, W)];
610
+ }
611
+ }
612
+ /** The startup banner — a LIVE cell: every render re-derives the tier
613
+ * from the CURRENT width AND height (bannerLines), so a resize re-tiers
614
+ * the art instead of re-folding frozen rows (the W1 tier table: below
615
+ * 40 cols the logo never paints). W11: no trailing blank — the
616
+ * container's formula breathes below the (always multi-row) banner. */
617
+ class Banner {
618
+ cell;
619
+ constructor(cell) {
620
+ this.cell = cell;
621
+ }
622
+ render(W, ctx) {
623
+ const p = palette();
624
+ const rows = bannerLines(W, ctx.height, this.cell.version, this.cell.extensionsText, this.cell.resume, ctx.now);
625
+ return rows.map((r) => `${p.dim}${r}${p.reset}`);
278
626
  }
279
627
  }
280
628
  /** The durable checklist — the ▞ header + one brick-glyph row per item. */
@@ -299,20 +647,24 @@ class Checklist {
299
647
  * the hint CUT FIRST when the width is short (the #16g rule); when
300
648
  * the STATUS ITSELF cannot fit, it cuts with a "…" — the last resort,
301
649
  * enforced by invariant ① (the old code let the status soft-wrap). */
302
- export function statusLine(status, tail, question, W) {
650
+ export function statusLine(status, tail, question, W, hint) {
303
651
  const p = palette();
304
652
  const text = `${status}${tail === "" ? "" : ` · ${tail}`}`;
305
653
  if (question)
306
654
  return `${p.dim}${widthCut(text, W)}${p.reset}`;
307
- const hint = " / commands · history";
655
+ // W18: the hint is a parameter the compacting row right-aligns its
656
+ // "esc to cancel" (the same one-line-bounded shape as W12's delegate
657
+ // row; the #16g rule still cuts the HINT first, then the status with
658
+ // a "…" — never a fold).
659
+ const hintText = hint ?? " / commands · ↑ history";
308
660
  const statusW = visibleWidth(text);
309
661
  if (statusW > W) {
310
662
  return `${p.dim}${widthCut(text, W - 1)}…${p.reset}`;
311
663
  }
312
- const hintW = visibleWidth(hint);
664
+ const hintW = visibleWidth(hintText);
313
665
  if (statusW + hintW > W)
314
666
  return `${p.dim}${text}${p.reset}`;
315
- return `${p.dim}${text}${" ".repeat(Math.max(0, W - statusW - hintW))}${hint}${p.reset}`;
667
+ return `${p.dim}${text}${" ".repeat(Math.max(0, W - statusW - hintW))}${hintText}${p.reset}`;
316
668
  }
317
669
  /** The display-width prefix of a plain (SGR-free) text. */
318
670
  function widthCut(text, max) {