@vincemakes/kiso-tui 0.16.5 → 0.16.6

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 —
@@ -801,6 +954,89 @@ export class Body {
801
954
  const idx = this.#collapsed[this.#expandPtr % this.#collapsed.length];
802
955
  this.#expandPtr += 1;
803
956
  const cell = this.#cells[idx];
957
+ // R3b — a folded SEGMENT expands to the work it stands for.
958
+ //
959
+ // The fold line collapses a run of thinking and tool cells into
960
+ // one row; without this the run would be unreachable, which is
961
+ // hiding a durable record behind a summary. The rows are APPENDED
962
+ // (ADR-0046 — history is never rewritten), exactly as every other
963
+ // expand in this method does, and they are the cells' OWN renders,
964
+ // so the expansion cannot drift from what was folded.
965
+ const seg = this.#segmentOf(idx);
966
+ if (seg !== null && seg.headCell === idx) {
967
+ const p = palette();
968
+ const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
969
+ const back = `${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
970
+ const W = this.#opts.width();
971
+ const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: this.#opts.height() };
972
+ // R3b (owner ruling): the ROLLUP is the expansion. TUI2-R1 built
973
+ // a richer projection of an explore run than a fold line can
974
+ // carry — the per-tool counts, and one row per tool with its
975
+ // subjects — and the segment fold would have retired it by
976
+ // simply arriving first. So the run's own rows are what the key
977
+ // opens: explore tools group the way the rollup groups them,
978
+ // everything else renders as itself.
979
+ // The segment's cells IN ORDER, with consecutive explore tools
980
+ // grouped exactly as the rollup groups them — a write, a shell
981
+ // or anything else BREAKS the run, which is TUI2-R1's own rule
982
+ // and the reason two explore runs on either side of a write
983
+ // stay two runs. Merging every explore tool of the segment
984
+ // would have been simpler and would have quietly deleted that
985
+ // rule.
986
+ const rows = [];
987
+ let run = [];
988
+ const flush = () => {
989
+ if (run.length === 0)
990
+ return;
991
+ // the same threshold the commit-time rollup uses: below it a
992
+ // "run" is just some rows
993
+ if (run.length > 2) {
994
+ // the run renders through the ROLLUP's own projection —
995
+ // literally the same function the commit path uses — so
996
+ // a single-name run keeps W13's row and a mixed one gets
997
+ // the exploration line, exactly as they would have if the
998
+ // segment had never folded.
999
+ const head = run[0];
1000
+ const saved = head.rolled;
1001
+ head.rolled = rolledOf(run);
1002
+ // the run OPENS. The fold's key already asked to see the
1003
+ // work, so what lands is the same rows `ctrl+r` on the
1004
+ // run itself would have opened — its title, then its
1005
+ // detail — never its collapsed row, which would make the
1006
+ // reader press a second time for what the first press
1007
+ // was for.
1008
+ rows.push(` ${p.dim}${escapeTerminal(rolledTitle(head))}${p.reset}`);
1009
+ rows.push(...rolledDetail(head, W));
1010
+ head.rolled = saved;
1011
+ }
1012
+ else {
1013
+ for (const c of run)
1014
+ rows.push(...cellComponent(c).render(W, ctx));
1015
+ }
1016
+ run = [];
1017
+ };
1018
+ for (const j of seg.cells) {
1019
+ if (j < idx)
1020
+ continue;
1021
+ const c = this.#cells[j];
1022
+ if (c.kind === "tool" && isExploreTool(c.name)) {
1023
+ run.push(c);
1024
+ continue;
1025
+ }
1026
+ flush();
1027
+ rows.push(...cellComponent(c).render(W, ctx));
1028
+ }
1029
+ flush();
1030
+ // the header NAMES the segment. When the segment is exactly one
1031
+ // explore run, "explored 8 files · 14 searches" is what that run
1032
+ // is called everywhere else in the product, and the header says
1033
+ // the same thing rather than a second wording of it.
1034
+ // the header states what the SEGMENT did, in the fold line's own
1035
+ // terms; each run below states what IT did, in the rollup's. Two
1036
+ // scales, one wording each — the header used to borrow the run's
1037
+ // sentence, which read as the same run twice.
1038
+ return { kind: "appended", lines: [`${p.bold}✦${p.reset} expanded · ${escapeTerminal(foldMeta(seg))} · ${back}`, ...rows] };
1039
+ }
804
1040
  if (cell.kind !== "tool")
805
1041
  return { kind: "none" };
806
1042
  if (cell.rolled !== null) {
@@ -814,15 +1050,10 @@ export class Body {
814
1050
  // TUI2-R1 (B): an EXPLORATION head lists per TOOL — the counts
815
1051
  // the row showed, then one row per tool with its subjects. The
816
1052
  // 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}`;
1053
+ const rolledHead = rolledTitle(cell);
823
1054
  return {
824
1055
  kind: "appended",
825
- lines: [header, ...cell.rolled.targets.map((t) => ` ${p.dim} ${escapeTerminal(t)}${p.reset}`)],
1056
+ lines: [`${p.bold}✦${p.reset} expanded · ${escapeTerminal(rolledHead)} · ${back}`, ...rolledDetail(cell, this.#opts.width())],
826
1057
  };
827
1058
  }
828
1059
  let input = {};
@@ -1620,7 +1851,11 @@ export class Body {
1620
1851
  // unshift: the cells commit oldest-first, so the NEWEST cut lands
1621
1852
  // at the front — the expand pointer's "newest back" walk starts
1622
1853
  // where the user's last key press would aim.
1623
- if (cell.kind === "tool" && lines.some((l) => l.includes("ctrl+r")))
1854
+ // R3b: a fold HEAD joins the ring too. The test used to demand a
1855
+ // tool cell, and a segment's fold can be emitted at a thinking
1856
+ // cell — which would have left the whole segment unreachable by
1857
+ // the very key its own row advertises.
1858
+ if ((cell.kind === "tool" || this.#segmentOf(i)?.headCell === i) && lines.some((l) => l.includes("ctrl+r")))
1624
1859
  this.#collapsed.unshift(i);
1625
1860
  this.#lineCache[i] = lines;
1626
1861
  const placed = this.#space(i, i > 0 ? this.#lineCache[i - 1] : null, lines);
@@ -1628,6 +1863,60 @@ export class Body {
1628
1863
  this.#committedLines += placed.length;
1629
1864
  this.#committedLinesThisFrame.push(...placed);
1630
1865
  }
1866
+ /** R3b — record which segment the cell just pushed belongs to. Called
1867
+ * right after the push, so #cells.length-1 is that cell. */
1868
+ #stampSegment() {
1869
+ const turn = this.#turns[this.#turns.length - 1];
1870
+ const idx = turn === undefined ? -1 : turn.segments.length - 1;
1871
+ const at = this.#cells.length - 1;
1872
+ this.#cellSegment[at] = idx;
1873
+ if (turn !== undefined && idx >= 0)
1874
+ turn.segments[idx].cells.push(at);
1875
+ }
1876
+ /**
1877
+ * R3b — does the segment hold a call that FAILED or was DENIED?
1878
+ *
1879
+ * Such a segment does not fold. Routine work is what the fold is for;
1880
+ * a refusal and an error are the opposite of routine, and putting
1881
+ * either behind a key hides the one thing on the screen that most
1882
+ * needs a human's eye. Law 1.3 makes the same call about marks — a
1883
+ * failure keeps its colour AND its words — and this is that rule at
1884
+ * the scale of a run.
1885
+ *
1886
+ * The cost, accepted: a turn that reads twenty files and hits one
1887
+ * denial keeps all twenty rows. The alternative is a screen that says
1888
+ * `✦ thought 3s · 20 reads` while a write was refused inside it.
1889
+ */
1890
+ #segmentHasTrouble(seg) {
1891
+ return this.#segmentTools(seg).some((c) => c.isError || c.reason !== null);
1892
+ }
1893
+ /** R3b — the segment's TOOL cells, in order. */
1894
+ #segmentTools(seg) {
1895
+ const out = [];
1896
+ for (const j of seg.cells) {
1897
+ const c = this.#cells[j];
1898
+ if (c.kind === "tool")
1899
+ out.push(c);
1900
+ }
1901
+ return out;
1902
+ }
1903
+ /** R3b — how many cells the segment holds. The fold's threshold reads
1904
+ * it; nothing else needs it, so it is counted rather than tracked. */
1905
+ #segmentCells(seg) {
1906
+ return seg.cells.length;
1907
+ }
1908
+ /** R3b — the segment a committed cell belongs to, or null when it has
1909
+ * none (a cell of the pipe path, or a kind that is not work). */
1910
+ #segmentOf(i) {
1911
+ const cell = this.#cells[i];
1912
+ if (cell.kind !== "thinking" && cell.kind !== "tool")
1913
+ return null;
1914
+ const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
1915
+ const si = this.#cellSegment[i];
1916
+ if (turn === undefined || si === undefined || si < 0)
1917
+ return null;
1918
+ return turn.segments[si] ?? null;
1919
+ }
1631
1920
  /** W14 — the fold-hold: a thinking/tool cell of the OPEN quiet turn
1632
1921
  * (no text yet) does not commit — its committed form is decided at
1633
1922
  * the release. The cell's OWN turn must be the CURRENT one (a cell
@@ -1640,10 +1929,32 @@ export class Body {
1640
1929
  const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
1641
1930
  if (turn === undefined || turn !== this.#turns[this.#turns.length - 1])
1642
1931
  return false;
1932
+ // R3b (owner, 2026-08-27) — the hold is the SEGMENT's, not the
1933
+ // quiet turn's.
1934
+ //
1935
+ // W14 held a turn's work only while the turn had produced no text
1936
+ // at all, because the fold existed only for a turn that never
1937
+ // spoke. The owner ruled that a segment folds the moment text
1938
+ // arrives, so the unit whose committed form is undecided is the
1939
+ // SEGMENT: while it is open, its cells must not reach the
1940
+ // scrollback, because a committed row cannot be replaced by the
1941
+ // fold line that is going to stand for it.
1942
+ //
1943
+ // This is exactly the change design.md §8 warned about — "folding
1944
+ // at every text boundary changes what commits and when" — and the
1945
+ // warning is why the hold is stated here, once, rather than
1946
+ // spread across the callers.
1947
+ //
1948
+ // The quiet turn is the same rule seen from one side: its single
1949
+ // segment never closes until the settle, so it holds exactly as
1950
+ // it always did.
1951
+ const seg = this.#segmentOf(i);
1952
+ if (seg !== null)
1953
+ return seg.closedAt === null;
1954
+ // no segment (the pipe path's shape) — W14's original test, kept
1955
+ // so a cell that never got a segment behaves as it used to.
1643
1956
  if (!turn.ended && !turn.hasText)
1644
1957
  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
1958
  if (turn.ended)
1648
1959
  return false;
1649
1960
  return this.#growingRun(i);
@@ -1696,18 +2007,53 @@ export class Body {
1696
2007
  #foldOrRollup(cell, i, W, ctx) {
1697
2008
  if (cell.kind === "thinking" || cell.kind === "tool") {
1698
2009
  const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
1699
- if (turn !== undefined && turn.ended && !turn.hasText) {
1700
- if (!turn.folded) {
2010
+ const seg = this.#segmentOf(i);
2011
+ // R3b — the SEGMENT folds, and it folds once.
2012
+ //
2013
+ // A cell only reaches here once its segment is closed (the hold
2014
+ // above keeps an open segment out of the commit loop entirely),
2015
+ // so the first cell of a closed segment emits the fold and every
2016
+ // cell after it renders nothing. `folded` is the latch: without
2017
+ // it the second cell would emit a second fold line for the same
2018
+ // work.
2019
+ //
2020
+ // A SPILLED segment is the honest degradation. The force-commit
2021
+ // path does not consult the hold — the screen's hard cap wins —
2022
+ // so a segment too big for the screen already has rows in the
2023
+ // scrollback that cannot be taken back. It renders normally and
2024
+ // says nothing false; it simply does not collapse.
2025
+ // R3b — a ONE-CELL segment does not fold. Collapsing one row into
2026
+ // one row gains no space and costs the row's subject: `✦ thought
2027
+ // 0s · 1 shell` says strictly less than `shell make build ·
2028
+ // exit 0`. The fold exists to stop a screen filling with work
2029
+ // rows, and one row is not that.
2030
+ if (turn !== undefined && seg !== null && seg.closedAt !== null && !seg.spilled && this.#segmentCells(seg) >= 2 && !this.#segmentHasTrouble(seg)) {
2031
+ if (!seg.folded) {
2032
+ seg.folded = true;
2033
+ seg.headCell = i;
1701
2034
  turn.folded = true;
1702
2035
  // 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 ①).
2036
+ // but ONLY on a quiet turn, where the fold stands for the
2037
+ // whole turn and the chip has nowhere else to be. In a
2038
+ // turn WITH text the chip cell commits on its own, so a
2039
+ // fold that repeated the words would put the user's line
2040
+ // on screen twice. The words take the fold's width budget
2041
+ // (turnFold is W-aware — the ONE row never trips
2042
+ // invariant ①).
2043
+ const quiet = turn.ended && !turn.hasText;
2044
+ // A QUIET turn (no text at all) keeps `thoughtSeconds` —
2045
+ // the CLI's own measure, taken at the settle, and the
2046
+ // number every W14 gate pins. A mid-turn segment cannot
2047
+ // have it (endTurn has not run), so it reports its own
2048
+ // wall clock, which is the only honest number available
2049
+ // at the moment it folds.
2050
+ const seconds = quiet ? turn.thoughtSeconds : Math.max(0, Math.round(((seg.closedAt ?? 0) - seg.openedAt) / 1000));
1705
2051
  return turnFold({
1706
- words: turn.words,
1707
- thoughtSeconds: turn.thoughtSeconds,
1708
- reads: turn.reads,
1709
- edits: turn.edits,
1710
- others: [...turn.others],
2052
+ words: quiet ? turn.words : "",
2053
+ thoughtSeconds: seconds,
2054
+ reads: seg.reads,
2055
+ edits: seg.edits,
2056
+ others: [...seg.others],
1711
2057
  }, W);
1712
2058
  }
1713
2059
  return [];
@@ -1775,37 +2121,7 @@ export class Body {
1775
2121
  // exploration row's counts and its expanded list both read them.
1776
2122
  // A search's subject is the PATTERN it looked for (quoted); a
1777
2123
  // 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 } : {}) };
2124
+ cell.rolled = rolledOf(members);
1809
2125
  return cellComponent(cell).render(W, ctx);
1810
2126
  }
1811
2127
  // a MEMBER of an already-rolled run → [] (its rows live in the
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.6",
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.6"
39
39
  }
40
40
  }