@vincemakes/kiso-tui 0.1.37 → 0.1.39

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.
@@ -69,14 +69,20 @@ export type BodyCell = {
69
69
  kind: "user";
70
70
  text: string;
71
71
  done: true;
72
+ turn: number;
72
73
  } | {
73
74
  kind: "thinking";
74
75
  text: string;
75
76
  done: boolean;
77
+ turn: number;
76
78
  } | {
77
79
  kind: "tool";
78
80
  name: string;
79
81
  input: string;
82
+ /** W15: the FULL input JSON (pretty-printed) — the display
83
+ * summary above is sliced at 60 chars; the expanded block's
84
+ * "--- input ---" section mirrors /last and needs it all. */
85
+ inputFull: string;
80
86
  childRoles: string[];
81
87
  state: "pending" | "approval" | "running" | "done";
82
88
  isError: boolean;
@@ -87,6 +93,32 @@ export type BodyCell = {
87
93
  startedAt: number | null;
88
94
  doneAt: number | null;
89
95
  done: boolean;
96
+ /** W15: the live-region expand toggle — while the cell is live
97
+ * the FULL body renders in place (the compositor owns those
98
+ * rows and redraws them); a committed cell can never toggle
99
+ * (history is never rewritten — ADR-0046). */
100
+ expanded: boolean;
101
+ /** W14: the turn boundary — the index of the turn record that
102
+ * created this cell (the fold-hold's owner; −1 when no turn
103
+ * exists yet — the pre-turn cells never hold). */
104
+ turn: number;
105
+ /** W13: the rolled-up group summary — set at COMMIT time when
106
+ * the head of an N > 2 same-tool run renders the group (the
107
+ * work order's claimed shape: "✓ read 5 files (2.4k lines,
108
+ * 1.1s)" + the target children). The members carry null — the
109
+ * compositor's rolled-heads bookkeeping renders them []. */
110
+ rolled: null | {
111
+ count: number;
112
+ lines: number;
113
+ elapsed: string;
114
+ targets: string[];
115
+ };
116
+ /** W19: a DENIED call's reason (the CLI extracted it from the
117
+ * result's "[Permission denied] " prefix, keyed on the "denied"
118
+ * tag). Non-null renders the pinned row — the full call name,
119
+ * the target, the reason in the W4 parentheses idiom, NO timing
120
+ * metadata (the call never ran). */
121
+ reason: string | null;
90
122
  } | {
91
123
  kind: "text";
92
124
  text: string;
@@ -112,26 +144,77 @@ export type BodyCell = {
112
144
  done: true;
113
145
  } | {
114
146
  kind: "checklist";
147
+ /** the model-authored header tail (parseChecklist's count line —
148
+ * chat.ts). The compositor's fixed "task" prefix rides BEFORE it
149
+ * (W20 naming ruling: never model-controlled). */
115
150
  header: string;
116
151
  items: {
117
152
  text: string;
118
153
  status: "pending" | "active" | "done";
119
154
  }[];
120
- done: true;
155
+ /** W20: false while LIVE — the current turn's ONE in-place block
156
+ * (the commit loop only takes done cells, so it stays in the
157
+ * live region); true once SETTLED — endTurn committed it as the
158
+ * turn's one recap block. */
159
+ done: boolean;
160
+ /** W20: the LIVE block's ctrl+r toggle (W15) — the capped form
161
+ * flips to the full list in place. The settled render ignores
162
+ * it (already full). */
163
+ expanded: boolean;
164
+ /** W20: the wall clock of the block's FIRST call — the settled
165
+ * header's duration is clocked from here, compositor-side (the
166
+ * CLI stays unchanged). */
167
+ startedAt: number;
168
+ /** W20: the run's duration at the settle — the `2h 14m` form. */
169
+ durationSeconds: number;
170
+ turn: number;
121
171
  };
122
172
  declare const TOOL_SUMMARY_MAX = 60;
123
173
  /** The component for one cell — the mapping table lives here so the
124
174
  * compositor stays a pure writer. */
125
175
  export declare function cellComponent(cell: BodyCell): Component;
176
+ /** W13 — the rollup opt-in table: which tools collapse, and the count
177
+ * NOUN (read_file calls → "5 files", list_dir → "5 dirs", search_text
178
+ * → "5 matches"). Only these tools opt in — a shell burst is never
179
+ * rolled up (its rows carry meaning). The folded-turn line (W14) reuses
180
+ * the plurals for its other-tool terms ("2 dirs", "1 match"). */
181
+ export declare const ROLLUP_NOUN: Readonly<Record<string, string>>;
182
+ /** W14 — the folded-turn line: a whole QUIET turn (no text), once it is
183
+ * scrollback, becomes ONE line — the work order's claimed shape
184
+ * (`▞ thought 19s · 5 reads · no edits`), the counts accumulated at
185
+ * toolStart: read_file → "reads", edit_file → "edits", the other tools
186
+ * as first-call-order terms (the ROLLUP_NOUN plurals when the tool opts
187
+ * in, the verb + "s" otherwise). */
188
+ export declare function turnFold(t: {
189
+ thoughtSeconds: number;
190
+ reads: number;
191
+ edits: number;
192
+ others: [string, number][];
193
+ }): string[];
194
+ /** W20 — the task block's fixed-window height: the whole live block
195
+ * (header + rows) in POST-FOLD screen rows at EVERY width: the header,
196
+ * the active row, up to 2 pending, the overflow-pending fold, the
197
+ * done-collapse. Every live row CUTS at W (never folds) — the block's
198
+ * height is its row count. */
199
+ export declare const CAP_TASK_LIVE = 6;
200
+ /** W20 — the settled block's duration, the `2h 14m` form (the task
201
+ * narrative's long-horizon idiom): minutes+seconds under an hour,
202
+ * hours+minutes past it. */
203
+ export declare function formatDuration(totalSeconds: number): string;
126
204
  /** The status container's row: the status text (+ the tail) with the
127
205
  * right-aligned "/ commands · ↑ history" hint in the idle state —
128
206
  * the hint CUT FIRST when the width is short (the #16g rule); when
129
207
  * the STATUS ITSELF cannot fit, it cuts with a "…" — the last resort,
130
208
  * enforced by invariant ① (the old code let the status soft-wrap). */
131
209
  export declare function statusLine(status: string, tail: string, question: boolean, W: number, hint?: string): string;
132
- /** The footer — the ONE dotted row (the old two-row chrome is gone;
133
- * the wall cannot return by construction). */
134
- export declare function footerLine(W: number): string;
210
+ /** W6 — the box: the chrome's top rail. The two dotted rows become
211
+ * a rounded box (the box already says "input lives here"); the rails
212
+ * stay dim, the width is still the full W (the box is a rail with
213
+ * corners — the menu/gap rows above and the status below are
214
+ * untouched). */
215
+ export declare function boxTop(W: number): string;
216
+ /** W6 — the box: the chrome's bottom rail. */
217
+ export declare function boxBottom(W: number): string;
135
218
  /** The terminal label + rhythm gap (the pipe path's v2c bytes — the
136
219
  * exact render the passthrough needs). */
137
220
  export declare function terminalPipe(label: string, statusLineText: string): string;
@@ -16,7 +16,7 @@
16
16
  * tint, fold wording).
17
17
  */
18
18
  import { displayWidth } from "./editor.js";
19
- import { bannerLines, 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
  /**
@@ -167,19 +167,20 @@ export function cellComponent(cell) {
167
167
  }
168
168
  }
169
169
  /**
170
- * The user message — the left rail (bright-white BOLD per row, the
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.
170
+ * The user message — the W16 inset chip ALONE (the 2026-08-09 ruling:
171
+ * the rail and the indent are retired the rail's stated pipe
172
+ * fallback was theoretical redundancy: the CLI's pipe path is the
173
+ * line-mode "you>" form and never renders UserMessage). The chip folds
174
+ * the text at W−2 (the side pads), then pads EVERY row to the longest
175
+ * row's DISPLAY width + one space each side, flush left: the block is
176
+ * only as wide as what was said (never the full-width band a short
177
+ * message like /think would paint a bar across the terminal). The
178
+ * padding is by cells (charWidth is the width authority), so a CJK row
179
+ * pads by width, never by chars, and the chip never overruns its fold.
180
+ * SGR 7 closed with SGR 27 never SGR 0, the chip composes with a
181
+ * surrounding span and NEVER dim: reverse video inverts the CURRENT
182
+ * colours, so dimmed text would invert into a dimmed block with no
183
+ * contrast.
183
184
  */
184
185
  class UserMessage {
185
186
  cell;
@@ -188,18 +189,17 @@ class UserMessage {
188
189
  }
189
190
  render(W, _ctx) {
190
191
  const p = palette();
191
- const rail = `${p.bold}▍${p.reset} `;
192
- const chipW = Math.max(1, W - 6);
192
+ const chipW = Math.max(1, W - 2);
193
193
  const rows = [];
194
194
  for (const para of this.cell.text.split("\n")) {
195
195
  const folded = foldLine(escapeTerminal(para), chipW);
196
196
  const inner = Math.max(...folded.map((r) => displayWidth(r)));
197
197
  for (const row of folded) {
198
198
  const pad = inner - displayWidth(row);
199
- rows.push(`${rail} ${p.rv} ${row}${" ".repeat(pad)} ${p.rvEnd}`);
199
+ rows.push(`${p.rv} ${row}${" ".repeat(pad)} ${p.rvEnd}`);
200
200
  }
201
201
  }
202
- return rows.length > 0 ? rows : [rail.trimEnd()];
202
+ return rows;
203
203
  }
204
204
  }
205
205
  /** The thinking fold — one dim line, width-capped so the /think suffix
@@ -328,7 +328,43 @@ class ToolExecution {
328
328
  const verb = escapeTerminal(c.name.replace("_file", ""));
329
329
  const verbCol = verb.length < 5 ? `${verb}${" ".repeat(5 - verb.length)}` : verb;
330
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
+ }
331
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
+ }
332
368
  const elapsed = c.startedAt !== null && c.doneAt !== null ? ((c.doneAt - c.startedAt) / 1000).toFixed(1) : "?";
333
369
  const meta = escapeTerminal(settledMeta(c));
334
370
  const out = c.isError
@@ -357,6 +393,47 @@ class ToolExecution {
357
393
  return gutterFold(`${p.dim}◦${p.reset} `, `${verbCol} ${summary}`, W);
358
394
  }
359
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
+ }
360
437
  // ---- the bounded-block flow contract (W7, W8, W10) ----
361
438
  /** The caps — screen rows counted AFTER the fold, at the current width
362
439
  * (the W7 table). The renderer-cut row is inside the cap. */
@@ -374,27 +451,39 @@ const blockMemo = new WeakMap();
374
451
  /** The block's body rows below the header (memoized, W9). */
375
452
  function toolBlockBody(c, W) {
376
453
  const memo = blockMemo.get(c);
377
- const state = `${c.state}:${c.isError}:${c.name}`;
454
+ const state = `${c.state}:${c.isError}:${c.name}:${c.expanded ? "x" : ""}`;
378
455
  const content = c.state === "approval" ? (c.diff ?? null) : c.resultText;
379
456
  if (memo !== undefined && memo.width === W && memo.state === state && memo.content === content)
380
457
  return memo.rows;
381
458
  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);
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);
398
487
  if (note !== null)
399
488
  rows.push(...foldLine(`${p.dim}${CUT_ROW}${note}${p.reset}`, W));
400
489
  blockMemo.set(c, { width: W, state, content, rows });
@@ -436,7 +525,11 @@ function errorBody(c, W) {
436
525
  // rides the header — the parsed code does — so the body keeps the
437
526
  // FULL text. Any other error keeps the pre-W4 split: line 0 is the
438
527
  // header's metadata, the body shows the rest.
439
- const skipFirst = c.name === "shell" && /^exit \d+/.test(c.resultText) ? 0 : 1;
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;
440
533
  const rows = blockRows(c.resultText.split("\n").slice(skipFirst).join("\n"), W);
441
534
  if (rows.length <= CAP_ERROR)
442
535
  return rows;
@@ -507,7 +600,7 @@ function oneLineRow(p, text, W) {
507
600
  * a floor of 3 SOURCE lines visible the head/tail pair is noise (each
508
601
  * fragment a sliver of a long line): drop to the head only — the head
509
602
  * takes the whole budget — and the └ row carries the rest. */
510
- function diffBody(diff, W) {
603
+ function diffBody(diff, W, expanded = false) {
511
604
  const p = palette();
512
605
  if (diff === null)
513
606
  return [];
@@ -527,7 +620,7 @@ function diffBody(diff, W) {
527
620
  rows.push(...gutterFold(`${p.dim}│${p.reset} `, body, W));
528
621
  starts.push(rows.length);
529
622
  }
530
- if (rows.length <= CAP_DIFF)
623
+ if (expanded || rows.length <= CAP_DIFF)
531
624
  return rows;
532
625
  const head = Math.floor((CAP_DIFF - 1) / 2);
533
626
  const tail = CAP_DIFF - 1 - head;
@@ -625,7 +718,64 @@ class Banner {
625
718
  return rows.map((r) => `${p.dim}${r}${p.reset}`);
626
719
  }
627
720
  }
628
- /** The durable checklist — the header + one brick-glyph row per item. */
721
+ /** W20 — the task 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_TASK_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 task
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 task 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 "task" 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 `task 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
+ */
629
779
  class Checklist {
630
780
  cell;
631
781
  constructor(cell) {
@@ -633,12 +783,47 @@ class Checklist {
633
783
  }
634
784
  render(W, _ctx) {
635
785
  const p = palette();
636
- const glyphOf = (status) => (status === "pending" ? "□" : status === "active" ? "▖" : "▣");
637
- const rows = foldLine(`${p.bold}▞${p.reset} ${escapeTerminal(this.cell.header)}`, W);
638
- for (const item of this.cell.items) {
639
- 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
+ ? `task done · ${plural(items.length, "item")} · ${formatDuration(durationSeconds)}`
794
+ : `task · ${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;
640
811
  }
641
- 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_TASK_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))];
642
827
  }
643
828
  }
644
829
  // ---- the chrome components (the status container, the slot, the footer) ----
@@ -678,10 +863,17 @@ function widthCut(text, max) {
678
863
  }
679
864
  return text.slice(0, i);
680
865
  }
681
- /** The footer — the ONE dotted row (the old two-row chrome is gone;
682
- * the wall cannot return by construction). */
683
- export function footerLine(W) {
684
- 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`;
685
877
  }
686
878
  /** The terminal label + rhythm gap (the pipe path's v2c bytes — the
687
879
  * exact render the passthrough needs). */
@@ -35,8 +35,9 @@
35
35
  * scheduler — a one-shot setTimeout re-armed only while a running
36
36
  * tool exists (the #14/#15 zero-output contract is structural).
37
37
  *
38
- * Layout at H rows (V6-3 — the design §03 chrome): content rows
39
- * 1..H−4, upper H−3, editor (the slot) H−2, lower ╌ H−1, status H.
38
+ * Layout at H rows (V6-3 — the design §03 chrome; W6 — the box):
39
+ * content rows 1..H−4, box top H−3, editor (the slot) H−2, box
40
+ * bottom H−1, status H.
40
41
  * Pipes / NO_COLOR: the passthrough branches below keep the v2a/v2b
41
42
  * line-mode bytes byte-for-byte (the e2e guards them).
42
43
  */
@@ -78,11 +79,32 @@ export declare class Body {
78
79
  toolResult(callId: string, result: {
79
80
  content: string;
80
81
  isError: boolean;
82
+ reason?: string | null;
81
83
  }): void;
82
84
  textAppend(text: string): void;
83
85
  textEnd(): void;
86
+ /** W14 — the turn boundary's END: the CLI calls this at the run's
87
+ * terminal event, once per run, BEFORE the recap (so the fold line
88
+ * commits before the recap in the cell order). `thoughtSeconds` is
89
+ * the CLI's wall-clocked thinking window. The QUIET turn (ended, no
90
+ * text) releases its held cells as the ONE fold line; a turn with
91
+ * text releases them as individual commits (the W13 rollups). The
92
+ * release is LAZY — the held cells commit at the next frame, when
93
+ * the fold/rollup decision runs. */
94
+ endTurn(thoughtSeconds: number): void;
84
95
  terminal(label: string, statusLineText: string): void;
85
96
  notice(text: string): void;
97
+ /** W20 — the task checklist as STATE, not events: the FIRST call of a
98
+ * turn creates the ONE live block (done:false — the commit loop only
99
+ * takes done cells, so it stays in the live region); later calls of
100
+ * the SAME turn MUTATE that block in place — same position, same
101
+ * height, zero committed rows (the W8 fixed-window rule generalised
102
+ * to state). An unchanged whole-table replace (the task extension's
103
+ * idempotent shape) is a no-op — no mark, no frame. The block commits
104
+ * ONCE at the turn's end (endTurn); the next turn's first call starts
105
+ * a fresh block — one settled block per turn that touched the list,
106
+ * never one per update. The pipe path stays per-call (byte-linear —
107
+ * every write is final; there is no in-place redraw in a pipe). */
86
108
  checklist(header: string, items: {
87
109
  text: string;
88
110
  status: "pending" | "active" | "done";
@@ -107,6 +129,23 @@ export declare class Body {
107
129
  isError: boolean;
108
130
  };
109
131
  } | null;
132
+ /** W15 — the expand key's target (ctrl+r). A cell still in the LIVE
133
+ * region (the newest live tool) TOGGLES in place — the compositor
134
+ * owns those rows and redraws them (the body flips to the full
135
+ * form, no cap). A committed cell can never toggle — history is
136
+ * never rewritten (ADR-0046) — so the key APPENDS a fresh expanded
137
+ * block at the bottom instead, the /last idiom aimed at a chosen
138
+ * cell: the pointer cycles the collapsed history, newest first, and
139
+ * the header names the target ("N turns back" — the user cells
140
+ * after it), so every press tells the user what they got. */
141
+ expandNext(): {
142
+ kind: "toggled";
143
+ } | {
144
+ kind: "appended";
145
+ lines: string[];
146
+ } | {
147
+ kind: "none";
148
+ };
110
149
  /** Docked = the chrome is live (a color TTY with a real size). */
111
150
  get active(): boolean;
112
151
  enter(): void;