@vincemakes/kiso-tui 0.1.36 → 0.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, cellComponent, foldLine, footerLine, statusLine, visibleWidth, } from "./components.js";
46
- import { 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,10 +81,24 @@ 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)
80
100
  #status = "";
101
+ #statusHint = null;
81
102
  #tail = "";
82
103
  #question = null;
83
104
  #inputState = () => ({ line: "", cursor: 0 });
@@ -118,7 +139,10 @@ export class Body {
118
139
  }
119
140
  this.#closeOpenThinking();
120
141
  this.#closeOpenText();
121
- 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 });
122
146
  this.#mark();
123
147
  }
124
148
  thinkingAppend(text) {
@@ -131,7 +155,7 @@ export class Body {
131
155
  last.text += text;
132
156
  }
133
157
  else {
134
- this.#cells.push({ kind: "thinking", text, done: false });
158
+ this.#cells.push({ kind: "thinking", text, done: false, turn: this.#turns.length - 1 });
135
159
  }
136
160
  this.#mark();
137
161
  }
@@ -154,8 +178,30 @@ export class Body {
154
178
  this.#write(`→ ${escapeTerminal(name)}(${escapeTerminal(JSON.stringify(input).slice(0, 200))})\n`);
155
179
  return;
156
180
  }
181
+ // W12: the cell carries the delegate's child roles from the FULL
182
+ // input — the display summary is sliced at 60 chars (unparseable);
183
+ // the roles are the only running-state data the parent holds (there
184
+ // is no live channel to a running child session).
185
+ const childRoles = [];
186
+ for (const t of Array.isArray(input.tasks) ? input.tasks : []) {
187
+ if (typeof t === "object" && t !== null && typeof t.role === "string") {
188
+ childRoles.push(t.role);
189
+ }
190
+ }
157
191
  this.#toolCells.set(callId, this.#cells.length);
158
- this.#cells.push({ kind: "tool", name, input: summary, 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
+ }
159
205
  this.#mark();
160
206
  }
161
207
  toolApproval(callId, diff) {
@@ -202,8 +248,11 @@ export class Body {
202
248
  this.#pendingCalls.delete(callId);
203
249
  }
204
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).
205
254
  const p = palette();
206
- this.#write(`${renderToolSummary(call?.name ?? "?", call?.input ?? {}, result)}\n` +
255
+ this.#write(`${renderToolSummary(call?.name ?? "?", call?.input ?? {}, result, result.reason ?? null)}\n` +
207
256
  `${p.dim}${result.isError ? p.red : p.dim} [result${result.isError ? " ✗" : ""}] ${foldResult(result.content)}${p.reset}\n`);
208
257
  return;
209
258
  }
@@ -213,6 +262,7 @@ export class Body {
213
262
  cell.state = "done";
214
263
  cell.isError = result.isError;
215
264
  cell.resultText = result.content;
265
+ cell.reason = result.reason ?? null;
216
266
  cell.doneAt = Date.now();
217
267
  cell.done = true;
218
268
  }
@@ -225,6 +275,12 @@ export class Body {
225
275
  this.#write(escapeTerminal(text));
226
276
  return;
227
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;
228
284
  const last = this.#cells[this.#cells.length - 1];
229
285
  if (last !== undefined && last.kind === "text" && !last.done) {
230
286
  last.text += text;
@@ -246,6 +302,51 @@ export class Body {
246
302
  last.done = true;
247
303
  this.#mark();
248
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
+ }
249
350
  terminal(label, statusLineText) {
250
351
  if (!this.#isActive()) {
251
352
  this.#closeOpenThinking();
@@ -270,6 +371,17 @@ export class Body {
270
371
  this.#cells.push({ kind: "notice", text, done: true });
271
372
  this.#mark();
272
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). */
273
385
  checklist(header, items) {
274
386
  if (!this.#isActive()) {
275
387
  this.#closeOpenThinking();
@@ -283,7 +395,49 @@ export class Body {
283
395
  }
284
396
  this.#closeOpenThinking();
285
397
  this.#closeOpenText();
286
- 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
+ });
417
+ this.#mark();
418
+ }
419
+ /** The startup banner (W1): a LIVE cell — the tier re-derives per
420
+ * frame (bannerLines with the CURRENT W and H), so a resize re-tiers
421
+ * the art instead of re-folding frozen rows (a window below 40 cols
422
+ * never paints the logo). W5: the resume metas ride the cell — the
423
+ * list re-gates with the tier (BIG only) and re-times with the
424
+ * frame. The inactive path keeps the historical bytes (no resume —
425
+ * the pipe contract). */
426
+ banner(version, extensionsText, resume = []) {
427
+ if (!this.#isActive()) {
428
+ this.#closeOpenThinking();
429
+ this.#closeOpenText();
430
+ const W = this.#opts.width() || 80; // a 0-size pty falls back
431
+ const H = this.#opts.height();
432
+ const p = palette();
433
+ for (const r of bannerLines(W, H, version, extensionsText))
434
+ this.#write(`${p.dim}${r}${p.reset}\n`);
435
+ this.#write("\n");
436
+ return;
437
+ }
438
+ this.#closeOpenThinking();
439
+ this.#closeOpenText();
440
+ this.#cells.push({ kind: "banner", version, extensionsText, resume, done: true });
287
441
  this.#mark();
288
442
  }
289
443
  raw(lines) {
@@ -307,6 +461,77 @@ export class Body {
307
461
  lastTool() {
308
462
  return this.#lastTool;
309
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
+ }
310
535
  // ---- the Dock façade (the CLI's chrome API — same shape as the old dock) ----
311
536
  /** Docked = the chrome is live (a color TTY with a real size). */
312
537
  get active() {
@@ -360,8 +585,12 @@ export class Body {
360
585
  this.#dirty = true;
361
586
  this.render(); // the immediate redraw at the NEW geometry
362
587
  }
363
- setStatus(text) {
588
+ /** W18: the status row's right-aligned hint is part of the status
589
+ * state — the compacting row passes "esc to cancel" (the affordance
590
+ * must survive repaints). */
591
+ setStatus(text, hint = null) {
364
592
  this.#status = text;
593
+ this.#statusHint = hint;
365
594
  this.redraw();
366
595
  }
367
596
  setTail(tail) {
@@ -392,7 +621,8 @@ export class Body {
392
621
  * reads it — the marker math never desyncs by construction). */
393
622
  editCol() {
394
623
  const inp = this.#inputState();
395
- 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;
396
626
  }
397
627
  /** The old dock's redraw — the editor's onRender target: mark + the
398
628
  * scheduler (16ms coalescing — the old sync draw coalesces the same). */
@@ -452,19 +682,29 @@ export class Body {
452
682
  }
453
683
  // ---- the one writer ----
454
684
  /** The live region's scalar — the unit tests assert the cap directly
455
- * (the e2e gate pins the screen consequence). */
685
+ * (the e2e gate pins the screen consequence). W11: the formula's
686
+ * blanks are join artifacts — the count includes them (they are real
687
+ * screen rows), threaded against the previous sibling's OWN rows. */
456
688
  liveCount() {
457
689
  const live = this.#cells.slice(this.#committed);
458
- const ctx = { spinnerI: this.#spinnerI, now: Date.now() };
690
+ const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: this.#opts.height() };
459
691
  const W = this.#opts.width();
460
692
  let lines = 0;
461
- for (const cell of live)
462
- lines += cellComponent(cell).render(W, ctx).length;
693
+ let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
694
+ for (const cell of live) {
695
+ const rows = cellComponent(cell).render(W, ctx);
696
+ lines += bodySpacing(prev, rows).length;
697
+ prev = rows;
698
+ }
463
699
  return lines + CHROME_ROWS + this.#menuRows(W).length;
464
700
  }
465
701
  /** The lines committed THIS frame — the writes land in the frame's
466
702
  * committed section (the rows just above the live region). */
467
703
  #committedLinesThisFrame = [];
704
+ // the CELL count when the frame began — the drawFull frozen bound's
705
+ // unit (the cells committed BEFORE this frame; a force-commit frame's
706
+ // placed LINES outnumber its cells)
707
+ #committedAtFrameStart = 0;
468
708
  render() {
469
709
  if (!this.#isActive())
470
710
  return;
@@ -473,7 +713,7 @@ export class Body {
473
713
  if (H < 4)
474
714
  return;
475
715
  this.#lastH = H;
476
- const ctx = { spinnerI: this.#spinnerI, now: Date.now() };
716
+ const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: H };
477
717
  // V6-1 (the screen-state == frame-state rule): the resize's first
478
718
  // frame — the terminal's reflow re-wrapped the committed content at
479
719
  // the NEW width, so the cached folds are stale. Re-fold the
@@ -485,8 +725,8 @@ export class Body {
485
725
  for (let i = 0; i < this.#committed; i += 1) {
486
726
  const cell = this.#cells[i];
487
727
  const lines = cellComponent(cell).render(W, ctx);
488
- this.#lineCache[i] = lines;
489
- this.#committedLines += lines.length;
728
+ this.#lineCache[i] = lines; // the cell's OWN rows — the cache stays raw
729
+ this.#committedLines += bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, lines).length;
490
730
  }
491
731
  }
492
732
  // 1. the natural commits — the leading DONE cells freeze: their
@@ -495,16 +735,30 @@ export class Body {
495
735
  // short sessions included — the frame coalescing keeps a
496
736
  // cell's first frame its freeze frame, so the frozen bytes
497
737
  // emit exactly once).
738
+ this.#committedAtFrameStart = this.#committed;
498
739
  this.#committedLinesThisFrame = [];
499
- 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)) {
500
747
  this.#commitCell(this.#committed, W, ctx);
501
748
  }
502
749
  // 2. the live lines — the unfinished cells (the tail) + the chrome.
750
+ // W11: the formula's blank above the first live cell hangs off
751
+ // the last COMMITTED sibling (the join spans the boundary).
503
752
  const menuRows = this.#menuRows(W);
504
753
  const chromeRows = CHROME_ROWS + menuRows.length;
505
754
  let liveLines = [];
506
- for (const cell of this.#cells.slice(this.#committed)) {
507
- liveLines.push(...cellComponent(cell).render(W, ctx));
755
+ {
756
+ let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
757
+ for (const cell of this.#cells.slice(this.#committed)) {
758
+ const rows = cellComponent(cell).render(W, ctx);
759
+ liveLines.push(...bodySpacing(prev, rows));
760
+ prev = rows;
761
+ }
508
762
  }
509
763
  // 3. the FORCE commits — the live region's hard cap H−1: overflow
510
764
  // commits the oldest live cell UNCONDITIONALLY (the one sharp
@@ -512,8 +766,13 @@ export class Body {
512
766
  while (liveLines.length > H - 4 && this.#committed < this.#cells.length) { // V6-3: the content cap H−4
513
767
  this.#commitCell(this.#committed, W, ctx);
514
768
  liveLines = [];
515
- for (const cell of this.#cells.slice(this.#committed)) {
516
- liveLines.push(...cellComponent(cell).render(W, ctx));
769
+ {
770
+ let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
771
+ for (const cell of this.#cells.slice(this.#committed)) {
772
+ const rows = cellComponent(cell).render(W, ctx);
773
+ liveLines.push(...bodySpacing(prev, rows));
774
+ prev = rows;
775
+ }
517
776
  }
518
777
  }
519
778
  // 4. the geometry — the live region's first row:
@@ -539,14 +798,142 @@ export class Body {
539
798
  /** Commit the cell at index i: render + cache its lines (immutable —
540
799
  * the force-committed form freezes at the current render), advance
541
800
  * the bookkeeping — and collect the lines for this frame's writes.
542
- * Pure accounting + the write list; the BYTES emit in the frame. */
801
+ * Pure accounting + the write list; the BYTES emit in the frame.
802
+ * W11: the formula's blank above the cell (when one belongs) rides
803
+ * this cell's commit — the cache stays raw, the placed rows count. */
543
804
  #commitCell(i, W, ctx) {
544
805
  const cell = this.#cells[i];
545
- 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);
546
816
  this.#lineCache[i] = lines;
817
+ const placed = bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, lines);
547
818
  this.#committed += 1;
548
- this.#committedLines += lines.length;
549
- this.#committedLinesThisFrame.push(...lines);
819
+ this.#committedLines += placed.length;
820
+ this.#committedLinesThisFrame.push(...placed);
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);
550
937
  }
551
938
  /** The slot occupant's extra rows — the slash-command menu (above the
552
939
  * status, in the rhythm gap + the content's spare rows — the old
@@ -570,7 +957,14 @@ export class Body {
570
957
  * cursor's display column WITHIN THE ROW (the brick/question lead
571
958
  * included), the question/editor/menu variants. The compositor
572
959
  * strips the marker and moves LEFT by the trailing width — the
573
- * 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. */
574
968
  #inputRow(W, _ctx) {
575
969
  const st = this.#inputState();
576
970
  let row;
@@ -588,8 +982,8 @@ export class Body {
588
982
  const leadW = visibleWidth(row.slice(0, row.length - st.line.length));
589
983
  // embed the marker at the cursor's display column
590
984
  let markerLine = "";
985
+ let w = 0;
591
986
  {
592
- let w = 0;
593
987
  let inserted = false;
594
988
  let i = 0;
595
989
  while (i < row.length) {
@@ -606,6 +1000,8 @@ export class Body {
606
1000
  inserted = true;
607
1001
  }
608
1002
  const cw = displayWidth(row[i]);
1003
+ if (w + cw > W - 4)
1004
+ break; // the cap — the two walls' columns
609
1005
  markerLine += row[i];
610
1006
  w += cw;
611
1007
  i += 1;
@@ -614,8 +1010,24 @@ export class Body {
614
1010
  markerLine += CURSOR_MARKER;
615
1011
  }
616
1012
  }
617
- const stripped = markerLine.replace(CURSOR_MARKER, "");
618
- 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;
619
1031
  return { stripped, afterW };
620
1032
  }
621
1033
  /** The full-redraw path (the first frame, the resize repaint) — CUP
@@ -633,20 +1045,24 @@ export class Body {
633
1045
  const committed = this.#committedLinesThisFrame;
634
1046
  // 0. the FROZEN rows — the re-folded committed content (re-flowed
635
1047
  // at the new width by the terminal): re-painted at [1..frozen],
636
- // so the reflow's shifted copies can never ghost.
637
- const frozen = this.#lineCache.slice(0, this.#committed - committed.length).flat().filter((l) => l !== null);
638
- let r = 1;
639
- for (const line of frozen) {
640
- out.push(`\x1b[${r};1H\x1b[0K${this.#checked(line, W)}`);
641
- r += 1;
1048
+ // so the reflow's shifted copies can never ghost. W11: the
1049
+ // formula's blanks between the cells are re-inserted here the
1050
+ // cache holds each cell's OWN rows; a missing blank would paint
1051
+ // every row below it one row too high (the V6-1 idempotence
1052
+ // rule: this draw must reproduce the screen, row by row).
1053
+ // The bound is the CELL count at the frame's start — the force
1054
+ // commit's placed LINES can exceed the cell count, and the old
1055
+ // lines-bound went negative, skipping every previously-committed
1056
+ // cell (the V6-1 frozen-loop finding — the banner vanished).
1057
+ const frozen = [];
1058
+ for (let i = 0; i < this.#committedAtFrameStart; i += 1) {
1059
+ frozen.push(...bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, this.#lineCache[i]));
642
1060
  }
643
- // 1. the committed lines (this frame's).
644
- for (const line of committed) {
645
- out.push(`\x1b[${r};1H\x1b[0K${this.#checked(line, W)}`);
646
- r += 1;
647
- }
648
- // 2. the live lines.
649
- for (const line of liveLines) {
1061
+ let r = 1;
1062
+ // one march the frozen, the committed (this frame's), the live —
1063
+ // in write order, r monotone (three byte-identical loops merged for
1064
+ // the gate; the exact write sequence preserved)
1065
+ for (const line of [...frozen, ...committed, ...liveLines]) {
650
1066
  out.push(`\x1b[${r};1H\x1b[0K${this.#checked(line, W)}`);
651
1067
  r += 1;
652
1068
  }
@@ -658,13 +1074,13 @@ export class Body {
658
1074
  for (let i = 0; i < menuRows.length; i += 1) {
659
1075
  out.push(`\x1b[${menuTop + i};1H\x1b[0K${this.#checked(menuRows[i], W)}`);
660
1076
  }
661
- // V6-3: the design §03 chrome — upper (H−3), input (H−2),
662
- // lower (H−1), status (H).
663
- 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)}`);
664
1080
  const editor = this.#inputRow(W, ctx);
665
1081
  out.push(`\x1b[${H - 2};1H\x1b[0K${this.#checked(editor.stripped, W)}`);
666
- out.push(`\x1b[${H - 1};1H\x1b[0K${footerLine(W)}`);
667
- out.push(`\x1b[${H};1H\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W), W)}`);
1082
+ out.push(`\x1b[${H - 1};1H\x1b[0K${boxBottom(W)}`);
1083
+ out.push(`\x1b[${H};1H\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W, this.#statusHint ?? undefined), W)}`);
668
1084
  // the cursor: up two (the input row at H−2) + left to the marker
669
1085
  out.push("\x1b[2A");
670
1086
  if (editor.afterW > 0)
@@ -687,12 +1103,13 @@ export class Body {
687
1103
  out.push("\x1b[2B");
688
1104
  for (let i = 0; i < committed.length; i += 1)
689
1105
  out.push("\n");
690
- // the bottom-up repaint, from the last row up — V6-3: the design
691
- // §03 chrome: status (H), lower (H−1), input (H−2), upper ╌ (H−3)
692
- out.push(`\x1b[1G\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W), W)}`); // H — the status
693
- out.push(`\x1b[1A\x1b[1G\x1b[0K${footerLine(W)}`); // H−1 — the lower ╌
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)
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
1110
+ out.push(`\x1b[1A\x1b[1G\x1b[0K${boxBottom(W)}`); // H−1 — the box bottom
694
1111
  out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(editor.stripped, W)}`); // H−2 — the input
695
- 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
696
1113
  for (let i = menuRows.length - 1; i >= 0; i -= 1) {
697
1114
  out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(menuRows[i], W)}`);
698
1115
  }
@@ -735,7 +1152,7 @@ export class Body {
735
1152
  // the down-distance from the LAST written row, in byte order: the
736
1153
  // committed band's bottom, then the stale ELs' bottom, then the gap
737
1154
  // ELs' bottom, then the live lines' bottom, then the menu's top
738
- // (its last marched row), else the chrome's upper ╌.
1155
+ // (its last marched row), else the chrome's box top.
739
1156
  const lastRow = committed.length > 0
740
1157
  ? Math.max(1, liveTop - 1)
741
1158
  : staleFrom < liveTop
@@ -806,8 +1223,8 @@ export class Dock {
806
1223
  onResize() {
807
1224
  compositorRef?.onResize();
808
1225
  }
809
- setStatus(text) {
810
- compositorRef?.setStatus(text);
1226
+ setStatus(text, hint) {
1227
+ compositorRef?.setStatus(text, hint ?? null);
811
1228
  }
812
1229
  setTail(tail) {
813
1230
  compositorRef?.setTail(tail);