@vincemakes/kiso-tui 0.16.5 → 0.16.7

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.
@@ -2,4 +2,5 @@
2
2
  * Amendment 4) — this module is the re-export shim: the compositor's
3
3
  * and index.ts's imports (./components.js) stay verbatim. */
4
4
  export * from "@vincemakes/kiso-tui-cells/components";
5
+ export { foldTerms } from "@vincemakes/kiso-tui-cells/components";
5
6
  export { MOTION_FRAMES, TWINKLE, breathFrame, twinkleFrame } from "@vincemakes/kiso-tui-cells/render";
@@ -4,4 +4,5 @@
4
4
  export * from "@vincemakes/kiso-tui-cells/components";
5
5
  // R3 (design §5.2): the two motion cycles reach the compositor through
6
6
  // the same shim every other cell primitive does.
7
+ export { foldTerms } from "@vincemakes/kiso-tui-cells/components";
7
8
  export { MOTION_FRAMES, TWINKLE, breathFrame, twinkleFrame } from "@vincemakes/kiso-tui-cells/render";
@@ -52,7 +52,7 @@ import { MOUSE_OFF } from "./editor.js";
52
52
  import { atPanelRows, bandHeader } from "./at-picker.js";
53
53
  // TUI2-R2 ②: the session picker's rows — the band's third occupant.
54
54
  import { sessionPickerRows } from "./session-picker.js";
55
- import { Container, ROLLUP_NOUN, MOTION_FRAMES, MdStream, bodySpacing, boxBottom, boxTop, cellComponent, exploreCounts, focusToken, exploreRows, foldLine, isExploreTool, pendingQueueRows, statusLine, turnFold, visibleWidth, } from "./components.js";
55
+ import { Container, ROLLUP_NOUN, MOTION_FRAMES, MdStream, bodySpacing, boxBottom, boxTop, cellComponent, exploreCounts, foldTerms, focusToken, exploreRows, foldLine, isExploreTool, pendingQueueRows, statusLine, turnFold, visibleWidth, } from "./components.js";
56
56
  import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, toolTarget } from "./render.js";
57
57
  import { displayVerb, keysSheetRows } from "./strings.js";
58
58
  /** The cursor marker — an APC private sequence the focus component
@@ -69,6 +69,123 @@ const NOT_PAINTED = "\u0000never";
69
69
  * that a single resize still feels immediate. */
70
70
  const RESIZE_SETTLE_MS = 80;
71
71
  const CHROME_ROWS = 4; // box top + input + box bottom + status — the design §03 chrome (V6-3; the box is W6)
72
+ /** W13 / TUI2-R1 (B) — a rolled run's TITLE: the exploration sentence on
73
+ * a mixed run, W13's verb+count on a single-name one. */
74
+ function rolledTitle(cell) {
75
+ const r = cell.rolled;
76
+ if (r.parts !== undefined)
77
+ return `explored ${exploreCounts(r.parts)}`;
78
+ return `${displayVerb(cell.name)} ${r.count} ${ROLLUP_NOUN[cell.name] ?? "calls"}`;
79
+ }
80
+ /** W13 / TUI2-R1 (B) — a rolled run's DETAIL rows: one row per tool with
81
+ * its subjects on a mixed run, one `└ target` per call on a single-name
82
+ * one.
83
+ *
84
+ * Extracted at R3b so the segment fold's expansion opens a run to the
85
+ * SAME rows `ctrl+r` on the run itself would have opened. Two copies of
86
+ * this would be two answers to "show me that run". */
87
+ function rolledDetail(cell, W) {
88
+ const p = palette();
89
+ const r = cell.rolled;
90
+ if (r.parts !== undefined)
91
+ return exploreRows(r.parts, W);
92
+ return r.targets.map((t) => ` ${p.dim}└ ${escapeTerminal(t)}${p.reset}`);
93
+ }
94
+ /**
95
+ * W13 / TUI2-R1 (B) — the rollup's own projection of a run: the count,
96
+ * the lines, the elapsed, the targets, and — on a MIXED run only — the
97
+ * per-tool parts. A single-name run keeps W13's row byte for byte,
98
+ * which is the "the generalization adds, it never rewrites" rule.
99
+ *
100
+ * Extracted at R3b because the segment fold's EXPANSION renders the run
101
+ * through this same projection rather than reimplementing it — so the
102
+ * expanded rows cannot drift from the ones the commit path would have
103
+ * drawn.
104
+ */
105
+ function rolledOf(members) {
106
+ let total = 0;
107
+ const targets = [];
108
+ for (const m of members) {
109
+ // the lines count, excluding the tool's OWN truncation note
110
+ // (read_file's "… N more lines") — the per-cell meta's rule
111
+ const noteAt = m.resultText.lastIndexOf("\n… ");
112
+ const shown = noteAt >= 0 ? m.resultText.slice(0, noteAt) : m.resultText;
113
+ const rows = shown.split("\n");
114
+ total += rows[rows.length - 1] === "" ? rows.length - 1 : rows.length;
115
+ let input = {};
116
+ try {
117
+ input = JSON.parse(m.inputFull);
118
+ }
119
+ catch {
120
+ // the full JSON is always parseable (stringified at toolStart)
121
+ }
122
+ const target = toolTarget(m.name, input);
123
+ targets.push(target.split("/").pop() ?? target);
124
+ }
125
+ const parts = exploreParts(members);
126
+ const first = members[0];
127
+ const last = members[members.length - 1];
128
+ const elapsed = first.startedAt !== null && last.doneAt !== null ? ((last.doneAt - first.startedAt) / 1000).toFixed(1) : "?";
129
+ return { count: members.length, lines: total, elapsed, targets, ...(parts.length > 1 ? { parts } : {}) };
130
+ }
131
+ /**
132
+ * TUI2-R1 (B) / R3b — the per-tool parts of an explore run, in
133
+ * first-call order. A search's subject is the PATTERN it looked for
134
+ * (quoted); a read's or a list's is the path it named.
135
+ *
136
+ * Extracted at R3b because TWO paths need it now: the commit-time
137
+ * rollup, which has always built it, and the segment fold's EXPANSION,
138
+ * which shows the rollup's rows rather than one row per call. Two
139
+ * copies of this would be two answers to "what did that run do".
140
+ */
141
+ function exploreParts(members) {
142
+ const parts = [];
143
+ for (const m of members) {
144
+ let input = {};
145
+ try {
146
+ input = JSON.parse(m.inputFull);
147
+ }
148
+ catch {
149
+ // the full JSON is always parseable (stringified at toolStart)
150
+ }
151
+ const target = toolTarget(m.name, input);
152
+ const subject = m.name === "search_text" ? `"${String(input.pattern ?? "")}"` : target;
153
+ const part = parts.find((x) => x.name === m.name);
154
+ if (part === undefined)
155
+ parts.push({ name: m.name, subjects: [subject] });
156
+ else
157
+ part.subjects.push(subject);
158
+ }
159
+ return parts;
160
+ }
161
+ /** R3b — a segment's terms, for the expand header. The fold line's own
162
+ * wording comes from `turnFold`; this is the same facts in the header
163
+ * idiom the other expands use. */
164
+ function foldMeta(seg) {
165
+ const parts = foldTerms(seg.reads, seg.edits, [...seg.others]);
166
+ return parts.length === 0 ? "thinking" : parts.join(" · ");
167
+ }
168
+ /** R3b — the turn's open segment, opened on demand at the first cell of
169
+ * work that follows a text block (or the turn's start). Returns null
170
+ * only when there is no turn at all, which is the pipe path's shape. */
171
+ function openSegment(turn, now) {
172
+ if (turn === undefined)
173
+ return null;
174
+ const last = turn.segments[turn.segments.length - 1];
175
+ if (last !== undefined && last.closedAt === null)
176
+ return last;
177
+ const fresh = { openedAt: now, closedAt: null, reads: 0, edits: 0, others: new Map(), folded: false, spilled: false, headCell: null, cells: [] };
178
+ turn.segments.push(fresh);
179
+ return fresh;
180
+ }
181
+ /** R3b — close the turn's open segment, if it has one. Idempotent: text
182
+ * arriving twice in a row closes nothing the second time, which is what
183
+ * keeps a zero-cell segment from ever existing. */
184
+ function closeSegment(turn, now) {
185
+ const last = turn?.segments[turn.segments.length - 1];
186
+ if (last !== undefined && last.closedAt === null)
187
+ last.closedAt = now;
188
+ }
72
189
  /** W20 — the whole-table-replace comparison: the live task block only
73
190
  * redraws when the items actually changed (the task extension's
74
191
  * idempotent shape — an unchanged replace is a no-op, no frame). */
@@ -163,6 +280,11 @@ export class Body {
163
280
  #lastThinking = null;
164
281
  #lastTool = null;
165
282
  #pendingCalls = new Map();
283
+ /** R3b — cell index → the index of the segment it belongs to, for
284
+ * thinking/tool cells; -1 for every other kind. Parallel to #cells,
285
+ * because a segment is the COMPOSITOR's bookkeeping and does not
286
+ * belong on the cell type the renderer sees. */
287
+ #cellSegment = [];
166
288
  #pipeBuf = ""; // the passthrough's thinking buffer
167
289
  /** TUI2-MD ⑤ — the markdown scanner of the message currently
168
290
  * streaming, and the cell index its first block landed at. Null
@@ -320,7 +442,7 @@ export class Body {
320
442
  // W14: the turn boundary — the record the fold-hold's release
321
443
  // state machine reads; the cell carries the record's index. A9:
322
444
  // the user's own words ride the record — the fold's leading chip.
323
- this.#turns.push({ ended: false, hasText: false, thoughtSeconds: 0, reads: 0, edits: 0, others: new Map(), words: text, folded: false });
445
+ this.#turns.push({ ended: false, hasText: false, thoughtSeconds: 0, reads: 0, edits: 0, others: new Map(), words: text, folded: false, segments: [] });
324
446
  this.#cells.push({ kind: "user", text, done: true, turn: this.#turns.length - 1 });
325
447
  this.#mark();
326
448
  }
@@ -335,6 +457,17 @@ export class Body {
335
457
  }
336
458
  else {
337
459
  this.#cells.push({ kind: "thinking", text, done: false, turn: this.#turns.length - 1 });
460
+ // R3b: thinking is WORK, so it opens a segment too — a turn that
461
+ // thinks, speaks, then thinks again has two segments, and the
462
+ // second one's clock starts here rather than at a tool call it
463
+ // may never make.
464
+ //
465
+ // OPEN then STAMP, in that order: the stamp records the segment
466
+ // the cell belongs to, and a stamp taken first records the
467
+ // PREVIOUS segment (or none at all) — which left the thinking
468
+ // row standing outside the fold it should have led.
469
+ openSegment(this.#turns[this.#turns.length - 1], Date.now());
470
+ this.#stampSegment();
338
471
  }
339
472
  this.#mark();
340
473
  }
@@ -393,7 +526,19 @@ export class Body {
393
526
  turn.edits += 1;
394
527
  else
395
528
  turn.others.set(name, (turn.others.get(name) ?? 0) + 1);
529
+ // R3b: and into the SEGMENT, which opens here when this is the
530
+ // first work since the last text block.
531
+ const seg = openSegment(turn, Date.now());
532
+ if (seg !== null) {
533
+ if (name === "read_file")
534
+ seg.reads += 1;
535
+ else if (name === "edit_file")
536
+ seg.edits += 1;
537
+ else
538
+ seg.others.set(name, (seg.others.get(name) ?? 0) + 1);
539
+ }
396
540
  }
541
+ this.#stampSegment();
397
542
  this.#mark();
398
543
  }
399
544
  toolApproval(callId, diff) {
@@ -515,6 +660,11 @@ export class Body {
515
660
  const turn = this.#turns[this.#turns.length - 1];
516
661
  if (turn !== undefined)
517
662
  turn.hasText = true;
663
+ // R3b: text CLOSES the open segment. This is the boundary design.md
664
+ // §8 names — "folding at every text boundary changes what commits
665
+ // and when" — and it is the whole mechanism: a segment is what sits
666
+ // between two of these.
667
+ closeSegment(turn, Date.now());
518
668
  // TUI2-MD ⑤: assistant body text is MARKDOWN, scanned as it
519
669
  // streams. The scanner yields CLOSED blocks (final source, final
520
670
  // render) and one OPEN tail block; each becomes a cell, and the
@@ -598,6 +748,9 @@ export class Body {
598
748
  return;
599
749
  turn.ended = true;
600
750
  turn.thoughtSeconds = thoughtSeconds;
751
+ // R3b: the settle closes the last open segment — the turn's end is
752
+ // a boundary exactly as a text block is.
753
+ closeSegment(turn, Date.now());
601
754
  // W20: the turn's live task block settles HERE — the ONE recap
602
755
  // block for the turn ("`task done · N items · <duration>", the
603
756
  // duration clocked compositor-side from the block's first call —
@@ -612,6 +765,25 @@ export class Body {
612
765
  break;
613
766
  }
614
767
  }
768
+ // R3g (fable D3, 2026-08-28): an INTERRUPTED tool never receives a
769
+ // result, so its cell stays `done: false` — and the commit loop
770
+ // stops at the first cell that is not done. One esc mid-tool
771
+ // therefore parked the commit pointer for the REST of the
772
+ // session: every later turn's rows piled up in the live region
773
+ // and only ever left it through the force-commit cap. The turn's
774
+ // end is the boundary that closes them, exactly as it closes an
775
+ // open thinking cell. `reason` is set so the row keeps its words
776
+ // AND so #segmentHasTrouble holds the turn unfolded — an
777
+ // interruption is trouble, and law 1.3 says trouble is never
778
+ // summarised away.
779
+ for (const c of this.#cells) {
780
+ if (c.kind === "tool" && !c.done) {
781
+ c.state = "done";
782
+ c.reason = "interrupted";
783
+ c.doneAt = Date.now();
784
+ c.done = true;
785
+ }
786
+ }
615
787
  // the QUIET turn: an open thinking cell closes at the boundary —
616
788
  // its natural closer is the text's arrival (never comes here — the
617
789
  // text-less turn), so without this the fold could never commit AT
@@ -801,6 +973,109 @@ export class Body {
801
973
  const idx = this.#collapsed[this.#expandPtr % this.#collapsed.length];
802
974
  this.#expandPtr += 1;
803
975
  const cell = this.#cells[idx];
976
+ // R3b — a folded SEGMENT expands to the work it stands for.
977
+ //
978
+ // The fold line collapses a run of thinking and tool cells into
979
+ // one row; without this the run would be unreachable, which is
980
+ // hiding a durable record behind a summary. The rows are APPENDED
981
+ // (ADR-0046 — history is never rewritten), exactly as every other
982
+ // expand in this method does, and they are the cells' OWN renders,
983
+ // so the expansion cannot drift from what was folded.
984
+ const seg = this.#segmentOf(idx);
985
+ const foldTurn = (cell.kind === "thinking" || cell.kind === "tool") && cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
986
+ if (seg !== null && foldTurn !== undefined && seg.headCell === idx) {
987
+ const p = palette();
988
+ const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
989
+ const back = `${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
990
+ const W = this.#opts.width();
991
+ const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: this.#opts.height() };
992
+ // R3b (owner ruling): the ROLLUP is the expansion. TUI2-R1 built
993
+ // a richer projection of an explore run than a fold line can
994
+ // carry — the per-tool counts, and one row per tool with its
995
+ // subjects — and the segment fold would have retired it by
996
+ // simply arriving first. So the run's own rows are what the key
997
+ // opens: explore tools group the way the rollup groups them,
998
+ // everything else renders as itself.
999
+ // The segment's cells IN ORDER, with consecutive explore tools
1000
+ // grouped exactly as the rollup groups them — a write, a shell
1001
+ // or anything else BREAKS the run, which is TUI2-R1's own rule
1002
+ // and the reason two explore runs on either side of a write
1003
+ // stay two runs. Merging every explore tool of the segment
1004
+ // would have been simpler and would have quietly deleted that
1005
+ // rule.
1006
+ const rows = [];
1007
+ let run = [];
1008
+ const flush = () => {
1009
+ if (run.length === 0)
1010
+ return;
1011
+ // the same threshold the commit-time rollup uses: below it a
1012
+ // "run" is just some rows
1013
+ if (run.length > 2) {
1014
+ // the run renders through the ROLLUP's own projection —
1015
+ // literally the same function the commit path uses — so
1016
+ // a single-name run keeps W13's row and a mixed one gets
1017
+ // the exploration line, exactly as they would have if the
1018
+ // segment had never folded.
1019
+ const head = run[0];
1020
+ const saved = head.rolled;
1021
+ head.rolled = rolledOf(run);
1022
+ // the run OPENS. The fold's key already asked to see the
1023
+ // work, so what lands is the same rows `ctrl+r` on the
1024
+ // run itself would have opened — its title, then its
1025
+ // detail — never its collapsed row, which would make the
1026
+ // reader press a second time for what the first press
1027
+ // was for.
1028
+ rows.push(` ${p.dim}${escapeTerminal(rolledTitle(head))}${p.reset}`);
1029
+ rows.push(...rolledDetail(head, W));
1030
+ head.rolled = saved;
1031
+ }
1032
+ else {
1033
+ for (const c of run)
1034
+ rows.push(...cellComponent(c).render(W, ctx));
1035
+ }
1036
+ run = [];
1037
+ };
1038
+ // R3f — the expansion covers the WHOLE TURN, every segment.
1039
+ //
1040
+ // R3d moved the fold to the turn while the expansion kept
1041
+ // walking one segment, so a turn that spoke between calls
1042
+ // folded to a line claiming `3 reads · 1 edit · 1 shell` whose
1043
+ // key opened only the reads: the edit and the shell were on no
1044
+ // surface and reachable by no key. That is the one thing this
1045
+ // round's own first gate forbids — the work is never
1046
+ // unreachable — and it is worse than never folding, because the
1047
+ // line names work it then withholds.
1048
+ //
1049
+ // A run still BREAKS at a non-explore cell, so the segment
1050
+ // boundaries survive where they carry meaning (the write that
1051
+ // splits two explore runs); they simply no longer bound what
1052
+ // the key can reach.
1053
+ for (const j of foldTurn.segments.flatMap((sg) => sg.cells).sort((a, b) => a - b)) {
1054
+ if (j < idx)
1055
+ continue;
1056
+ const c = this.#cells[j];
1057
+ if (c.kind === "tool" && isExploreTool(c.name)) {
1058
+ run.push(c);
1059
+ continue;
1060
+ }
1061
+ flush();
1062
+ rows.push(...cellComponent(c).render(W, ctx));
1063
+ }
1064
+ flush();
1065
+ // the header NAMES the segment. When the segment is exactly one
1066
+ // explore run, "explored 8 files · 14 searches" is what that run
1067
+ // is called everywhere else in the product, and the header says
1068
+ // the same thing rather than a second wording of it.
1069
+ // the header states what the SEGMENT did, in the fold line's own
1070
+ // terms; each run below states what IT did, in the rollup's. Two
1071
+ // scales, one wording each — the header used to borrow the run's
1072
+ // sentence, which read as the same run twice.
1073
+ // the header names what the FOLD said — the turn's terms — so the
1074
+ // line you pressed and the block it opens agree. It used to name
1075
+ // segment 1's, which contradicted the fold above it.
1076
+ const head = foldTerms(foldTurn.reads, foldTurn.edits, [...foldTurn.others]);
1077
+ return { kind: "appended", lines: [`${p.bold}✦${p.reset} expanded · ${escapeTerminal(head.length === 0 ? "thinking" : head.join(" · "))} · ${back}`, ...rows] };
1078
+ }
804
1079
  if (cell.kind !== "tool")
805
1080
  return { kind: "none" };
806
1081
  if (cell.rolled !== null) {
@@ -814,15 +1089,10 @@ export class Body {
814
1089
  // TUI2-R1 (B): an EXPLORATION head lists per TOOL — the counts
815
1090
  // the row showed, then one row per tool with its subjects. The
816
1091
  // header keeps W15's shape; only the subject changes.
817
- if (cell.rolled.parts !== undefined) {
818
- const header = `${p.bold}✦${p.reset} expanded · ${escapeTerminal(`explored ${exploreCounts(cell.rolled.parts)}`)} · ${back}`;
819
- return { kind: "appended", lines: [header, ...exploreRows(cell.rolled.parts, this.#opts.width())] };
820
- }
821
- const noun = ROLLUP_NOUN[cell.name] ?? "calls";
822
- const header = `${p.bold}✦${p.reset} expanded · ${escapeTerminal(`${displayVerb(cell.name)} ${cell.rolled.count} ${noun}`)} · ${back}`;
1092
+ const rolledHead = rolledTitle(cell);
823
1093
  return {
824
1094
  kind: "appended",
825
- lines: [header, ...cell.rolled.targets.map((t) => ` ${p.dim} ${escapeTerminal(t)}${p.reset}`)],
1095
+ lines: [`${p.bold}✦${p.reset} expanded · ${escapeTerminal(rolledHead)} · ${back}`, ...rolledDetail(cell, this.#opts.width())],
826
1096
  };
827
1097
  }
828
1098
  let input = {};
@@ -1444,6 +1714,17 @@ export class Body {
1444
1714
  // edge — the cap scalar is asserted by the gates). W22: the
1445
1715
  // queue band shrinks the cap by its rows (empty queue → H−4).
1446
1716
  while (liveLines.length > H - 4 - inputExtra - queueRows.length && this.#committed < this.#cells.length) { // V6-3: the content cap H−4 (KC1: −N's extra rows)
1717
+ // R3f: the cell about to be force-committed marks its segment
1718
+ // SPILLED. The rule was written at R3b — "a segment too big for
1719
+ // the screen already has rows in the scrollback that cannot be
1720
+ // taken back, so it renders normally and does not collapse" —
1721
+ // and then never wired: `spilled` had a declaration, an
1722
+ // initializer and a read, and nothing ever set it. The read was
1723
+ // therefore vacuously true, so a 43-call turn force-committed
1724
+ // thirty expanded rows and STILL printed `✦ thought 103s · 43
1725
+ // reads` underneath them, claiming as folded the work standing
1726
+ // visible above it.
1727
+ this.#markSpilled(this.#committed);
1447
1728
  this.#commitCell(this.#committed, W, ctx);
1448
1729
  liveLines = [];
1449
1730
  {
@@ -1620,7 +1901,11 @@ export class Body {
1620
1901
  // unshift: the cells commit oldest-first, so the NEWEST cut lands
1621
1902
  // at the front — the expand pointer's "newest back" walk starts
1622
1903
  // where the user's last key press would aim.
1623
- if (cell.kind === "tool" && lines.some((l) => l.includes("ctrl+r")))
1904
+ // R3b: a fold HEAD joins the ring too. The test used to demand a
1905
+ // tool cell, and a segment's fold can be emitted at a thinking
1906
+ // cell — which would have left the whole segment unreachable by
1907
+ // the very key its own row advertises.
1908
+ if ((cell.kind === "tool" || this.#segmentOf(i)?.headCell === i) && lines.some((l) => l.includes("ctrl+r")))
1624
1909
  this.#collapsed.unshift(i);
1625
1910
  this.#lineCache[i] = lines;
1626
1911
  const placed = this.#space(i, i > 0 ? this.#lineCache[i - 1] : null, lines);
@@ -1628,6 +1913,89 @@ export class Body {
1628
1913
  this.#committedLines += placed.length;
1629
1914
  this.#committedLinesThisFrame.push(...placed);
1630
1915
  }
1916
+ /** R3b — record which segment the cell just pushed belongs to. Called
1917
+ * right after the push, so #cells.length-1 is that cell. */
1918
+ #stampSegment() {
1919
+ const turn = this.#turns[this.#turns.length - 1];
1920
+ const idx = turn === undefined ? -1 : turn.segments.length - 1;
1921
+ const at = this.#cells.length - 1;
1922
+ this.#cellSegment[at] = idx;
1923
+ if (turn !== undefined && idx >= 0)
1924
+ turn.segments[idx].cells.push(at);
1925
+ }
1926
+ /**
1927
+ * R3b — does the segment hold a call that FAILED or was DENIED?
1928
+ *
1929
+ * Such a segment does not fold. Routine work is what the fold is for;
1930
+ * a refusal and an error are the opposite of routine, and putting
1931
+ * either behind a key hides the one thing on the screen that most
1932
+ * needs a human's eye. Law 1.3 makes the same call about marks — a
1933
+ * failure keeps its colour AND its words — and this is that rule at
1934
+ * the scale of a run.
1935
+ *
1936
+ * The cost, accepted: a turn that reads twenty files and hits one
1937
+ * denial keeps all twenty rows. The alternative is a screen that says
1938
+ * `✦ thought 3s · 20 reads` while a write was refused inside it.
1939
+ */
1940
+ #segmentHasTrouble(seg) {
1941
+ // R3g (fable, 2026-08-28): a DENIED call is the case this rule
1942
+ // exists for, and it was the one case the predicate could not
1943
+ // see — a denial carrying no `reason` string leaves isError
1944
+ // false and reason null, so `✦ thought 3s · 20 reads` could
1945
+ // stand over a refused write. The verdict is the record of it.
1946
+ return this.#segmentTools(seg).some((c) => c.isError || c.reason !== null || c.verdict?.decision === "denied");
1947
+ }
1948
+ /** R3b — the segment's TOOL cells, in order. */
1949
+ #segmentTools(seg) {
1950
+ const out = [];
1951
+ for (const j of seg.cells) {
1952
+ const c = this.#cells[j];
1953
+ if (c.kind === "tool")
1954
+ out.push(c);
1955
+ }
1956
+ return out;
1957
+ }
1958
+ /** R3f — the cell is leaving the live region under the screen's hard
1959
+ * cap, so its segment can no longer be represented by a fold. */
1960
+ #markSpilled(i) {
1961
+ const seg = this.#segmentOf(i);
1962
+ if (seg !== null)
1963
+ seg.spilled = true;
1964
+ }
1965
+ /** R3f — did ANY of the turn's segments spill? The fold is the
1966
+ * TURN's, so one spilled segment makes the whole turn unfoldable:
1967
+ * a line claiming the turn's counts cannot stand under rows that
1968
+ * already show part of that same work. */
1969
+ #turnSpilled(turn) {
1970
+ return turn.segments.some((seg) => seg.spilled);
1971
+ }
1972
+ /** R3d — the turn's cells and its trouble, across every segment. */
1973
+ #turnCells(turn) {
1974
+ let n = 0;
1975
+ for (const seg of turn.segments)
1976
+ n += seg.cells.length;
1977
+ return n;
1978
+ }
1979
+ #turnHasTrouble(turn) {
1980
+ return turn.segments.some((seg) => this.#segmentHasTrouble(seg));
1981
+ }
1982
+ /** R3b — how many cells the segment holds. The fold's threshold reads
1983
+ * it; nothing else needs it, so it is counted rather than tracked. */
1984
+ #segmentCells(seg) {
1985
+ return seg.cells.length;
1986
+ }
1987
+ /** R3b — the segment a committed cell belongs to, or null when it has
1988
+ * none (a cell of the pipe path, or a kind that is not work). */
1989
+ #segmentOf(i) {
1990
+ const cell = this.#cells[i];
1991
+ if (cell.kind !== "thinking" && cell.kind !== "tool")
1992
+ return null;
1993
+ const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
1994
+ const si = this.#cellSegment[i];
1995
+ if (turn === undefined || si === undefined || si < 0)
1996
+ return null;
1997
+ return turn.segments[si] ?? null;
1998
+ }
1631
1999
  /** W14 — the fold-hold: a thinking/tool cell of the OPEN quiet turn
1632
2000
  * (no text yet) does not commit — its committed form is decided at
1633
2001
  * the release. The cell's OWN turn must be the CURRENT one (a cell
@@ -1640,10 +2008,38 @@ export class Body {
1640
2008
  const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
1641
2009
  if (turn === undefined || turn !== this.#turns[this.#turns.length - 1])
1642
2010
  return false;
2011
+ // R3b (owner, 2026-08-27) — the hold is the SEGMENT's, not the
2012
+ // quiet turn's.
2013
+ //
2014
+ // W14 held a turn's work only while the turn had produced no text
2015
+ // at all, because the fold existed only for a turn that never
2016
+ // spoke. The owner ruled that a segment folds the moment text
2017
+ // arrives, so the unit whose committed form is undecided is the
2018
+ // SEGMENT: while it is open, its cells must not reach the
2019
+ // scrollback, because a committed row cannot be replaced by the
2020
+ // fold line that is going to stand for it.
2021
+ //
2022
+ // This is exactly the change design.md §8 warned about — "folding
2023
+ // at every text boundary changes what commits and when" — and the
2024
+ // warning is why the hold is stated here, once, rather than
2025
+ // spread across the callers.
2026
+ //
2027
+ // The quiet turn is the same rule seen from one side: its single
2028
+ // segment never closes until the settle, so it holds exactly as
2029
+ // it always did.
2030
+ // R3d: the hold is the TURN's. A turn's work has no committed form
2031
+ // until the turn ends, because one line stands for all of it — and
2032
+ // a row already in the scrollback cannot be replaced by that line.
2033
+ // The force-commit path still overrides this (a turn too big for
2034
+ // the screen spills and renders normally); that is the honest
2035
+ // degradation, marked `spilled`.
2036
+ const seg = this.#segmentOf(i);
2037
+ if (seg !== null)
2038
+ return !turn.ended;
2039
+ // no segment (the pipe path's shape) — W14's original test, kept
2040
+ // so a cell that never got a segment behaves as it used to.
1643
2041
  if (!turn.ended && !turn.hasText)
1644
2042
  return true;
1645
- // the turn's END releases every hold — the settle is where the run
1646
- // is decided, and a held cell at settle would never commit at all.
1647
2043
  if (turn.ended)
1648
2044
  return false;
1649
2045
  return this.#growingRun(i);
@@ -1696,15 +2092,78 @@ export class Body {
1696
2092
  #foldOrRollup(cell, i, W, ctx) {
1697
2093
  if (cell.kind === "thinking" || cell.kind === "tool") {
1698
2094
  const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
1699
- if (turn !== undefined && turn.ended && !turn.hasText) {
2095
+ const seg = this.#segmentOf(i);
2096
+ // R3b — the SEGMENT folds, and it folds once.
2097
+ //
2098
+ // A cell only reaches here once its segment is closed (the hold
2099
+ // above keeps an open segment out of the commit loop entirely),
2100
+ // so the first cell of a closed segment emits the fold and every
2101
+ // cell after it renders nothing. `folded` is the latch: without
2102
+ // it the second cell would emit a second fold line for the same
2103
+ // work.
2104
+ //
2105
+ // A SPILLED segment is the honest degradation. The force-commit
2106
+ // path does not consult the hold — the screen's hard cap wins —
2107
+ // so a segment too big for the screen already has rows in the
2108
+ // scrollback that cannot be taken back. It renders normally and
2109
+ // says nothing false; it simply does not collapse.
2110
+ // R3b — a ONE-CELL segment does not fold. Collapsing one row into
2111
+ // one row gains no space and costs the row's subject: `✦ thought
2112
+ // 0s · 1 shell` says strictly less than `shell make build ·
2113
+ // exit 0`. The fold exists to stop a screen filling with work
2114
+ // rows, and one row is not that.
2115
+ // R3d (owner, 2026-08-28) — a segment folds only on a QUIET turn.
2116
+ //
2117
+ // R3b folded every closed segment, and in use that was wrong for
2118
+ // a reason the design questions never surfaced: a model narrates
2119
+ // between calls, so a turn is not two or three segments, it is
2120
+ // one per tool. Every call became its own `✦ thought 2s · 1 read`
2121
+ // row — the same row count the fold exists to remove, now saying
2122
+ // less. The screen is not improved by summarising one thing.
2123
+ //
2124
+ // The turn's ONE line (renderRecap, R3d) carries the work now,
2125
+ // which is where it always belonged: it is already emitted once
2126
+ // per turn, in the right place, and it only needed to say what
2127
+ // the turn DID rather than "43 tools".
2128
+ //
2129
+ // The quiet turn keeps its fold because there IS no recap line
2130
+ // to carry it: a turn with no text is the fold, and W14's gates
2131
+ // pin that shape.
2132
+ if (turn !== undefined && seg !== null && seg.closedAt !== null && !this.#turnSpilled(turn) && turn.ended && this.#turnCells(turn) >= 2 && !this.#turnHasTrouble(turn)) {
1700
2133
  if (!turn.folded) {
2134
+ seg.folded = true;
2135
+ seg.headCell = i;
1701
2136
  turn.folded = true;
1702
2137
  // A9 (ruling R2, mock A): the user chip rides the fold —
1703
- // the words take the fold's width budget (turnFold is
1704
- // W-aware the ONE row never trips invariant ①).
2138
+ // but ONLY on a quiet turn, where the fold stands for the
2139
+ // whole turn and the chip has nowhere else to be. In a
2140
+ // turn WITH text the chip cell commits on its own, so a
2141
+ // fold that repeated the words would put the user's line
2142
+ // on screen twice. The words take the fold's width budget
2143
+ // (turnFold is W-aware — the ONE row never trips
2144
+ // invariant ①).
2145
+ const quiet = turn.ended && !turn.hasText;
2146
+ // R3g (fable, 2026-08-28) — DECLARED SUPERSESSION: both
2147
+ // branches read the SAME number now, `thoughtSeconds`,
2148
+ // the measure the kernel took and handed to endTurn.
2149
+ // The non-quiet branch used to re-derive a wall clock
2150
+ // from the segment's opening and print it under the word
2151
+ // "thought" — a different quantity wearing the same
2152
+ // label: a turn that thought 1s and then ran a 40s shell
2153
+ // said "thought 41s". The fold only ever renders after
2154
+ // endTurn (the gate below requires `turn.ended`), so the
2155
+ // honest number is always available by the time it runs.
2156
+ //
2157
+ // The terms are the TURN's, not the segment's: R3d folds
2158
+ // a turn's work into ONE line wherever the first work
2159
+ // lands. A per-segment line put a row on screen for every
2160
+ // break in the model's narration, which on a chatty model
2161
+ // is one row per tool — the row count the fold exists to
2162
+ // remove.
2163
+ const seconds = turn.thoughtSeconds;
1705
2164
  return turnFold({
1706
- words: turn.words,
1707
- thoughtSeconds: turn.thoughtSeconds,
2165
+ words: quiet ? turn.words : "",
2166
+ thoughtSeconds: seconds,
1708
2167
  reads: turn.reads,
1709
2168
  edits: turn.edits,
1710
2169
  others: [...turn.others],
@@ -1766,7 +2225,14 @@ export class Body {
1766
2225
  // text's release they are — the natural loop commits the run in
1767
2226
  // one frame; the force-commit's early commits degrade to the
1768
2227
  // individual rows, the members render normally after).
1769
- if (!members.every((c) => c.done))
2228
+ // R3g (2026-08-28): ...and no member is in TROUBLE. A rollup
2229
+ // says "explored 3 paths" — a sentence a failed or interrupted
2230
+ // call makes false, and the row it replaces was the only place
2231
+ // that failure had words. Law 1.3 at the scale of a run: the
2232
+ // same rule #segmentHasTrouble applies to the fold. Found when
2233
+ // R3g's interrupt-closing made an aborted call `done`, which
2234
+ // let a run it never finished roll up as if it had.
2235
+ if (!members.every((c) => c.done && !c.isError && c.reason === null))
1770
2236
  return cellComponent(cell).render(W, ctx);
1771
2237
  this.#rolledHeads.add(head);
1772
2238
  let total = 0;
@@ -1775,37 +2241,7 @@ export class Body {
1775
2241
  // exploration row's counts and its expanded list both read them.
1776
2242
  // A search's subject is the PATTERN it looked for (quoted); a
1777
2243
  // read's or a list's is the path it named.
1778
- const parts = [];
1779
- for (const m of members) {
1780
- // the lines count, excluding the tool's OWN truncation note
1781
- // (read_file's "… N more lines") — the per-cell meta's rule
1782
- const noteAt = m.resultText.lastIndexOf("\n… ");
1783
- const shown = noteAt >= 0 ? m.resultText.slice(0, noteAt) : m.resultText;
1784
- const rows = shown.split("\n");
1785
- total += rows[rows.length - 1] === "" ? rows.length - 1 : rows.length;
1786
- let input = {};
1787
- try {
1788
- input = JSON.parse(m.inputFull);
1789
- }
1790
- catch {
1791
- // the full JSON is always parseable (stringified at
1792
- // toolStart) — the empty fallback never fires
1793
- }
1794
- const target = toolTarget(m.name, input);
1795
- targets.push(target.split("/").pop() ?? target);
1796
- const subject = m.name === "search_text" ? `"${String(input.pattern ?? "")}"` : target;
1797
- const part = parts.find((x) => x.name === m.name);
1798
- if (part === undefined)
1799
- parts.push({ name: m.name, subjects: [subject] });
1800
- else
1801
- part.subjects.push(subject);
1802
- }
1803
- const first = members[0];
1804
- const last = members[members.length - 1];
1805
- const elapsed = first.startedAt !== null && last.doneAt !== null ? ((last.doneAt - first.startedAt) / 1000).toFixed(1) : "?";
1806
- // TUI2-R1 (B): `parts` rides ONLY a mixed run — a single-name
1807
- // run keeps W13's row, byte for byte (the generalization adds).
1808
- cell.rolled = { count: members.length, lines: total, elapsed, targets, ...(parts.length > 1 ? { parts } : {}) };
2244
+ cell.rolled = rolledOf(members);
1809
2245
  return cellComponent(cell).render(W, ctx);
1810
2246
  }
1811
2247
  // a MEMBER of an already-rolled run → [] (its rows live in the
@@ -2318,6 +2754,32 @@ export class Body {
2318
2754
  /** Invariant ①: every emitted line fits the width — a violation is a
2319
2755
  * CRASH with the diagnostic, never a silent truncate. */
2320
2756
  #checked(line, W) {
2757
+ // Invariant ①b (R3f): a ROW IS ONE PHYSICAL ROW.
2758
+ //
2759
+ // The defect this catches shipped in 0.16.6 and smashed the
2760
+ // composer. `escapeTerminal` keeps `\n` (it strips C0 except tab
2761
+ // and newline), and `charWidth(0x0A)` is 1 — so a newline counts as
2762
+ // ONE CELL in `visibleWidth`, and every width check in the product,
2763
+ // invariant ① included, waves a multi-line string through as a
2764
+ // single row of legal width. `#emitDiff` then paints it as
2765
+ // `CUP(row,1) + EL + content`, the terminal's ONLCR moves the
2766
+ // cursor down at the newline, and the tail lands on whatever
2767
+ // physical row is there — the box rail, the input row. The diff
2768
+ // then adopts `desired` as the screen's truth, so the corruption
2769
+ // SURVIVES: the self-healing property this renderer is built on
2770
+ // ("a wrong row is repaired by the next frame, because the
2771
+ // difference includes it") is exactly what a lying `#screen`
2772
+ // breaks.
2773
+ //
2774
+ // Width was never the whole invariant — it was the half we
2775
+ // noticed. A row that occupies two physical rows violates the
2776
+ // geometry as surely as one that overruns the width, and it does
2777
+ // so INVISIBLY to a width check. `\r` is here for the same reason
2778
+ // (it moves the cursor to column 1).
2779
+ const bad = /[\n\r]/.exec(line);
2780
+ if (bad !== null) {
2781
+ throw new Error(`kiso-tui invariant ①b violated: a row containing ${JSON.stringify(bad[0])} was about to be emitted — a row must be ONE physical row, and the width check cannot see this (charWidth counts a newline as one cell) — ${JSON.stringify(line.slice(0, 80))}`);
2782
+ }
2321
2783
  const w = visibleWidth(line);
2322
2784
  if (w > W) {
2323
2785
  throw new Error(`kiso-tui invariant ① violated: a line of visible width ${w} > ${W} was about to be emitted — ${JSON.stringify(line.slice(0, 80))}`);
package/dist/render.d.ts CHANGED
@@ -144,15 +144,45 @@ export declare function renderStatusLine(turn: number, usage: RunUsage, ctxRatio
144
144
  * (zero tokens): wall seconds, tool counts, usage, cache hit %, ctx left.
145
145
  */
146
146
  export interface RecapStats {
147
+ /** The TURN's wall seconds — what it took, start to settle. Named
148
+ * `took` on the row since R3g: it was labelled "thought" while the
149
+ * fold line one row above printed the kernel's MEASURED thinking
150
+ * seconds under that same word, so a turn that thought for 1s and
151
+ * then ran a 40s shell had two different numbers both called
152
+ * "thought". */
147
153
  readonly seconds: number;
148
- readonly tools: number;
149
- readonly edits: number;
154
+ /**
155
+ * R3g — THE WORK TERMS ARE OPTIONAL, AND kiso NO LONGER PASSES THEM.
156
+ *
157
+ * The turn's work is said ONCE, by the compositor's fold line, in
158
+ * the place the work happened and with the key that reopens it. This
159
+ * row used to repeat it a few rows below — the same terms, a
160
+ * different clock — which is the doubling the owner called out.
161
+ *
162
+ * The fields stay, and still render when a caller supplies them, so
163
+ * an embedder of this package sees the byte-for-byte historical row.
164
+ * A turn whose work did NOT fold (it spilled past the live region,
165
+ * or it hit trouble) keeps every one of its rows on screen, so the
166
+ * work is not lost by their absence — it is standing right there.
167
+ */
168
+ readonly tools?: number;
169
+ readonly edits?: number;
170
+ /** The turn's work BY TOOL, in first-call order (R3d). */
171
+ readonly byTool?: readonly [string, number][];
150
172
  readonly usage: RunUsage;
151
173
  /** R-C item 4: the per-turn cache miss (min(prevIn, in) − cacheRead),
152
174
  * passed only when above the noise floor — the re-sent-uncached
153
175
  * prefix. Absent → the recap bytes stay the historical form. */
154
176
  readonly missed?: number;
155
177
  readonly ctxLeftPct: number | null;
178
+ /** R3g — the terminal's width. The recap is the ONE row on the screen
179
+ * that was never measured: it is written raw, so a line longer than
180
+ * the terminal wrapped, and the second physical row is a fragment
181
+ * matching no cell format (the v2v lint reads it as interleaving).
182
+ * R3g's verb+noun terms made an 80-column wrap ordinary rather than
183
+ * rare, which is how it surfaced. Absent → uncut, the historical
184
+ * bytes, for the callers that render into no terminal. */
185
+ readonly width?: number;
156
186
  /** W19 — the mode the turn ran under. Under "plan" the recap becomes
157
187
  * the way-forward row (the claimed shape): a plan turn's currency is
158
188
  * the plan, not the tool count — the timing and tool-count parts
package/dist/render.js CHANGED
@@ -14,6 +14,8 @@
14
14
  * session line).
15
15
  */
16
16
  import { escapeTerminal, foldResult, foldThinking, kUnit, palette } from "@vincemakes/kiso-tui-cells/render";
17
+ import { foldTerms, widthCut } from "@vincemakes/kiso-tui-cells/components";
18
+ import { visibleWidth } from "@vincemakes/kiso-tui-cells/width";
17
19
  export * from "@vincemakes/kiso-tui-cells/render";
18
20
  /**
19
21
  * Render one event. `text` may be a continuation (text_delta appends to the
@@ -163,6 +165,23 @@ export function renderStatusLine(turn, usage, ctxRatio, faux = false) {
163
165
  return null;
164
166
  return `[turn ${turn} · ${parts.join(" · ")}]`;
165
167
  }
168
+ /** R3d — the per-tool terms of a settled turn, in the fold line's own
169
+ * vocabulary (ROLLUP_NOUN plurals, zero terms dropped). One wording for
170
+ * "what a run did", wherever it is said. */
171
+ function recapWork(byTool) {
172
+ let reads = 0;
173
+ let edits = 0;
174
+ const others = [];
175
+ for (const [name, n] of byTool) {
176
+ if (name === "read_file")
177
+ reads += n;
178
+ else if (name === "edit_file")
179
+ edits += n;
180
+ else
181
+ others.push([name, n]);
182
+ }
183
+ return foldTerms(reads, edits, others);
184
+ }
166
185
  export function renderRecap(s) {
167
186
  const p = palette();
168
187
  // W19: under plan the recap is the way out of the mode — the header
@@ -176,7 +195,21 @@ export function renderRecap(s) {
176
195
  const parts = ["plan ready", "/mode default executes", "/mode accept-edits auto-approves edits"];
177
196
  return `${p.bold}✦${p.reset} ${parts.join(" · ")}\n`;
178
197
  }
179
- const parts = [`${s.seconds}s`, `${s.tools} tool${s.tools === 1 ? "" : "s"}${s.edits > 0 ? ` (${s.edits} edit${s.edits === 1 ? "" : "s"})` : ""}`];
198
+ // R3d (owner, 2026-08-28): the turn's ONE line says what the turn DID.
199
+ //
200
+ // The per-segment folds this replaces put a row on screen for every
201
+ // break in the model's narration — and a model that narrates between
202
+ // every call turned that into one row per tool, which is the row
203
+ // count the fold was built to remove, wearing a summary's clothes.
204
+ // The turn already had exactly one line in exactly the right place;
205
+ // it was just too coarse to be worth reading.
206
+ const edits = s.edits ?? 0;
207
+ const work = s.byTool !== undefined && s.byTool.length > 0
208
+ ? recapWork(s.byTool)
209
+ : s.tools !== undefined && s.tools > 0
210
+ ? [`${s.tools} tool${s.tools === 1 ? "" : "s"}${edits > 0 ? ` (${edits} edit${edits === 1 ? "" : "s"})` : ""}`]
211
+ : [];
212
+ const parts = [`took ${s.seconds}s`, ...work];
180
213
  if (s.usage.known) {
181
214
  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)}` : ""}`;
182
215
  if (seg !== "")
@@ -193,7 +226,17 @@ export function renderRecap(s) {
193
226
  }
194
227
  if (s.ctxLeftPct !== null)
195
228
  parts.push(`ctx left ~${Math.round(s.ctxLeftPct)}%`);
196
- return `${p.bold}✦${p.reset} ${parts.join(" · ")}\n`;
229
+ // R3g: ONE physical row, at any width — the same rule every other row
230
+ // in the product obeys. The cut is the honest "…": the recap said
231
+ // more than fits, and says so.
232
+ const line = parts.join(" · ");
233
+ // R3g: the floor is the renderer's own guard against a caller that
234
+ // hands it a degenerate width (a PTY with no winsize reports 0). A
235
+ // recap cut to one character is worse than one that wraps.
236
+ if (s.width !== undefined && s.width >= 20 && visibleWidth(`✦ ${line}`) > s.width) {
237
+ return `${p.bold}✦${p.reset} ${widthCut(line, Math.max(1, s.width - 3))}…\n`;
238
+ }
239
+ return `${p.bold}✦${p.reset} ${line}\n`;
197
240
  }
198
241
  /** One-line summary of a session, for `kiso sessions`. */
199
242
  export function renderSessionLine(meta) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.16.5",
3
+ "version": "0.16.7",
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",
@@ -35,6 +35,6 @@
35
35
  },
36
36
  "homepage": "https://github.com/vincemakes/kiso/tree/main/packages/tui#readme",
37
37
  "dependencies": {
38
- "@vincemakes/kiso-tui-cells": "0.16.5"
38
+ "@vincemakes/kiso-tui-cells": "0.16.7"
39
39
  }
40
40
  }