@vincemakes/kiso-tui 0.1.36 → 0.1.38

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.
@@ -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, toolTarget, kUnit, 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,12 +221,87 @@ 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
  }
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
+ }
191
305
  /** The tool execution line + the bounded block — every state is its
192
306
  * own render; the lines fold (the summary gives way first). W7 (the
193
307
  * flow contract): the block's BODY (the rows below the header) is
@@ -195,7 +309,14 @@ class ThinkingFold {
195
309
  * renderer-cut row (`└ +N … · ctrl+r`) sits INSIDE the cap (a
196
310
  * truncated block is cap−1 output rows + the cut row); the TOOL-cut
197
311
  * row (`└ capped by …` — the tool's OWN truncation note, W10) is a
198
- * DIFFERENT fact, never counted in the output cap. */
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. */
199
320
  class ToolExecution {
200
321
  cell;
201
322
  constructor(cell) {
@@ -204,31 +325,115 @@ class ToolExecution {
204
325
  render(W, ctx) {
205
326
  const p = palette();
206
327
  const c = this.cell;
207
- 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;
208
330
  const summary = escapeTerminal(c.input);
331
+ if (c.rolled !== null) {
332
+ // W13 — the rolled-up group's ONE row + the target children:
333
+ // the work order's claimed shape, verbatim — the verbCol's
334
+ // 5-char pad reproduces the "read 5 files" double space, the
335
+ // children are the first 3 basename targets, the overflow row
336
+ // carries the ctrl+r affordance (its "└ … ctrl+r" joins the
337
+ // W15 expand history — the head's commit captures it).
338
+ const r = c.rolled;
339
+ const noun = ROLLUP_NOUN[c.name] ?? "calls";
340
+ const out = gutterFold(`${p.bold}✓${p.reset} `, `${verbCol} ${r.count} ${noun} (${kUnit(r.lines)} lines, ${r.elapsed}s)`, W);
341
+ const shown = r.targets.slice(0, 3);
342
+ if (shown.length > 0)
343
+ out.push(` ${p.dim}${CUT_ROW}${escapeTerminal(shown.join(" · "))}${p.reset}`);
344
+ if (r.targets.length > 3)
345
+ out.push(` ${p.dim}${CUT_ROW}+${r.targets.length - 3} more — ctrl+r expands${p.reset}`);
346
+ return out;
347
+ }
209
348
  if (c.state === "done") {
349
+ // W19: the pinned deny — the claimed shape verbatim: the FULL
350
+ // call name (the denial names the call), the target, the reason
351
+ // in the W4 parentheses idiom, no timing (the call never ran).
352
+ // The same ✗ family as any failure; the [result ✗] body still
353
+ // rides below (never hide information).
354
+ if (c.reason !== null) {
355
+ let input = {};
356
+ try {
357
+ input = JSON.parse(c.inputFull);
358
+ }
359
+ catch {
360
+ // the full JSON is always parseable (stringified at
361
+ // toolStart) — the empty fallback never fires
362
+ }
363
+ const target = toolTarget(c.name, input);
364
+ const out = gutterFold(`${p.red}✗${p.reset} `, `${p.red}${escapeTerminal(`${c.name} ${target}`)} (${escapeTerminal(c.reason)})${p.reset}`, W);
365
+ out.push(...toolBlockBody(c, W));
366
+ return out;
367
+ }
210
368
  const elapsed = c.startedAt !== null && c.doneAt !== null ? ((c.doneAt - c.startedAt) / 1000).toFixed(1) : "?";
211
- const line = c.isError
212
- ? `${p.red}✗ ${name} (${escapeTerminal(c.resultText.split("\n")[0].slice(0, 60))}, ${elapsed}s)${p.reset}`
213
- : `${p.bold}✓ ${name}${p.reset} (${summary}${c.added + c.removed > 0 ? `, +${c.added} -${c.removed}` : ""}, ${elapsed}s)`;
214
- const out = foldLine(line, W);
369
+ const meta = escapeTerminal(settledMeta(c));
370
+ const out = c.isError
371
+ ? gutterFold(`${p.red}✗${p.reset} `, `${p.red}${verbCol} (${meta}, ${elapsed}s)${p.reset}`, W)
372
+ : gutterFold(`${p.bold}✓${p.reset} `, `${verbCol} (${meta}, ${elapsed}s)`, W);
215
373
  out.push(...toolBlockBody(c, W));
216
374
  return out;
217
375
  }
218
376
  if (c.state === "approval") {
219
- const out = foldLine(`→ ${name} ${summary} ${p.bold}⏸${p.reset}`, W);
377
+ // W2: the ⏸ is the GUTTER (the left edge), never the line's tail
378
+ const out = gutterFold(`${p.bold}⏸${p.reset} `, `${verbCol} ${summary}`, W);
220
379
  out.push(...toolBlockBody(c, W));
221
380
  return out;
222
381
  }
223
382
  if (c.state === "running") {
383
+ // W2: the spinner IS the gutter (the left edge); the elapsed
384
+ // rides the summary's tail
224
385
  const elapsed = c.startedAt !== null ? Math.max(1, Math.round((ctx.now - c.startedAt) / 1000)) : 1;
225
- const out = foldLine(`→ ${name} ${summary} ${p.bold}${SPINNER[ctx.spinnerI % SPINNER.length]}${p.reset} ${elapsed}s`, W);
386
+ const out = gutterFold(`${p.bold}${SPINNER[ctx.spinnerI % SPINNER.length]}${p.reset} `, `${verbCol} ${summary} ${elapsed}s`, W);
226
387
  out.push(...toolBlockBody(c, W));
227
388
  return out;
228
389
  }
229
- return foldLine(`→ ${name} ${summary}`, W);
390
+ // W2: replaces → for QUEUED — · is the separator inside every
391
+ // metadata group; a queued marker that is also the separator
392
+ // glyph reads as noise
393
+ return gutterFold(`${p.dim}◦${p.reset} `, `${verbCol} ${summary}`, W);
230
394
  }
231
395
  }
396
+ /** W13 — the rollup opt-in table: which tools collapse, and the count
397
+ * NOUN (read_file calls → "5 files", list_dir → "5 dirs", search_text
398
+ * → "5 matches"). Only these tools opt in — a shell burst is never
399
+ * rolled up (its rows carry meaning). The folded-turn line (W14) reuses
400
+ * the plurals for its other-tool terms ("2 dirs", "1 match"). */
401
+ export const ROLLUP_NOUN = {
402
+ read_file: "files",
403
+ list_dir: "dirs",
404
+ search_text: "matches",
405
+ };
406
+ /** The count term with the singular/plural forms — "no reads", "1 read",
407
+ * "5 reads". The noun's singular drops the plural suffix ("dirs" → "dir",
408
+ * "matches" → "match"). */
409
+ function countTerm(n, singular, plural) {
410
+ if (n === 0)
411
+ return `no ${plural}`;
412
+ if (n === 1)
413
+ return `1 ${singular}`;
414
+ return `${n} ${plural}`;
415
+ }
416
+ /** W14 — the folded-turn line: a whole QUIET turn (no text), once it is
417
+ * scrollback, becomes ONE line — the work order's claimed shape
418
+ * (`▞ thought 19s · 5 reads · no edits`), the counts accumulated at
419
+ * toolStart: read_file → "reads", edit_file → "edits", the other tools
420
+ * as first-call-order terms (the ROLLUP_NOUN plurals when the tool opts
421
+ * in, the verb + "s" otherwise). */
422
+ export function turnFold(t) {
423
+ const p = palette();
424
+ const parts = [`thought ${t.thoughtSeconds}s`, countTerm(t.reads, "read", "reads"), countTerm(t.edits, "edit", "edits")];
425
+ for (const [name, n] of t.others) {
426
+ const noun = ROLLUP_NOUN[name];
427
+ if (noun !== undefined) {
428
+ parts.push(countTerm(n, noun.endsWith("es") ? noun.slice(0, -2) : noun.slice(0, -1), noun));
429
+ }
430
+ else {
431
+ const verb = name.replace("_file", "");
432
+ parts.push(countTerm(n, verb, `${verb}s`));
433
+ }
434
+ }
435
+ return [`${p.bold}▞${p.reset} ${parts.join(" · ")}`];
436
+ }
232
437
  // ---- the bounded-block flow contract (W7, W8, W10) ----
233
438
  /** The caps — screen rows counted AFTER the fold, at the current width
234
439
  * (the W7 table). The renderer-cut row is inside the cap. */
@@ -238,30 +443,47 @@ const CAP_DIFF = 12; // the approval diff: head + the named middle + tail
238
443
  const CAP_ERROR = 3; // the error text head
239
444
  /** The block body rows' prefixes (W2's gutter table): │ a bounded
240
445
  * block's body, └ the block's last row — what was cut, where the rest
241
- * is. Structural (constraint 1) they survive a pipe. */
242
- const BODY_ROW = " │ ";
243
- const CUT_ROW = " ";
446
+ * is at the LEFT EDGE (the gutter column: the left edge alone
447
+ * distinguishes the states at --plain). Structural (constraint 1). */
448
+ const BODY_ROW = " ";
449
+ const CUT_ROW = "└ ";
244
450
  const blockMemo = new WeakMap();
245
451
  /** The block's body rows below the header (memoized, W9). */
246
452
  function toolBlockBody(c, W) {
247
453
  const memo = blockMemo.get(c);
248
- const state = `${c.state}:${c.isError}:${c.name}`;
454
+ const state = `${c.state}:${c.isError}:${c.name}:${c.expanded ? "x" : ""}`;
249
455
  const content = c.state === "approval" ? (c.diff ?? null) : c.resultText;
250
456
  if (memo !== undefined && memo.width === W && memo.state === state && memo.content === content)
251
457
  return memo.rows;
252
458
  const p = palette();
253
- const rows = c.state === "done"
254
- ? c.isError
255
- ? errorBody(c.resultText, W)
256
- : c.name.startsWith("shell")
257
- ? shellTail(c.resultText, W)
258
- : []
259
- : c.state === "running"
260
- ? liveWindow(c.resultText, W)
261
- : c.state === "approval"
262
- ? diffBody(c.diff, W)
263
- : [];
264
- const note = toolCutNote(c.name, c.resultText);
459
+ const rows = c.expanded
460
+ ? // W15: the toggle's full form — the WHOLE body, no cap, no
461
+ // cut note (nothing is cut; the width fold still holds — the
462
+ // height may change while live, the user asked for it). The
463
+ // delegate has no body — its rows are unchanged.
464
+ c.state === "approval"
465
+ ? diffBody(c.diff, W, true)
466
+ : c.name === "delegate"
467
+ ? c.state === "running"
468
+ ? delegateRunning(c, W)
469
+ : delegateSettled(c, W)
470
+ : blockRows(c.resultText, W)
471
+ : c.state === "done"
472
+ ? c.isError
473
+ ? errorBody(c, W)
474
+ : c.name === "delegate"
475
+ ? delegateSettled(c, W)
476
+ : c.name.startsWith("shell")
477
+ ? shellTail(c.resultText, W)
478
+ : []
479
+ : c.state === "running"
480
+ ? c.name === "delegate"
481
+ ? delegateRunning(c, W)
482
+ : liveWindow(c.resultText, W)
483
+ : c.state === "approval"
484
+ ? diffBody(c.diff, W)
485
+ : [];
486
+ const note = c.expanded ? null : toolCutNote(c.name, c.resultText);
265
487
  if (note !== null)
266
488
  rows.push(...foldLine(`${p.dim}${CUT_ROW}${note}${p.reset}`, W));
267
489
  blockMemo.set(c, { width: W, state, content, rows });
@@ -297,9 +519,18 @@ function shellTail(text, W) {
297
519
  /** The error text head: the FIRST rows, capped at 3 — the answer is at
298
520
  * the start (opencode's collapseToolOutput direction). The header row
299
521
  * already summarizes the first line, so the body starts at line 2. */
300
- function errorBody(text, W) {
522
+ function errorBody(c, W) {
301
523
  const p = palette();
302
- const rows = blockRows(text.split("\n").slice(1).join("\n"), W);
524
+ // W4: a shell EXECUTION failure's line 0 ("exit 1: …") no longer
525
+ // rides the header — the parsed code does — so the body keeps the
526
+ // FULL text. Any other error keeps the pre-W4 split: line 0 is the
527
+ // header's metadata, the body shows the rest.
528
+ // W19: a DENIED call's header meta is the PARSED reason (from the
529
+ // denied tag), decoupled from the result text — the body keeps the
530
+ // FULL content including the "[Permission denied] " prefix (never
531
+ // hide information — the folded body rides the pinned row).
532
+ const skipFirst = c.name === "shell" && /^exit \d+/.test(c.resultText) ? 0 : c.reason !== null && c.reason !== undefined ? 0 : 1;
533
+ const rows = blockRows(c.resultText.split("\n").slice(skipFirst).join("\n"), W);
303
534
  if (rows.length <= CAP_ERROR)
304
535
  return rows;
305
536
  const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - (CAP_ERROR - 1)} more · ctrl+r${p.reset}`, W);
@@ -324,32 +555,88 @@ function liveWindow(text, W) {
324
555
  const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - (CAP_LIVE_WINDOW - 1)} earlier rows · ctrl+r${p.reset}`, W);
325
556
  return [...rows.slice(rows.length - (CAP_LIVE_WINDOW - 1)), ...cut];
326
557
  }
558
+ /** W12: the delegate's child sessions collapse to the tool row plus ONE
559
+ * line — the height NEVER changes (running → settled replaces the row
560
+ * in place). The running row derives from the INPUT: the parent has no
561
+ * live channel to a running child (ToolContext carries only
562
+ * signal/sessionId; execute returns ONE result), so the roles are the
563
+ * honest current data — the spec's "<child's current tool>" has no
564
+ * event source. The settled row parses the extension's summary marker
565
+ * (the blob's first line) — its absence falls back to no body (an old
566
+ * extension's output still renders). The one-line shape is shared with
567
+ * W18's status row (the work order: "implement them with one helper"). */
568
+ function delegateRunning(c, W) {
569
+ const p = palette();
570
+ const n = c.childRoles.length;
571
+ const text = n === 0 ? "children running…" : `${n === 1 ? "1 child" : `${n} children`} · ${c.childRoles.join(" · ")}`;
572
+ return [oneLineRow(p, text, W)];
573
+ }
574
+ function delegateSettled(c, W) {
575
+ const p = palette();
576
+ const m = /^summary: (.+)$/m.exec(c.resultText);
577
+ if (m === null)
578
+ return [];
579
+ return [oneLineRow(p, `${m[1]} · /last for the report`, W)];
580
+ }
581
+ /** ONE row at the left gutter, truncated to fit the width — never a
582
+ * fold (a fold would wrap into TWO rows and break the one-line height
583
+ * contract). */
584
+ function oneLineRow(p, text, W) {
585
+ const esc = escapeTerminal(text);
586
+ if (visibleWidth(`${p.dim}${CUT_ROW}${esc}${p.reset}`) <= W)
587
+ return `${p.dim}${CUT_ROW}${esc}${p.reset}`;
588
+ const w = Math.max(1, W - visibleWidth(`${p.dim}${CUT_ROW}${p.reset}`));
589
+ return `${p.dim}${CUT_ROW}${esc.slice(0, w - 1)}…${p.reset}`;
590
+ }
327
591
  /** The approval mini-diff (W7): capped at 12 folded rows — the head +
328
592
  * the named middle (the renderer cut — what was cut, how to expand) +
329
593
  * the tail. The rows are folded at the current width BEFORE the cap —
330
594
  * the R1 measured bug: truncateDiff capped at 40 ENTRIES while the
331
595
  * fold turned them into 73 SCREEN rows at W≤80 (a 44-row terminal's
332
596
  * content cap is H−4 = 40 — the approval force-committed a third of
333
- * the screen into scrollback inside one frame). */
334
- function diffBody(diff, W) {
597
+ * the screen into scrollback inside one frame).
598
+ * W17: the cap is a ROW budget at every width — the └ cut is ONE line
599
+ * (a folded cut pushed the total past 12 at narrow widths), and below
600
+ * a floor of 3 SOURCE lines visible the head/tail pair is noise (each
601
+ * fragment a sliver of a long line): drop to the head only — the head
602
+ * takes the whole budget — and the └ row carries the rest. */
603
+ function diffBody(diff, W, expanded = false) {
335
604
  const p = palette();
336
605
  if (diff === null)
337
606
  return [];
338
607
  const rows = [];
608
+ // W17: each line's fold START row (the running total) — the pair
609
+ // floor reads it for the head/tail SOURCE-line counts below.
610
+ const starts = [0];
339
611
  for (const d of diff) {
340
612
  const body = d.kind === "-"
341
613
  ? `${p.red}- ${escapeTerminal(d.text)}${p.reset}`
342
614
  : d.kind === "+"
343
615
  ? `${p.green}+ ${escapeTerminal(d.text)}${p.reset}`
344
616
  : `${p.dim} ${escapeTerminal(d.text)}${p.reset}`;
345
- rows.push(...foldLine(`${p.bold}▎${p.reset}${body}`, W));
617
+ // W2: the diff body is a bounded block's body — the │ gutter
618
+ // (dim), never the old bold ▎ rail (the table lists no ▎); the
619
+ // +/- marks and their colors ride the content
620
+ rows.push(...gutterFold(`${p.dim}│${p.reset} `, body, W));
621
+ starts.push(rows.length);
346
622
  }
347
- if (rows.length <= CAP_DIFF)
623
+ if (expanded || rows.length <= CAP_DIFF)
348
624
  return rows;
349
625
  const head = Math.floor((CAP_DIFF - 1) / 2);
350
626
  const tail = CAP_DIFF - 1 - head;
351
- const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - head - tail} rows · ctrl+r to expand · /last for the full diff${p.reset}`, W);
352
- return [...rows.slice(0, head), ...cut, ...rows.slice(rows.length - tail)];
627
+ // W17: the cut is ONE row at every width the count leads, the
628
+ // expand affordances are cuttable (the same one-line shape as W12's
629
+ // delegate row and W18's status row).
630
+ const cut = (n) => oneLineRow(p, `+${n} rows · ctrl+r to expand · /last for the full diff`, W);
631
+ // W17: the floor — the head window shows the lines whose fold starts
632
+ // before `head` rows; the tail window the lines whose fold ENDS after
633
+ // `rows.length - tail` (starts[i+1] is line i's end). When the pair
634
+ // shows fewer than 3 SOURCE lines together, it is noise at this width
635
+ // (each fragment a sliver of a long line): drop to the head only —
636
+ // the head takes the whole budget, the └ row carries the rest.
637
+ if (starts.filter((s) => s < head).length + starts.slice(1).filter((s) => s > rows.length - tail).length < 3)
638
+ return [...rows.slice(0, CAP_DIFF - 1), cut(rows.length - (CAP_DIFF - 1))];
639
+ return [...rows.slice(0, head), cut(rows.length - head - tail), ...rows.slice(rows.length - tail)];
353
640
  }
354
641
  /** The TOOL's OWN truncation note (W10) — a different fact from the
355
642
  * renderer's cut: the tools truncate and append a continuation note
@@ -403,20 +690,92 @@ class RawBlock {
403
690
  return this.cell.lines.flatMap((l) => foldLine(l, W));
404
691
  }
405
692
  }
406
- /** The terminal label + the status line + the rhythm gap blank. */
693
+ /** The terminal label + the status line. W11: the rhythm gap blank is
694
+ * gone — the container's formula breathes below a multi-row cell (the
695
+ * terminal is always multi-row when labelled), never the component. */
407
696
  class TerminalBlock {
408
697
  cell;
409
698
  constructor(cell) {
410
699
  this.cell = cell;
411
700
  }
412
701
  render(W, _ctx) {
413
- const lines = [...foldLine(this.cell.label, W), ...foldLine(this.cell.line, W)];
414
- if (this.cell.label !== "")
415
- lines.push("");
416
- return lines;
702
+ return [...foldLine(this.cell.label, W), ...foldLine(this.cell.line, W)];
703
+ }
704
+ }
705
+ /** The startup banner — a LIVE cell: every render re-derives the tier
706
+ * from the CURRENT width AND height (bannerLines), so a resize re-tiers
707
+ * the art instead of re-folding frozen rows (the W1 tier table: below
708
+ * 40 cols the logo never paints). W11: no trailing blank — the
709
+ * container's formula breathes below the (always multi-row) banner. */
710
+ class Banner {
711
+ cell;
712
+ constructor(cell) {
713
+ this.cell = cell;
714
+ }
715
+ render(W, ctx) {
716
+ const p = palette();
717
+ const rows = bannerLines(W, ctx.height, this.cell.version, this.cell.extensionsText, this.cell.resume, ctx.now);
718
+ return rows.map((r) => `${p.dim}${r}${p.reset}`);
417
719
  }
418
720
  }
419
- /** The durable checklist — the header + one brick-glyph row per item. */
721
+ /** W20 — the todo block's fixed-window height: the whole live block
722
+ * (header + rows) in POST-FOLD screen rows at EVERY width: the header,
723
+ * the active row, up to 2 pending, the overflow-pending fold, the
724
+ * done-collapse. Every live row CUTS at W (never folds) — the block's
725
+ * height is its row count. */
726
+ export const CAP_TODO_LIVE = 6;
727
+ /** W20 — the live block's fixed-window row cut: an SGR-aware ONE-ROW
728
+ * truncation (foldLine wraps; a wrapped row would break the height
729
+ * cap — every live row is exactly one screen row at every width).
730
+ * A line that fits (≤ W) passes through whole; an overflow cuts the
731
+ * content at W−1 — the ellipsis's slot — and the ellipsis rides AFTER
732
+ * the reset (post-reset — the PTY needles' convention). The cut row
733
+ * never exceeds W (invariant ①). */
734
+ function cutLine(line, W) {
735
+ if (visibleWidth(line) <= W)
736
+ return line;
737
+ let out = "";
738
+ let width = 0;
739
+ for (let i = 0; i < line.length;) {
740
+ if (line[i] === "\x1b") {
741
+ const m = /^\x1b\[[0-9;]*m/.exec(line.slice(i)) ?? line[i];
742
+ out += m;
743
+ i += m.length;
744
+ continue;
745
+ }
746
+ const cw = displayWidth(line[i]);
747
+ if (width + cw > W - 1)
748
+ break; // reserve the ellipsis's column
749
+ out += line[i];
750
+ width += cw;
751
+ i += 1;
752
+ }
753
+ return `${out}\x1b[0m…`;
754
+ }
755
+ /** W20 — the settled block's duration, the `2h 14m` form (the todo
756
+ * narrative's long-horizon idiom): minutes+seconds under an hour,
757
+ * hours+minutes past it. */
758
+ export function formatDuration(totalSeconds) {
759
+ const s = Math.max(0, Math.round(totalSeconds));
760
+ if (s < 60)
761
+ return `${s}s`;
762
+ const m = Math.floor(s / 60);
763
+ return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`;
764
+ }
765
+ /**
766
+ * W20 — the todo checklist as STATE: ONE live block that redraws in
767
+ * place (the current turn's in-place updates), settling at the turn's
768
+ * end as ONE recap block. LIVE (done:false): the fixed "todo" prefix +
769
+ * the compositor-derived counts (the model tail rides AFTER — never
770
+ * model-controlled), the active item first with ▸ (the menu's "the
771
+ * current one"), pending next (≤2), the done items COLLAPSED behind the
772
+ * W10 cut family `└ +N done · ctrl+r`, overflow pending behind
773
+ * `└ +N more · ctrl+r` — every row cut at W so the cap holds at every
774
+ * width. ctrl+r (W15) toggles the full list in place (expanded). SETTLED
775
+ * (done:true): the recap idiom `todo done · N items · <duration>` + the
776
+ * FULL final item list in the checklist's existing shape (▖/□/▣ —
777
+ * indented two, the glyph leads, no │ gutter).
778
+ */
420
779
  class Checklist {
421
780
  cell;
422
781
  constructor(cell) {
@@ -424,12 +783,47 @@ class Checklist {
424
783
  }
425
784
  render(W, _ctx) {
426
785
  const p = palette();
427
- const glyphOf = (status) => (status === "pending" ? "□" : status === "active" ? "▖" : "▣");
428
- const rows = foldLine(`${p.bold}▞${p.reset} ${escapeTerminal(this.cell.header)}`, W);
429
- for (const item of this.cell.items) {
430
- rows.push(...foldLine(` ${glyphOf(item.status)} ${escapeTerminal(item.text)}`, W));
786
+ const { items, done, expanded, durationSeconds } = this.cell;
787
+ const active = items.filter((i) => i.status === "active");
788
+ const pending = items.filter((i) => i.status === "pending");
789
+ const doneCount = items.length - active.length - pending.length;
790
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
791
+ const tail = this.cell.header === "" ? "" : ` · ${this.cell.header}`;
792
+ const fixed = done
793
+ ? `todo done · ${plural(items.length, "item")} · ${formatDuration(durationSeconds)}`
794
+ : `todo · ${plural(items.length, "item")} · ${active.length} active · ${doneCount} done`;
795
+ const header = `${p.bold}▞${p.reset} ${escapeTerminal(fixed + tail)}`;
796
+ // the FULL-list forms: SETTLED — the durable record (the fold is
797
+ // fine — committed content wraps naturally) — and the LIVE ctrl+r
798
+ // toggle (the header CUTS — the block stays one window high; the
799
+ // expanded rows show the ▣ the collapse hid). The live flag picks
800
+ // the glyphs: the settled list keeps the durable ▖, the expanded
801
+ // live list the ▸.
802
+ const glyph = (status, live) => {
803
+ const g = status === "pending" ? "□" : status === "active" ? (live ? "▸" : "▖") : "▣";
804
+ return g === "▸" ? `${p.bold}▸${p.reset}` : g;
805
+ };
806
+ if (done || expanded) {
807
+ const rows = done ? foldLine(header, W) : [cutLine(header, W)];
808
+ for (const item of items)
809
+ rows.push(...foldLine(` ${glyph(item.status, !done)} ${escapeTerminal(item.text)}`, W));
810
+ return rows;
431
811
  }
432
- return rows;
812
+ // LIVE — the fixed window: the header + the item rows CUT at W
813
+ // (one screen row each — the block's height is its row count,
814
+ // CAP_TODO_LIVE, at every width). The cut is the momentary view;
815
+ // the settle (and the ctrl+r toggle) show everything.
816
+ const itemRows = [];
817
+ if (active.length > 0)
818
+ itemRows.push(` ${p.bold}▸${p.reset} ${escapeTerminal(active[0].text)}`);
819
+ for (const item of pending.slice(0, 2))
820
+ itemRows.push(` □ ${escapeTerminal(item.text)}`);
821
+ const more = pending.length - 2;
822
+ if (more > 0)
823
+ itemRows.push(` ${p.dim}└ +${more} more · ctrl+r${p.reset}`);
824
+ if (doneCount > 0)
825
+ itemRows.push(` ${p.dim}└ +${doneCount} done · ctrl+r${p.reset}`);
826
+ return [cutLine(header, W), ...itemRows.map((r) => cutLine(r, W))];
433
827
  }
434
828
  }
435
829
  // ---- the chrome components (the status container, the slot, the footer) ----
@@ -438,20 +832,24 @@ class Checklist {
438
832
  * the hint CUT FIRST when the width is short (the #16g rule); when
439
833
  * the STATUS ITSELF cannot fit, it cuts with a "…" — the last resort,
440
834
  * enforced by invariant ① (the old code let the status soft-wrap). */
441
- export function statusLine(status, tail, question, W) {
835
+ export function statusLine(status, tail, question, W, hint) {
442
836
  const p = palette();
443
837
  const text = `${status}${tail === "" ? "" : ` · ${tail}`}`;
444
838
  if (question)
445
839
  return `${p.dim}${widthCut(text, W)}${p.reset}`;
446
- const hint = " / commands · history";
840
+ // W18: the hint is a parameter the compacting row right-aligns its
841
+ // "esc to cancel" (the same one-line-bounded shape as W12's delegate
842
+ // row; the #16g rule still cuts the HINT first, then the status with
843
+ // a "…" — never a fold).
844
+ const hintText = hint ?? " / commands · ↑ history";
447
845
  const statusW = visibleWidth(text);
448
846
  if (statusW > W) {
449
847
  return `${p.dim}${widthCut(text, W - 1)}…${p.reset}`;
450
848
  }
451
- const hintW = visibleWidth(hint);
849
+ const hintW = visibleWidth(hintText);
452
850
  if (statusW + hintW > W)
453
851
  return `${p.dim}${text}${p.reset}`;
454
- return `${p.dim}${text}${" ".repeat(Math.max(0, W - statusW - hintW))}${hint}${p.reset}`;
852
+ return `${p.dim}${text}${" ".repeat(Math.max(0, W - statusW - hintW))}${hintText}${p.reset}`;
455
853
  }
456
854
  /** The display-width prefix of a plain (SGR-free) text. */
457
855
  function widthCut(text, max) {
@@ -465,10 +863,17 @@ function widthCut(text, max) {
465
863
  }
466
864
  return text.slice(0, i);
467
865
  }
468
- /** The footer — the ONE dotted row (the old two-row chrome is gone;
469
- * the wall cannot return by construction). */
470
- export function footerLine(W) {
471
- return `\x1b[2m${"╌".repeat(W)}\x1b[0m`;
866
+ /** W6 — the box: the chrome's top rail. The two dotted rows become
867
+ * a rounded box (the box already says "input lives here"); the rails
868
+ * stay dim, the width is still the full W (the box is a rail with
869
+ * corners — the menu/gap rows above and the status below are
870
+ * untouched). */
871
+ export function boxTop(W) {
872
+ return `\x1b[2m╭${"─".repeat(Math.max(0, W - 2))}╮\x1b[0m`;
873
+ }
874
+ /** W6 — the box: the chrome's bottom rail. */
875
+ export function boxBottom(W) {
876
+ return `\x1b[2m╰${"─".repeat(Math.max(0, W - 2))}╯\x1b[0m`;
472
877
  }
473
878
  /** The terminal label + rhythm gap (the pipe path's v2c bytes — the
474
879
  * exact render the passthrough needs). */