@vincemakes/kiso-tui 0.1.37 → 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.
@@ -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 "todo" 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 todo 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_TODO_LIVE = 6;
200
+ /** W20 — the settled block's duration, the `2h 14m` form (the todo
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
  /**
@@ -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 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
+ */
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
+ ? `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;
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_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))];
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 todo 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 todo 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;
@@ -35,22 +35,29 @@
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
  */
43
44
  import { truncateDiff } from "./diff.js";
44
45
  import { displayWidth } from "./editor.js";
45
- import { Container, SPINNER, bodySpacing, cellComponent, foldLine, footerLine, statusLine, visibleWidth, } from "./components.js";
46
- import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary } from "./render.js";
46
+ import { Container, ROLLUP_NOUN, SPINNER, bodySpacing, boxBottom, boxTop, cellComponent, foldLine, statusLine, turnFold, visibleWidth, } from "./components.js";
47
+ import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, toolTarget } from "./render.js";
47
48
  /** The cursor marker — an APC private sequence the focus component
48
49
  * embeds at the edit position; the compositor strips it and moves
49
50
  * relatively (it never reaches the terminal). */
50
51
  export const CURSOR_MARKER = "\x1b_[kiso-cur]\x1b\\";
51
52
  const FRAME_MS = 16; // state changes coalesce to ≥16ms frames
52
53
  const SPINNER_MS = 200; // the spinner cadence — a ONE-SHOT re-armed on demand
53
- const CHROME_ROWS = 4; // upper + input + lower + status — the design §03 chrome (V6-3)
54
+ const CHROME_ROWS = 4; // box top + input + box bottom + status — the design §03 chrome (V6-3; the box is W6)
55
+ /** W20 — the whole-table-replace comparison: the live todo block only
56
+ * redraws when the items actually changed (the todo extension's
57
+ * idempotent shape — an unchanged replace is a no-op, no frame). */
58
+ function sameTodo(a, b) {
59
+ return a.length === b.length && a.every((x, i) => x.text === b[i].text && x.status === b[i].status);
60
+ }
54
61
  /** The one compositor — implements the Body façade AND the Dock chrome
55
62
  * API (see the class comments on each method group). */
56
63
  export class Body {
@@ -74,6 +81,19 @@ export class Body {
74
81
  #pendingCalls = new Map();
75
82
  #pipeBuf = ""; // the passthrough's thinking buffer
76
83
  #toolCells = new Map(); // callId → cell index (parallel tools)
84
+ // W15: the collapsed (cut) tool cells — committed cells whose last
85
+ // rendered row carried the "ctrl+r" affordance; the expand key's
86
+ // cycling pointer walks this list from the newest back.
87
+ #collapsed = [];
88
+ #expandPtr = 0;
89
+ // W14: the turn records — one per userLine, the fold-hold's state
90
+ // machine (ended / hasText / folded) plus the folded-turn line's
91
+ // counts (accumulated at toolStart). The cells carry the record's
92
+ // index as their turn boundary.
93
+ #turns = [];
94
+ // W13: the rolled-up run heads — the commit-time scan's verdict:
95
+ // the head's group summary renders, the members render [].
96
+ #rolledHeads = new Set();
77
97
  #write;
78
98
  #resizeHandler = null;
79
99
  // the chrome state (the Dock façade)
@@ -119,7 +139,10 @@ export class Body {
119
139
  }
120
140
  this.#closeOpenThinking();
121
141
  this.#closeOpenText();
122
- this.#cells.push({ kind: "user", text, done: true });
142
+ // W14: the turn boundary the record the fold-hold's release
143
+ // state machine reads; the cell carries the record's index.
144
+ this.#turns.push({ ended: false, hasText: false, thoughtSeconds: 0, reads: 0, edits: 0, others: new Map(), folded: false });
145
+ this.#cells.push({ kind: "user", text, done: true, turn: this.#turns.length - 1 });
123
146
  this.#mark();
124
147
  }
125
148
  thinkingAppend(text) {
@@ -132,7 +155,7 @@ export class Body {
132
155
  last.text += text;
133
156
  }
134
157
  else {
135
- this.#cells.push({ kind: "thinking", text, done: false });
158
+ this.#cells.push({ kind: "thinking", text, done: false, turn: this.#turns.length - 1 });
136
159
  }
137
160
  this.#mark();
138
161
  }
@@ -166,7 +189,19 @@ export class Body {
166
189
  }
167
190
  }
168
191
  this.#toolCells.set(callId, this.#cells.length);
169
- this.#cells.push({ kind: "tool", name, input: summary, childRoles, state: "pending", isError: false, resultText: "", diff: null, added: 0, removed: 0, startedAt: null, doneAt: null, done: false });
192
+ this.#cells.push({ kind: "tool", name, input: summary, inputFull: JSON.stringify(input, null, 2), childRoles, state: "pending", isError: false, resultText: "", diff: null, added: 0, removed: 0, startedAt: null, doneAt: null, done: false, expanded: false, turn: this.#turns.length - 1, rolled: null, reason: null });
193
+ // W14: the turn record's counts — the folded-turn line's terms
194
+ // (reads = read_file, edits = edit_file, the rest in first-call
195
+ // order). The CLI's recap counts the same way (edit_file).
196
+ const turn = this.#turns[this.#turns.length - 1];
197
+ if (turn !== undefined) {
198
+ if (name === "read_file")
199
+ turn.reads += 1;
200
+ else if (name === "edit_file")
201
+ turn.edits += 1;
202
+ else
203
+ turn.others.set(name, (turn.others.get(name) ?? 0) + 1);
204
+ }
170
205
  this.#mark();
171
206
  }
172
207
  toolApproval(callId, diff) {
@@ -213,8 +248,11 @@ export class Body {
213
248
  this.#pendingCalls.delete(callId);
214
249
  }
215
250
  if (!this.#isActive()) {
251
+ // W19: the pipe path renders the SAME pinned deny row (the
252
+ // reason in the W4 parentheses idiom), byte-clean — plus the
253
+ // folded [result ✗] body below (never hide information).
216
254
  const p = palette();
217
- this.#write(`${renderToolSummary(call?.name ?? "?", call?.input ?? {}, result)}\n` +
255
+ this.#write(`${renderToolSummary(call?.name ?? "?", call?.input ?? {}, result, result.reason ?? null)}\n` +
218
256
  `${p.dim}${result.isError ? p.red : p.dim} [result${result.isError ? " ✗" : ""}] ${foldResult(result.content)}${p.reset}\n`);
219
257
  return;
220
258
  }
@@ -224,6 +262,7 @@ export class Body {
224
262
  cell.state = "done";
225
263
  cell.isError = result.isError;
226
264
  cell.resultText = result.content;
265
+ cell.reason = result.reason ?? null;
227
266
  cell.doneAt = Date.now();
228
267
  cell.done = true;
229
268
  }
@@ -236,6 +275,12 @@ export class Body {
236
275
  this.#write(escapeTerminal(text));
237
276
  return;
238
277
  }
278
+ // W14: the text's arrival RELEASES the fold-hold — the turn now
279
+ // has text, its held cells commit individually (with the W13
280
+ // rollups; the fold is only for the QUIET turn).
281
+ const turn = this.#turns[this.#turns.length - 1];
282
+ if (turn !== undefined)
283
+ turn.hasText = true;
239
284
  const last = this.#cells[this.#cells.length - 1];
240
285
  if (last !== undefined && last.kind === "text" && !last.done) {
241
286
  last.text += text;
@@ -257,6 +302,51 @@ export class Body {
257
302
  last.done = true;
258
303
  this.#mark();
259
304
  }
305
+ /** W14 — the turn boundary's END: the CLI calls this at the run's
306
+ * terminal event, once per run, BEFORE the recap (so the fold line
307
+ * commits before the recap in the cell order). `thoughtSeconds` is
308
+ * the CLI's wall-clocked thinking window. The QUIET turn (ended, no
309
+ * text) releases its held cells as the ONE fold line; a turn with
310
+ * text releases them as individual commits (the W13 rollups). The
311
+ * release is LAZY — the held cells commit at the next frame, when
312
+ * the fold/rollup decision runs. */
313
+ endTurn(thoughtSeconds) {
314
+ if (!this.#isActive())
315
+ return;
316
+ const turn = this.#turns[this.#turns.length - 1];
317
+ if (turn === undefined || turn.ended)
318
+ return;
319
+ turn.ended = true;
320
+ turn.thoughtSeconds = thoughtSeconds;
321
+ // W20: the turn's live todo block settles HERE — the ONE recap
322
+ // block for the turn (`todo done · N items · <duration>`, the
323
+ // duration clocked compositor-side from the block's first call —
324
+ // the CLI stays unchanged). A turn that never touched the list has
325
+ // no live block — nothing settles. Newest-first: the live block is
326
+ // the newest cell of its turn.
327
+ for (let i = this.#cells.length - 1; i >= 0; i -= 1) {
328
+ const c = this.#cells[i];
329
+ if (c.kind === "checklist" && !c.done) {
330
+ c.done = true;
331
+ c.durationSeconds = Math.max(0, Math.round((Date.now() - c.startedAt) / 1000));
332
+ break;
333
+ }
334
+ }
335
+ // the QUIET turn: an open thinking cell closes at the boundary —
336
+ // its natural closer is the text's arrival (never comes here — the
337
+ // text-less turn), so without this the fold could never commit AT
338
+ // it (the commit loop only takes done cells — the fold would stall
339
+ // forever behind the live thinking).
340
+ for (let i = this.#cells.length - 1; i >= 0; i -= 1) {
341
+ const c = this.#cells[i];
342
+ if (c.kind === "thinking" && !c.done) {
343
+ c.done = true;
344
+ this.#lastThinking = c.text;
345
+ break;
346
+ }
347
+ }
348
+ this.#mark();
349
+ }
260
350
  terminal(label, statusLineText) {
261
351
  if (!this.#isActive()) {
262
352
  this.#closeOpenThinking();
@@ -281,6 +371,17 @@ export class Body {
281
371
  this.#cells.push({ kind: "notice", text, done: true });
282
372
  this.#mark();
283
373
  }
374
+ /** W20 — the todo checklist as STATE, not events: the FIRST call of a
375
+ * turn creates the ONE live block (done:false — the commit loop only
376
+ * takes done cells, so it stays in the live region); later calls of
377
+ * the SAME turn MUTATE that block in place — same position, same
378
+ * height, zero committed rows (the W8 fixed-window rule generalised
379
+ * to state). An unchanged whole-table replace (the todo extension's
380
+ * idempotent shape) is a no-op — no mark, no frame. The block commits
381
+ * ONCE at the turn's end (endTurn); the next turn's first call starts
382
+ * a fresh block — one settled block per turn that touched the list,
383
+ * never one per update. The pipe path stays per-call (byte-linear —
384
+ * every write is final; there is no in-place redraw in a pipe). */
284
385
  checklist(header, items) {
285
386
  if (!this.#isActive()) {
286
387
  this.#closeOpenThinking();
@@ -294,7 +395,25 @@ export class Body {
294
395
  }
295
396
  this.#closeOpenThinking();
296
397
  this.#closeOpenText();
297
- this.#cells.push({ kind: "checklist", header, items, done: true });
398
+ const turn = this.#turns.length - 1;
399
+ const last = this.#cells[this.#cells.length - 1];
400
+ if (last !== undefined && last.kind === "checklist" && !last.done && last.turn === turn) {
401
+ if (!sameTodo(last.items, items)) {
402
+ Object.assign(last, { header, items });
403
+ this.#mark();
404
+ }
405
+ return;
406
+ }
407
+ this.#cells.push({
408
+ kind: "checklist",
409
+ header,
410
+ items,
411
+ done: false,
412
+ expanded: false,
413
+ startedAt: Date.now(),
414
+ durationSeconds: 0,
415
+ turn,
416
+ });
298
417
  this.#mark();
299
418
  }
300
419
  /** The startup banner (W1): a LIVE cell — the tier re-derives per
@@ -342,6 +461,77 @@ export class Body {
342
461
  lastTool() {
343
462
  return this.#lastTool;
344
463
  }
464
+ /** W15 — the expand key's target (ctrl+r). A cell still in the LIVE
465
+ * region (the newest live tool) TOGGLES in place — the compositor
466
+ * owns those rows and redraws them (the body flips to the full
467
+ * form, no cap). A committed cell can never toggle — history is
468
+ * never rewritten (ADR-0046) — so the key APPENDS a fresh expanded
469
+ * block at the bottom instead, the /last idiom aimed at a chosen
470
+ * cell: the pointer cycles the collapsed history, newest first, and
471
+ * the header names the target ("N turns back" — the user cells
472
+ * after it), so every press tells the user what they got. */
473
+ expandNext() {
474
+ for (let i = this.#cells.length - 1; i >= this.#committed; i -= 1) {
475
+ const cell = this.#cells[i];
476
+ if (cell.kind === "tool" && cell.state !== "pending") {
477
+ cell.expanded = !cell.expanded;
478
+ this.#mark();
479
+ return { kind: "toggled" };
480
+ }
481
+ // W20: the LIVE todo block toggles in place too — the capped
482
+ // form flips to the full list (the "done-collapse expands
483
+ // under ctrl+r" claim). The SETTLED block is already full —
484
+ // no toggle, and its rows carry no affordance, so it never
485
+ // joins #collapsed (the committed /last append is moot).
486
+ if (cell.kind === "checklist" && !cell.done) {
487
+ cell.expanded = !cell.expanded;
488
+ this.#mark();
489
+ return { kind: "toggled" };
490
+ }
491
+ }
492
+ if (this.#collapsed.length === 0)
493
+ return { kind: "none" };
494
+ const idx = this.#collapsed[this.#expandPtr % this.#collapsed.length];
495
+ this.#expandPtr += 1;
496
+ const cell = this.#cells[idx];
497
+ if (cell.kind !== "tool")
498
+ return { kind: "none" };
499
+ if (cell.rolled !== null) {
500
+ // W13: a rolled-up head expands to the FULL per-call children —
501
+ // the rollup showed the first 3 + the overflow; the expand shows
502
+ // every target, one └ row each (the /last idiom — the children
503
+ // land as NEW content, history is never rewritten, ADR-0046).
504
+ const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
505
+ const p = palette();
506
+ const noun = ROLLUP_NOUN[cell.name] ?? "calls";
507
+ const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${cell.name.replace("_file", "")} ${cell.rolled.count} ${noun}`)} · ${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
508
+ return {
509
+ kind: "appended",
510
+ lines: [header, ...cell.rolled.targets.map((t) => ` ${p.dim}└ ${escapeTerminal(t)}${p.reset}`)],
511
+ };
512
+ }
513
+ let input = {};
514
+ try {
515
+ input = JSON.parse(cell.inputFull);
516
+ }
517
+ catch {
518
+ // the full JSON is always parseable (it was stringified at
519
+ // toolStart) — the empty fallback never fires
520
+ }
521
+ const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
522
+ const p = palette();
523
+ const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${cell.name.replace("_file", "")} ${toolTarget(cell.name, input)}`)} · ${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
524
+ return {
525
+ kind: "appended",
526
+ lines: [
527
+ header,
528
+ `--- ${cell.name} input ---`,
529
+ cell.inputFull,
530
+ `--- ${cell.name} output${cell.isError ? " (error)" : ""} ---`,
531
+ cell.resultText,
532
+ ],
533
+ };
534
+ }
345
535
  // ---- the Dock façade (the CLI's chrome API — same shape as the old dock) ----
346
536
  /** Docked = the chrome is live (a color TTY with a real size). */
347
537
  get active() {
@@ -431,7 +621,8 @@ export class Body {
431
621
  * reads it — the marker math never desyncs by construction). */
432
622
  editCol() {
433
623
  const inp = this.#inputState();
434
- return displayWidth(this.#inputPrompt.replace(/\x1b\[[0-9;]*m/g, "")) + inp.cursor + 1;
624
+ // W6: the box's left wall (2 cols) prefixes the prompt
625
+ return 2 + displayWidth(this.#inputPrompt.replace(/\x1b\[[0-9;]*m/g, "")) + inp.cursor + 1;
435
626
  }
436
627
  /** The old dock's redraw — the editor's onRender target: mark + the
437
628
  * scheduler (16ms coalescing — the old sync draw coalesces the same). */
@@ -546,7 +737,13 @@ export class Body {
546
737
  // emit exactly once).
547
738
  this.#committedAtFrameStart = this.#committed;
548
739
  this.#committedLinesThisFrame = [];
549
- while (this.#committed < this.#cells.length && this.#cells[this.#committed].done) {
740
+ // W14: the natural loop HONORS the fold-hold — a thinking/tool
741
+ // cell of the OPEN quiet turn (no text yet) does not commit: its
742
+ // committed form is decided at the release (the turn's text →
743
+ // individual commits with the W13 rollups; the turn's end → the
744
+ // fold). The FORCE-commit path below bypasses the hold — the
745
+ // screen never sticks, the rollup degrades to individuals.
746
+ while (this.#committed < this.#cells.length && this.#cells[this.#committed].done && !this.#held(this.#committed)) {
550
747
  this.#commitCell(this.#committed, W, ctx);
551
748
  }
552
749
  // 2. the live lines — the unfinished cells (the tail) + the chrome.
@@ -606,13 +803,138 @@ export class Body {
606
803
  * this cell's commit — the cache stays raw, the placed rows count. */
607
804
  #commitCell(i, W, ctx) {
608
805
  const cell = this.#cells[i];
609
- const lines = cellComponent(cell).render(W, ctx);
806
+ const lines = this.#foldOrRollup(cell, i, W, ctx);
807
+ // W15: a tool cell whose last committed row carried the "ctrl+r"
808
+ // affordance (the renderer cut "└ … ctrl+r") joins the expand
809
+ // history — the detection is the renderer's OWN output, so the
810
+ // read's "/last"-only cut note never lands here.
811
+ // unshift: the cells commit oldest-first, so the NEWEST cut lands
812
+ // at the front — the expand pointer's "newest back" walk starts
813
+ // where the user's last key press would aim.
814
+ if (cell.kind === "tool" && /└ .*ctrl\+r/.test(lines[lines.length - 1] ?? ""))
815
+ this.#collapsed.unshift(i);
610
816
  this.#lineCache[i] = lines;
611
817
  const placed = bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, lines);
612
818
  this.#committed += 1;
613
819
  this.#committedLines += placed.length;
614
820
  this.#committedLinesThisFrame.push(...placed);
615
821
  }
822
+ /** W14 — the fold-hold: a thinking/tool cell of the OPEN quiet turn
823
+ * (no text yet) does not commit — its committed form is decided at
824
+ * the release. The cell's OWN turn must be the CURRENT one (a cell
825
+ * of a released turn commits normally). The force-commit path never
826
+ * consults this — the screen's hard cap wins over the hold. */
827
+ #held(i) {
828
+ const cell = this.#cells[i];
829
+ if (cell.kind !== "thinking" && cell.kind !== "tool")
830
+ return false;
831
+ const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
832
+ if (turn === undefined || turn !== this.#turns[this.#turns.length - 1])
833
+ return false;
834
+ return !turn.ended && !turn.hasText;
835
+ }
836
+ /** W14/W13 — the release-time decision at a commit, BEFORE the cell's
837
+ * own render: the folded-turn fold first (a QUIET turn — ended, no
838
+ * text — becomes the ONE fold line; the rest of its thinking/tool
839
+ * cells render [] after the fold), then the W13 rollup (a text
840
+ * turn's N > 2 same-tool run: the HEAD renders the group summary,
841
+ * the members render [] — the scan is the work order's "group key",
842
+ * derived at commit time, never pre-stored). */
843
+ #foldOrRollup(cell, i, W, ctx) {
844
+ if (cell.kind === "thinking" || cell.kind === "tool") {
845
+ const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
846
+ if (turn !== undefined && turn.ended && !turn.hasText) {
847
+ if (!turn.folded) {
848
+ turn.folded = true;
849
+ return turnFold({
850
+ thoughtSeconds: turn.thoughtSeconds,
851
+ reads: turn.reads,
852
+ edits: turn.edits,
853
+ others: [...turn.others],
854
+ });
855
+ }
856
+ return [];
857
+ }
858
+ }
859
+ if (cell.kind !== "tool" || ROLLUP_NOUN[cell.name] === undefined)
860
+ return cellComponent(cell).render(W, ctx);
861
+ // the maximal same-name run around i — forward/backward scans over
862
+ // the cells. The turn-less noise cells (the permission raws, the ⚠
863
+ // notices) are TRANSPARENT: the streaming execution (loop.ts launch)
864
+ // interleaves them BETWEEN the calls of one burst, so the run must
865
+ // see through them. It never crosses a user/text/thinking cell —
866
+ // those separate turns and contexts.
867
+ let s = i;
868
+ let head = i;
869
+ while (s > 0) {
870
+ const prev = this.#cells[s - 1];
871
+ if (prev.kind === "raw" || prev.kind === "notice") {
872
+ s -= 1;
873
+ continue;
874
+ }
875
+ if (prev.kind !== "tool" || prev.name !== cell.name)
876
+ break;
877
+ s -= 1;
878
+ head = s; // a same-name tool precedes — it is the group's head
879
+ }
880
+ let e = i;
881
+ while (e + 1 < this.#cells.length) {
882
+ const next = this.#cells[e + 1];
883
+ if (next.kind === "raw" || next.kind === "notice") {
884
+ e += 1;
885
+ continue;
886
+ }
887
+ if (next.kind !== "tool" || next.name !== cell.name)
888
+ break;
889
+ e += 1;
890
+ }
891
+ // the run counts the TOOL cells only — the span's raws are noise.
892
+ const members = this.#cells.slice(s, e + 1).filter((c) => c.kind === "tool");
893
+ if (members.length <= 2)
894
+ return cellComponent(cell).render(W, ctx);
895
+ if (head === i) {
896
+ // the HEAD — the rollup only when EVERY member is done (at the
897
+ // text's release they are — the natural loop commits the run in
898
+ // one frame; the force-commit's early commits degrade to the
899
+ // individual rows, the members render normally after).
900
+ if (!members.every((c) => c.done))
901
+ return cellComponent(cell).render(W, ctx);
902
+ this.#rolledHeads.add(head);
903
+ let total = 0;
904
+ const targets = [];
905
+ for (const m of members) {
906
+ // the lines count, excluding the tool's OWN truncation note
907
+ // (read_file's "… N more lines") — the per-cell meta's rule
908
+ const noteAt = m.resultText.lastIndexOf("\n… ");
909
+ const shown = noteAt >= 0 ? m.resultText.slice(0, noteAt) : m.resultText;
910
+ const parts = shown.split("\n");
911
+ total += parts[parts.length - 1] === "" ? parts.length - 1 : parts.length;
912
+ let input = {};
913
+ try {
914
+ input = JSON.parse(m.inputFull);
915
+ }
916
+ catch {
917
+ // the full JSON is always parseable (stringified at
918
+ // toolStart) — the empty fallback never fires
919
+ }
920
+ const target = toolTarget(m.name, input);
921
+ targets.push(target.split("/").pop() ?? target);
922
+ }
923
+ const first = members[0];
924
+ const last = members[members.length - 1];
925
+ const elapsed = first.startedAt !== null && last.doneAt !== null ? ((last.doneAt - first.startedAt) / 1000).toFixed(1) : "?";
926
+ cell.rolled = { count: members.length, lines: total, elapsed, targets };
927
+ return cellComponent(cell).render(W, ctx);
928
+ }
929
+ // a MEMBER of an already-rolled run → [] (its rows live in the
930
+ // head's summary). A member of a run whose head committed
931
+ // INDIVIDUALLY (the force-commit's degradation) renders normally —
932
+ // the head is not in #rolledHeads, the run never rolls after the
933
+ // head's individual commit.
934
+ if (this.#rolledHeads.has(head))
935
+ return [];
936
+ return cellComponent(cell).render(W, ctx);
937
+ }
616
938
  /** The slot occupant's extra rows — the slash-command menu (above the
617
939
  * status, in the rhythm gap + the content's spare rows — the old
618
940
  * menu's position, slot-shaped). */
@@ -635,7 +957,14 @@ export class Body {
635
957
  * cursor's display column WITHIN THE ROW (the brick/question lead
636
958
  * included), the question/editor/menu variants. The compositor
637
959
  * strips the marker and moves LEFT by the trailing width — the
638
- * cursor derives from the frame, never from side-channel math. */
960
+ * cursor derives from the frame, never from side-channel math.
961
+ *
962
+ * W6: the row lives INSIDE the box — the walls are a prefix/suffix
963
+ * width only, composed AFTER the marker embed (the marker math is
964
+ * untouched; the marker's row column = the wall + the lead + the
965
+ * cursor). The content caps at W−4 (the walls' columns) and the
966
+ * pad completes the row to EXACTLY W — invariant ① throws on
967
+ * overflow, so the box row is built full-width, never truncated. */
639
968
  #inputRow(W, _ctx) {
640
969
  const st = this.#inputState();
641
970
  let row;
@@ -653,8 +982,8 @@ export class Body {
653
982
  const leadW = visibleWidth(row.slice(0, row.length - st.line.length));
654
983
  // embed the marker at the cursor's display column
655
984
  let markerLine = "";
985
+ let w = 0;
656
986
  {
657
- let w = 0;
658
987
  let inserted = false;
659
988
  let i = 0;
660
989
  while (i < row.length) {
@@ -671,6 +1000,8 @@ export class Body {
671
1000
  inserted = true;
672
1001
  }
673
1002
  const cw = displayWidth(row[i]);
1003
+ if (w + cw > W - 4)
1004
+ break; // the cap — the two walls' columns
674
1005
  markerLine += row[i];
675
1006
  w += cw;
676
1007
  i += 1;
@@ -679,8 +1010,24 @@ export class Body {
679
1010
  markerLine += CURSOR_MARKER;
680
1011
  }
681
1012
  }
682
- const stripped = markerLine.replace(CURSOR_MARKER, "");
683
- const afterW = visibleWidth(markerLine.slice(markerLine.indexOf(CURSOR_MARKER) + CURSOR_MARKER.length));
1013
+ const stripped0 = markerLine.replace(CURSOR_MARKER, "");
1014
+ const tailW = visibleWidth(markerLine.slice(markerLine.indexOf(CURSOR_MARKER) + CURSOR_MARKER.length));
1015
+ if (W < 4) {
1016
+ // the degenerate screen: the box cannot hold its walls — the
1017
+ // bare row (the pre-W6 bytes; the fold probe's pass-through
1018
+ // line still crashes invariant ① downstream, as before)
1019
+ return { stripped: stripped0, afterW: tailW };
1020
+ }
1021
+ // the pad completes the row to W — the content stopped at W−4,
1022
+ // so the pad is ≥ 1
1023
+ const padW = W - 3 - w;
1024
+ const stripped = `\x1b[2m│ \x1b[0m${stripped0}\x1b[2m${" ".repeat(padW)}│\x1b[0m`;
1025
+ // the LEFT move = the width AFTER the cursor-rest cell — the
1026
+ // full-width row parks the terminal cursor at the LAST cell (not
1027
+ // one past the end), so the rest cell IS the first tail cell and
1028
+ // the move = the tail + the pad (the right wall rides inside the
1029
+ // pad's tail)
1030
+ const afterW = tailW + padW;
684
1031
  return { stripped, afterW };
685
1032
  }
686
1033
  /** The full-redraw path (the first frame, the resize repaint) — CUP
@@ -727,12 +1074,12 @@ export class Body {
727
1074
  for (let i = 0; i < menuRows.length; i += 1) {
728
1075
  out.push(`\x1b[${menuTop + i};1H\x1b[0K${this.#checked(menuRows[i], W)}`);
729
1076
  }
730
- // V6-3: the design §03 chrome — upper (H−3), input (H−2),
731
- // lower (H−1), status (H).
732
- out.push(`\x1b[${H - 3};1H\x1b[0K${footerLine(W)}`);
1077
+ // V6-3 + W6: the design §03 chrome — box top (H−3), input
1078
+ // (H−2), box bottom (H−1), status (H) — the box's four rows.
1079
+ out.push(`\x1b[${H - 3};1H\x1b[0K${boxTop(W)}`);
733
1080
  const editor = this.#inputRow(W, ctx);
734
1081
  out.push(`\x1b[${H - 2};1H\x1b[0K${this.#checked(editor.stripped, W)}`);
735
- out.push(`\x1b[${H - 1};1H\x1b[0K${footerLine(W)}`);
1082
+ out.push(`\x1b[${H - 1};1H\x1b[0K${boxBottom(W)}`);
736
1083
  out.push(`\x1b[${H};1H\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W, this.#statusHint ?? undefined), W)}`);
737
1084
  // the cursor: up two (the input row at H−2) + left to the marker
738
1085
  out.push("\x1b[2A");
@@ -756,12 +1103,13 @@ export class Body {
756
1103
  out.push("\x1b[2B");
757
1104
  for (let i = 0; i < committed.length; i += 1)
758
1105
  out.push("\n");
759
- // the bottom-up repaint, from the last row up — V6-3: the design
760
- // §03 chrome: status (H), lower (H−1), input (H−2), upper ╌ (H−3)
1106
+ // the bottom-up repaint, from the last row up — V6-3 + W6: the
1107
+ // design §03 chrome: status (H), box bottom (H−1), input (H−2),
1108
+ // box top (H−3)
761
1109
  out.push(`\x1b[1G\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W, this.#statusHint ?? undefined), W)}`); // H — the status
762
- out.push(`\x1b[1A\x1b[1G\x1b[0K${footerLine(W)}`); // H−1 — the lower
1110
+ out.push(`\x1b[1A\x1b[1G\x1b[0K${boxBottom(W)}`); // H−1 — the box bottom
763
1111
  out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(editor.stripped, W)}`); // H−2 — the input
764
- out.push(`\x1b[1A\x1b[1G\x1b[0K${footerLine(W)}`); // H−3 — the upper
1112
+ out.push(`\x1b[1A\x1b[1G\x1b[0K${boxTop(W)}`); // H−3 — the box top
765
1113
  for (let i = menuRows.length - 1; i >= 0; i -= 1) {
766
1114
  out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(menuRows[i], W)}`);
767
1115
  }
@@ -804,7 +1152,7 @@ export class Body {
804
1152
  // the down-distance from the LAST written row, in byte order: the
805
1153
  // committed band's bottom, then the stale ELs' bottom, then the gap
806
1154
  // ELs' bottom, then the live lines' bottom, then the menu's top
807
- // (its last marched row), else the chrome's upper ╌.
1155
+ // (its last marched row), else the chrome's box top.
808
1156
  const lastRow = committed.length > 0
809
1157
  ? Math.max(1, liveTop - 1)
810
1158
  : staleFrom < liveTop
package/dist/editor.d.ts CHANGED
@@ -41,6 +41,7 @@ export declare class Editor {
41
41
  onSigint(cb: () => void): void;
42
42
  onEot(cb: () => void): void;
43
43
  onEscape(cb: () => void): void;
44
+ onExpand(cb: () => void): void;
44
45
  /** The whole buffer as text (the CLI's line()/clearLine()). */
45
46
  line(): string;
46
47
  clearLine(): void;
package/dist/editor.js CHANGED
@@ -56,6 +56,11 @@ export class Editor {
56
56
  // (dispatch) coexist; a listener removes itself via an unarmed guard
57
57
  // (the compact's handler no-ops after its abort has fired).
58
58
  #escapeCbs = [];
59
+ // W15: the expand-key list (ctrl+r) — the CLI's dispatch decides the
60
+ // target (a live cell toggles in place; a committed cell appends the
61
+ // expanded block). Mirrors the escape list: multiple listeners can
62
+ // coexist; the editor never interprets the key itself.
63
+ #expandCbs = [];
59
64
  #onRender;
60
65
  #menuOpen = false; // v3 §04: the slash-command menu
61
66
  #menuSel = 0;
@@ -95,6 +100,9 @@ export class Editor {
95
100
  onEscape(cb) {
96
101
  this.#escapeCbs.push(cb);
97
102
  }
103
+ onExpand(cb) {
104
+ this.#expandCbs.push(cb);
105
+ }
98
106
  /** The whole buffer as text (the CLI's line()/clearLine()). */
99
107
  line() {
100
108
  return String.fromCodePoint(...this.#chars);
@@ -272,6 +280,13 @@ export class Editor {
272
280
  }
273
281
  i += 1;
274
282
  }
283
+ else if (c === "\x12") {
284
+ // W15: the expand key (ctrl+r) — rides the chain like a
285
+ // command, the editor just forwards it.
286
+ for (const cb of [...this.#expandCbs])
287
+ cb();
288
+ i += 1;
289
+ }
275
290
  else if (c !== undefined && c < " ") {
276
291
  i += 1; // other control — ignored
277
292
  }
@@ -455,7 +470,7 @@ export class Editor {
455
470
  // ---- width-based horizontal scroll ----
456
471
  #reflow() {
457
472
  const W = (process.stdout.columns ?? 0) || 80; // degenerate 0 falls back to 80
458
- const maxW = Math.max(1, W - PROMPT_WIDTH - 1); // 1 col for the "…"
473
+ const maxW = Math.max(1, W - PROMPT_WIDTH - 4); // W6: the box's walls (2+2) — the visible line fits the box's inner width; the "…" rides inside
459
474
  const curCol = widthOf(this.#chars.slice(0, this.#cursor));
460
475
  const scrolledW = widthOf(this.#chars.slice(0, this.#scroll));
461
476
  if (curCol < scrolledW) {
package/dist/render.d.ts CHANGED
@@ -177,7 +177,12 @@ export declare function renderEvent(ev: RenderInput, prevThinking?: boolean, res
177
177
  export declare function renderToolSummary(name: string, input: Record<string, unknown>, result: {
178
178
  content: string;
179
179
  isError: boolean;
180
- }): string;
180
+ }, reason?: string | null): string;
181
+ /** W15 — the expand header's target: the tool call's subject (the path
182
+ * for the *_file tools, the command for shell) — the same extraction
183
+ * the summary detail uses, WITHOUT the counts (the header names what
184
+ * was expanded, not its size). */
185
+ export declare function toolTarget(name: string, input: Record<string, unknown>): string;
181
186
  /** k-units for the status line: 12345 → 12.3k, 800 → 800, null → ?. */
182
187
  export declare function kUnit(value: number | null): string;
183
188
  /** B area: usage data gathered from the run's usage events. */
@@ -242,6 +247,11 @@ export interface RecapStats {
242
247
  readonly edits: number;
243
248
  readonly usage: RunUsage;
244
249
  readonly ctxLeftPct: number | null;
250
+ /** W19 — the mode the turn ran under. Under "plan" the recap becomes
251
+ * the way-forward row (the claimed shape): a plan turn's currency is
252
+ * the plan, not the tool count — the timing and tool-count parts
253
+ * drop, and the two /mode hints replace them. */
254
+ readonly mode?: string;
245
255
  }
246
256
  export declare function renderRecap(s: RecapStats): string;
247
257
  /** One-line summary of a session, for `kiso sessions`. */
package/dist/render.js CHANGED
@@ -189,9 +189,16 @@ function approvalDetail(name, input, resolvePath) {
189
189
  * edit/write show +/- line counts, read shows lines, shell shows the exit
190
190
  * code; failures (isError) are ✗. Pure and deterministic.
191
191
  */
192
- export function renderToolSummary(name, input, result) {
192
+ export function renderToolSummary(name, input, result, reason = null) {
193
193
  // v2a/v5: ✓ is a bold identity accent; ✗ stays red.
194
194
  const p = palette();
195
+ // W19: a DENIED call (the "denied" tag) renders the pinned row — the
196
+ // FULL call name, the target, the reason in the W4 parentheses idiom,
197
+ // and NO timing metadata (the call never ran — (0.0s) would be noise).
198
+ // The same row in the interactive and pipe paths, byte-clean on a pipe.
199
+ if (reason !== null) {
200
+ return `${p.red}✗${p.reset} ${escapeTerminal(`${name} ${toolTarget(name, input)} (${reason})`)}`;
201
+ }
195
202
  const mark = result.isError ? `${p.red}✗${p.reset}` : `${p.bold}✓${p.reset}`;
196
203
  const shortName = name.replace("_file", "");
197
204
  const detail = toolSummaryDetail(name, input, result);
@@ -233,6 +240,24 @@ function toolSummaryDetail(name, input, result) {
233
240
  return String(input.path ?? input.command ?? "");
234
241
  }
235
242
  }
243
+ /** W15 — the expand header's target: the tool call's subject (the path
244
+ * for the *_file tools, the command for shell) — the same extraction
245
+ * the summary detail uses, WITHOUT the counts (the header names what
246
+ * was expanded, not its size). */
247
+ export function toolTarget(name, input) {
248
+ switch (name) {
249
+ case "read_file":
250
+ case "write_file":
251
+ case "edit_file":
252
+ return String(input.path ?? "?");
253
+ case "shell":
254
+ return String(input.command ?? "?");
255
+ case "list_dir":
256
+ return String(input.path ?? "(root)");
257
+ default:
258
+ return String(input.path ?? input.command ?? "");
259
+ }
260
+ }
236
261
  /** The exit code of a shell result: parsed from the failure text, 0 on success. */
237
262
  function exitCodeOf(result) {
238
263
  if (!result.isError)
@@ -373,6 +398,15 @@ export function bannerLines(W, H, version, extensionsText, resume = [], now = Da
373
398
  }
374
399
  export function renderRecap(s) {
375
400
  const p = palette();
401
+ // W19: under plan the recap is the way out of the mode — the header
402
+ // names the mode's posture, the hints name the exits (the ONLY
403
+ // controls — /mode is the only way to leave plan mode).
404
+ if (s.mode === "plan") {
405
+ const parts = ["plan ready", "/mode default executes", "/mode accept-edits auto-approves edits"];
406
+ if (s.ctxLeftPct !== null)
407
+ parts.push(`ctx left ~${Math.round(s.ctxLeftPct)}%`);
408
+ return `${p.bold}▞${p.reset} ${parts.join(" · ")}\n`;
409
+ }
376
410
  const parts = [`${s.seconds}s`, `${s.tools} tool${s.tools === 1 ? "" : "s"}${s.edits > 0 ? ` (${s.edits} edit${s.edits === 1 ? "" : "s"})` : ""}`];
377
411
  if (s.usage.known) {
378
412
  const seg = `${s.usage.in !== null ? `in ${kUnit(s.usage.in)}` : ""}${s.usage.in !== null && s.usage.out !== null ? " " : ""}${s.usage.out !== null ? `out ${kUnit(s.usage.out)}` : ""}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.1.37",
3
+ "version": "0.1.38",
4
4
  "description": "kiso tui — the pure terminal layer (cell renderer, dock, raw editor, diff, palette). Zero runtime dependencies: input is data, output is bytes.",
5
5
  "type": "module",
6
6
  "license": "MIT",