@vincemakes/kiso-tui 0.1.37 → 0.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 task block only
56
+ * redraws when the items actually changed (the task extension's
57
+ * idempotent shape — an unchanged replace is a no-op, no frame). */
58
+ function sameTask(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 task block settles HERE — the ONE recap
322
+ // block for the turn ("`task 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 task 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 task 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 (!sameTask(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 task 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
@@ -11,8 +11,9 @@
11
11
  * v2a — the palette, centralized (no hard-coded codes elsewhere); v5
12
12
  * (TUI v5 #16e, the v4.1 design): the decorative blue (38;5;75) is
13
13
  * RETIRED — the identity accents (the you> prompt, the banner tagline,
14
- * ✓ marks, slash-command names, the ▍ user rail, the input brick) are
15
- * bright-white BOLD (SGR 1); `code` is the content semantic tint for
14
+ * ✓ marks, slash-command names, the input brick) are bright-white BOLD
15
+ * (SGR 1); the user message is the SGR-7 chip (the 2026-08-09 ruling
16
+ * retired the ▍ rail); `code` is the content semantic tint for
16
17
  * inline code spans in assistant text (256-color 110 — the cube color
17
18
  * nearest the design's #8fb4d8); red for errors, dim for metadata,
18
19
  * green for the diff additions. NO_COLOR set, or a non-TTY output →
@@ -177,7 +178,12 @@ export declare function renderEvent(ev: RenderInput, prevThinking?: boolean, res
177
178
  export declare function renderToolSummary(name: string, input: Record<string, unknown>, result: {
178
179
  content: string;
179
180
  isError: boolean;
180
- }): string;
181
+ }, reason?: string | null): string;
182
+ /** W15 — the expand header's target: the tool call's subject (the path
183
+ * for the *_file tools, the command for shell) — the same extraction
184
+ * the summary detail uses, WITHOUT the counts (the header names what
185
+ * was expanded, not its size). */
186
+ export declare function toolTarget(name: string, input: Record<string, unknown>): string;
181
187
  /** k-units for the status line: 12345 → 12.3k, 800 → 800, null → ?. */
182
188
  export declare function kUnit(value: number | null): string;
183
189
  /** B area: usage data gathered from the run's usage events. */
@@ -242,6 +248,11 @@ export interface RecapStats {
242
248
  readonly edits: number;
243
249
  readonly usage: RunUsage;
244
250
  readonly ctxLeftPct: number | null;
251
+ /** W19 — the mode the turn ran under. Under "plan" the recap becomes
252
+ * the way-forward row (the claimed shape): a plan turn's currency is
253
+ * the plan, not the tool count — the timing and tool-count parts
254
+ * drop, and the two /mode hints replace them. */
255
+ readonly mode?: string;
245
256
  }
246
257
  export declare function renderRecap(s: RecapStats): string;
247
258
  /** One-line summary of a session, for `kiso sessions`. */