@vincemakes/kiso-tui 0.16.7 → 0.17.0

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,5 +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
+ export { foldCountsObjects, foldTerms } from "@vincemakes/kiso-tui-cells/components";
6
6
  export { MOTION_FRAMES, TWINKLE, breathFrame, twinkleFrame } from "@vincemakes/kiso-tui-cells/render";
@@ -4,5 +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
+ export { foldCountsObjects, foldTerms } from "@vincemakes/kiso-tui-cells/components";
8
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, foldTerms, focusToken, exploreRows, foldLine, isExploreTool, pendingQueueRows, statusLine, turnFold, visibleWidth, } from "./components.js";
55
+ import { Container, ROLLUP_NOUN, MOTION_FRAMES, MdStream, bodySpacing, boxBottom, boxTop, cellComponent, exploreCounts, foldCountsObjects, foldTerms, focusToken, exploreRows, foldLine, isExploreTool, pendingQueueRows, statusLine, stretchLine, turnFold, visibleWidth, twinkleFrame, } 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,11 @@ 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
+ /** R3i — how many calls in flight the act window shows at once. Beyond
73
+ * it the block would grow with the model's parallelism, which is the
74
+ * same unbounded height the projection exists to remove; the rest are
75
+ * COUNTED, never dropped silently. */
76
+ const LIVE_ACT_HEADS = 3;
72
77
  /** W13 / TUI2-R1 (B) — a rolled run's TITLE: the exploration sentence on
73
78
  * a mixed run, W13's verb+count on a single-name one. */
74
79
  function rolledTitle(cell) {
@@ -174,7 +179,7 @@ function openSegment(turn, now) {
174
179
  const last = turn.segments[turn.segments.length - 1];
175
180
  if (last !== undefined && last.closedAt === null)
176
181
  return last;
177
- const fresh = { openedAt: now, closedAt: null, reads: 0, edits: 0, others: new Map(), folded: false, spilled: false, headCell: null, cells: [] };
182
+ const fresh = { openedAt: now, closedAt: null, reads: 0, edits: 0, others: new Map(), seen: new Map(), thinkingMs: 0, thinkingSince: null, folded: false, spilled: false, headCell: null, cells: [] };
178
183
  turn.segments.push(fresh);
179
184
  return fresh;
180
185
  }
@@ -183,8 +188,21 @@ function openSegment(turn, now) {
183
188
  * keeps a zero-cell segment from ever existing. */
184
189
  function closeSegment(turn, now) {
185
190
  const last = turn?.segments[turn.segments.length - 1];
186
- if (last !== undefined && last.closedAt === null)
187
- last.closedAt = now;
191
+ if (last === undefined || last.closedAt !== null)
192
+ return;
193
+ stopThinking(last, now);
194
+ last.closedAt = now;
195
+ }
196
+ /** R3i — the segment's thinking clock stops. It runs from the first
197
+ * thinking delta of a stretch and stops at the first NON-thinking
198
+ * event, the same rule the CLI applies to the turn — so `thought Ns`
199
+ * is thinking time at every scale and never a wall clock wearing the
200
+ * word (the R3g defect, kept closed at the new scale). */
201
+ function stopThinking(seg, now) {
202
+ if (seg === undefined || seg.thinkingSince === null)
203
+ return;
204
+ seg.thinkingMs += Math.max(0, now - seg.thinkingSince);
205
+ seg.thinkingSince = null;
188
206
  }
189
207
  /** W20 — the whole-table-replace comparison: the live task block only
190
208
  * redraws when the items actually changed (the task extension's
@@ -442,7 +460,7 @@ export class Body {
442
460
  // W14: the turn boundary — the record the fold-hold's release
443
461
  // state machine reads; the cell carries the record's index. A9:
444
462
  // the user's own words ride the record — the fold's leading chip.
445
- this.#turns.push({ ended: false, hasText: false, thoughtSeconds: 0, reads: 0, edits: 0, others: new Map(), words: text, folded: false, segments: [] });
463
+ this.#turns.push({ ended: false, hasText: false, thoughtSeconds: 0, reads: 0, edits: 0, others: new Map(), seen: new Map(), words: text, folded: false, segments: [] });
446
464
  this.#cells.push({ kind: "user", text, done: true, turn: this.#turns.length - 1 });
447
465
  this.#mark();
448
466
  }
@@ -466,7 +484,20 @@ export class Body {
466
484
  // the cell belongs to, and a stamp taken first records the
467
485
  // PREVIOUS segment (or none at all) — which left the thinking
468
486
  // row standing outside the fold it should have led.
469
- openSegment(this.#turns[this.#turns.length - 1], Date.now());
487
+ const seg = openSegment(this.#turns[this.#turns.length - 1], Date.now());
488
+ // R3i: the stretch's thinking clock starts HERE — at the first
489
+ // delta of this stretch, the same moment the CLI starts the
490
+ // turn's — and stops at the next non-thinking event below.
491
+ if (seg !== null && seg.thinkingSince === null)
492
+ seg.thinkingSince = Date.now();
493
+ // R3i: and the beat starts HERE. Law 1.4 says "a running thought
494
+ // twinkles", and `#armSpinner`'s own predicate has always
495
+ // included an open thinking cell — but the only caller was
496
+ // `toolRunning`, so a stretch that thought and did nothing else
497
+ // never moved at all. The line's seconds are a frame-time
498
+ // derivation, so without the beat they also never ticked: the
499
+ // row read `thinking 0s` for as long as the model thought.
500
+ this.#armSpinner();
470
501
  this.#stampSegment();
471
502
  }
472
503
  this.#mark();
@@ -474,6 +505,10 @@ export class Body {
474
505
  thinkingEnd() {
475
506
  const last = this.#cells[this.#cells.length - 1];
476
507
  if (last !== undefined && last.kind === "thinking" && !last.done) {
508
+ // R3i: every closer — text, a notice, a terminal label, the next
509
+ // turn — routes through here, so the clock cannot keep running
510
+ // past the thing that ended it.
511
+ stopThinking(this.#turns[this.#turns.length - 1]?.segments.at(-1), Date.now());
477
512
  last.done = true;
478
513
  this.#lastThinking = last.text;
479
514
  if (!this.#isActive())
@@ -520,16 +555,56 @@ export class Body {
520
555
  // order). The CLI's recap counts the same way (edit_file).
521
556
  const turn = this.#turns[this.#turns.length - 1];
522
557
  if (turn !== undefined) {
523
- if (name === "read_file")
524
- turn.reads += 1;
525
- else if (name === "edit_file")
526
- turn.edits += 1;
527
- else
528
- turn.others.set(name, (turn.others.get(name) ?? 0) + 1);
558
+ // R3h (fable, 2026-08-29): an OBJECT-counting tool counts the
559
+ // distinct thing, not the act. Reading one file twice used to
560
+ // fold as `read 2 files` — a sentence law 1.3 forbids, and one
561
+ // this product shipped. `bump` is false on the second sighting
562
+ // of a target the term has already counted; an ACT-counting
563
+ // tool (a search, a shell command) always bumps, because two
564
+ // searches for the same pattern really are two searches.
565
+ // R3i phase 5 — an ANSWER is words, and words do not fold (law
566
+ // 1.7). `ask_user` closes the open stretch exactly as prose
567
+ // does, and never joins one: absorbed into `1 × ask_user`,
568
+ // what the human said would be gone from the screen — and the
569
+ // one thing a summary must not do is speak for the human.
570
+ if (name === "ask_user") {
571
+ // no stamp: it belongs to NO stretch, so no fold can speak
572
+ // for it — the same standing a block of prose has.
573
+ closeSegment(turn, Date.now());
574
+ this.#mark();
575
+ return;
576
+ }
577
+ const target = foldCountsObjects(name) ? toolTarget(name, input) : null;
578
+ const bump = (rec) => {
579
+ if (target === null)
580
+ return true;
581
+ let set = rec.seen.get(name);
582
+ if (set === undefined) {
583
+ set = new Set();
584
+ rec.seen.set(name, set);
585
+ }
586
+ if (set.has(target))
587
+ return false;
588
+ set.add(target);
589
+ return true;
590
+ };
591
+ if (bump(turn)) {
592
+ if (name === "read_file")
593
+ turn.reads += 1;
594
+ else if (name === "edit_file")
595
+ turn.edits += 1;
596
+ else
597
+ turn.others.set(name, (turn.others.get(name) ?? 0) + 1);
598
+ }
529
599
  // R3b: and into the SEGMENT, which opens here when this is the
530
- // first work since the last text block.
600
+ // first work since the last text block. Its set is its OWN — a
601
+ // file read once per segment is one file in each segment's
602
+ // terms and one file in the turn's.
531
603
  const seg = openSegment(turn, Date.now());
532
- if (seg !== null) {
604
+ // R3i: a tool call is a NON-thinking event — the clock stops,
605
+ // exactly as the CLI's does at the same boundary.
606
+ stopThinking(seg ?? undefined, Date.now());
607
+ if (seg !== null && bump(seg)) {
533
608
  if (name === "read_file")
534
609
  seg.reads += 1;
535
610
  else if (name === "edit_file")
@@ -1050,7 +1125,20 @@ export class Body {
1050
1125
  // boundaries survive where they carry meaning (the write that
1051
1126
  // splits two explore runs); they simply no longer bound what
1052
1127
  // the key can reach.
1053
- for (const j of foldTurn.segments.flatMap((sg) => sg.cells).sort((a, b) => a - b)) {
1128
+ // DECLARED SUPERSESSION (R3i phase 3) the expansion covers THIS
1129
+ // STRETCH, and only this stretch.
1130
+ //
1131
+ // R3f widened it to the whole turn, and had to: R3d had made
1132
+ // the fold the TURN's while the expansion still walked one
1133
+ // segment, so a line claiming `read 3 files · edited 1 file`
1134
+ // opened only the reads — work named and then withheld, the one
1135
+ // thing this file's first gate forbids. R3i moves the fold back
1136
+ // to the stretch, so the pairing is exact again: every stretch
1137
+ // has its OWN line and its own key, and each key opens the work
1138
+ // its line named. Keeping the turn walk would break the same
1139
+ // rule from the other side — two lines, each opening
1140
+ // everything, each header describing rows the other also shows.
1141
+ for (const j of seg.cells) {
1054
1142
  if (j < idx)
1055
1143
  continue;
1056
1144
  const c = this.#cells[j];
@@ -1073,8 +1161,26 @@ export class Body {
1073
1161
  // the header names what the FOLD said — the turn's terms — so the
1074
1162
  // line you pressed and the block it opens agree. It used to name
1075
1163
  // 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] };
1164
+ const t = this.#stretchTerms(seg);
1165
+ const head = foldTerms(t.calls.find(([n]) => n === "read_file")?.[1] ?? 0, t.calls.find(([n]) => n === "edit_file")?.[1] ?? 0, t.calls.filter(([n]) => n !== "read_file" && n !== "edit_file"));
1166
+ // R3i phase 4 — THE FOOTER TELLS THE TRUTH ABOUT THIS PATH.
1167
+ //
1168
+ // The rows come from the rollup's own projection, whose last
1169
+ // row reads `└ ctrl+r collapses` — true where it was written
1170
+ // (the LIVE toggle, which really does close again) and false
1171
+ // here. A committed row is ink: ADR-0046 forbids rewriting
1172
+ // history, so nothing about this block can be taken back. The
1173
+ // next press opens the NEXT fold, and the row now says so.
1174
+ const closing = rows.length > 0 && /ctrl\+r collapses/.test(rows[rows.length - 1] ?? "");
1175
+ const body = closing ? rows.slice(0, -1) : rows;
1176
+ return {
1177
+ kind: "appended",
1178
+ lines: [
1179
+ `${p.bold}✦${p.reset} expanded · ${escapeTerminal(head.length === 0 ? "thinking" : head.join(" · "))} · ${back}`,
1180
+ ...body,
1181
+ ` ${p.dim}└ end of expansion · ctrl+r opens the next fold${p.reset}`,
1182
+ ],
1183
+ };
1078
1184
  }
1079
1185
  if (cell.kind !== "tool")
1080
1186
  return { kind: "none" };
@@ -1549,6 +1655,118 @@ export class Body {
1549
1655
  this.render();
1550
1656
  }
1551
1657
  // ---- the one writer ----
1658
+ /**
1659
+ * R3i phase 2 — THE LIVE PROJECTION.
1660
+ *
1661
+ * One definition, called from the natural path and from inside the
1662
+ * force-commit loop, because two copies of "what the live region
1663
+ * looks like" is two answers to one question.
1664
+ *
1665
+ * The change this phase makes, and the ONLY one: the cells of the
1666
+ * OPEN stretch no longer each hold a row. The stretch is one line —
1667
+ * the same line the settle will keep, in the present tense — plus
1668
+ * the calls actually in flight. A completed call renders nothing;
1669
+ * its count rides the line.
1670
+ *
1671
+ * What it fixes: a 28-call turn used to spend 28 rows of a 30-row
1672
+ * live region, so overflow was the NORM on real turns rather than
1673
+ * the edge — and a turn that overflows may not fold (R3f: a line
1674
+ * cannot claim rows already in the scrollback), which is why the
1675
+ * fold missed exactly the turns it exists for. The block's height
1676
+ * no longer depends on the call count at all.
1677
+ *
1678
+ * What it does NOT change: nothing about what commits or when. The
1679
+ * hold is untouched, the force-commit cap is untouched, and the
1680
+ * settle still produces the same fold it did before. That is the
1681
+ * charter's line between this phase and the next.
1682
+ */
1683
+ #liveProjection(W, ctx) {
1684
+ const out = [];
1685
+ const focus = this.#focusIndex();
1686
+ const turn = this.#turns[this.#turns.length - 1];
1687
+ const open = turn !== undefined && !turn.ended ? (turn.segments[turn.segments.length - 1] ?? null) : null;
1688
+ const openSeg = open !== null && open.closedAt === null ? open : null;
1689
+ let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
1690
+ let stretchDrawn = false;
1691
+ let runningShown = 0;
1692
+ let runningHidden = 0;
1693
+ for (let i = this.#committed; i < this.#cells.length; i += 1) {
1694
+ const cell = this.#cells[i];
1695
+ const inOpen = openSeg !== null && openSeg.cells.includes(i);
1696
+ if (inOpen) {
1697
+ // the stretch's ONE line, drawn once, at its first cell
1698
+ if (!stretchDrawn) {
1699
+ stretchDrawn = true;
1700
+ const rows = stretchLine({ ...this.#stretchTerms(openSeg), phase: this.#stretchPhase(openSeg), mark: twinkleFrame(this.#spinnerI) }, W);
1701
+ out.push(...this.#space(i, prev, rows));
1702
+ prev = rows;
1703
+ }
1704
+ // a DONE cell's row is gone; its count is on the line above.
1705
+ // A cell still in flight keeps its row and its output —
1706
+ // hiding the work in flight would be the opposite defect.
1707
+ const flight = cell.kind === "tool" && !cell.done;
1708
+ if (!flight)
1709
+ continue;
1710
+ if (runningShown >= LIVE_ACT_HEADS) {
1711
+ runningHidden += 1;
1712
+ continue;
1713
+ }
1714
+ runningShown += 1;
1715
+ }
1716
+ const rows = cellComponent(cell).render(W, ctx);
1717
+ // the head row carries the affordance; the tint lands on it and
1718
+ // nowhere else, which is what makes "exactly one" structural
1719
+ if (i === focus && rows.length > 0)
1720
+ rows[0] = focusToken(rows[0], W);
1721
+ out.push(...this.#space(i, prev, rows));
1722
+ prev = rows;
1723
+ }
1724
+ if (runningHidden > 0) {
1725
+ const p = palette();
1726
+ out.push(` ${p.dim}└ +${runningHidden} more running${p.reset}`);
1727
+ }
1728
+ return out;
1729
+ }
1730
+ /** R3i — the open stretch's phase. It is THINKING while a thinking
1731
+ * cell of it is still open and no call has started; otherwise it is
1732
+ * ACTING. The tense follows the phase, and the phase is what the
1733
+ * human is watching happen. */
1734
+ #stretchPhase(seg) {
1735
+ return seg.thinkingSince !== null && seg.reads === 0 && seg.edits === 0 && seg.others.size === 0 ? "thinking" : "acting";
1736
+ }
1737
+ /** R3i — the open stretch's terms, in the shape the line renders. */
1738
+ #stretchTerms(seg) {
1739
+ const ms = seg.thinkingMs + (seg.thinkingSince === null ? 0 : Math.max(0, Date.now() - seg.thinkingSince));
1740
+ // R3i: a call in TROUBLE does not contribute to the work terms.
1741
+ // The counts are taken at toolStart, before the outcome is known,
1742
+ // so a denied write would otherwise fold as `wrote 1 file` beside
1743
+ // the clause admitting it was refused — the line saying, in one
1744
+ // breath, that the file was written and that it was not. The
1745
+ // trouble clause is where those calls are counted.
1746
+ const bad = new Map();
1747
+ for (const j of seg.cells) {
1748
+ const c = this.#cells[j];
1749
+ if (c === undefined || c.kind !== "tool" || !this.#cellInTrouble(j))
1750
+ continue;
1751
+ bad.set(c.name, (bad.get(c.name) ?? 0) + 1);
1752
+ }
1753
+ const net = (name, n) => Math.max(0, n - (bad.get(name) ?? 0));
1754
+ const calls = [];
1755
+ if (net("read_file", seg.reads) > 0)
1756
+ calls.push(["read_file", net("read_file", seg.reads)]);
1757
+ if (net("edit_file", seg.edits) > 0)
1758
+ calls.push(["edit_file", net("edit_file", seg.edits)]);
1759
+ for (const [name, n] of seg.others)
1760
+ if (net(name, n) > 0)
1761
+ calls.push([name, net(name, n)]);
1762
+ const targets = [];
1763
+ for (const j of seg.cells) {
1764
+ const c = this.#cells[j];
1765
+ if (c !== undefined && c.kind === "tool")
1766
+ targets.push(toolTarget(c.name, JSON.parse(c.inputFull)));
1767
+ }
1768
+ return { thoughtSeconds: Math.round(ms / 1000), calls, targets, trouble: this.#segmentTroubleTerms(seg) };
1769
+ }
1552
1770
  /** The live region's scalar — the unit tests assert the cap directly
1553
1771
  * (the e2e gate pins the screen consequence). W11: the formula's
1554
1772
  * blanks are join artifacts — the count includes them (they are real
@@ -1696,18 +1914,7 @@ export class Body {
1696
1914
  // by construction), so the marker can never point at a cell the
1697
1915
  // key would not take — which is the only way a focus marker is
1698
1916
  // worth having.
1699
- const focus = this.#focusIndex();
1700
- let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
1701
- for (let i = this.#committed; i < this.#cells.length; i += 1) {
1702
- const cell = this.#cells[i];
1703
- const rows = cellComponent(cell).render(W, ctx);
1704
- // the head row carries the affordance; the tint lands on it and
1705
- // nowhere else, which is what makes "exactly one" structural
1706
- if (i === focus && rows.length > 0)
1707
- rows[0] = focusToken(rows[0], W);
1708
- liveLines.push(...this.#space(i, prev, rows));
1709
- prev = rows;
1710
- }
1917
+ liveLines = this.#liveProjection(W, ctx);
1711
1918
  }
1712
1919
  // 3. the FORCE commits — the live region's hard cap H−1: overflow
1713
1920
  // commits the oldest live cell UNCONDITIONALLY (the one sharp
@@ -1726,21 +1933,9 @@ export class Body {
1726
1933
  // visible above it.
1727
1934
  this.#markSpilled(this.#committed);
1728
1935
  this.#commitCell(this.#committed, W, ctx);
1729
- liveLines = [];
1730
- {
1731
- // TUI2-R2 ⑤: the focus re-derives after a commit — the cell it
1732
- // pointed at may have just left the live region
1733
- const focus = this.#focusIndex();
1734
- let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
1735
- for (let i = this.#committed; i < this.#cells.length; i += 1) {
1736
- const cell = this.#cells[i];
1737
- const rows = cellComponent(cell).render(W, ctx);
1738
- if (i === focus && rows.length > 0)
1739
- rows[0] = focusToken(rows[0], W);
1740
- liveLines.push(...this.#space(i, prev, rows));
1741
- prev = rows;
1742
- }
1743
- }
1936
+ // TUI2-R2 ⑤: the focus re-derives after a commit — the cell it
1937
+ // pointed at may have just left the live region.
1938
+ liveLines = this.#liveProjection(W, ctx);
1744
1939
  }
1745
1940
  // 4. the geometry — the live region's first row:
1746
1941
  // liveTop = min(totalCommitted, H - liveRows) + 1 — the screen
@@ -1880,7 +2075,29 @@ export class Body {
1880
2075
  #space(i, prev, rows) {
1881
2076
  if (i > 0 && this.#cells[i]?.kind === "md" && this.#cells[i - 1]?.kind === "md")
1882
2077
  return rows;
1883
- return bodySpacing(prev, rows);
2078
+ return bodySpacing(this.#lastDrawn(i, prev), rows);
2079
+ }
2080
+ /**
2081
+ * R3i — the previous DRAWN sibling, not the previous cell.
2082
+ *
2083
+ * The spacing formula reads what stood above; a cell that rendered
2084
+ * nothing did not stand above anything. Since R3d whole families of
2085
+ * cells render `[]` — the members a fold speaks for — and the
2086
+ * formula was reading that empty array as "a zero-row sibling", so a
2087
+ * multi-row block following a fold lost the blank that belongs above
2088
+ * it. The defect predates this round (any folded turn followed by a
2089
+ * raw block has it); R3i's projection is what finally put a test on
2090
+ * the path.
2091
+ */
2092
+ #lastDrawn(i, prev) {
2093
+ if (prev !== null && prev.length > 0)
2094
+ return prev;
2095
+ for (let j = i - 1; j >= 0; j -= 1) {
2096
+ const cached = this.#lineCache[j];
2097
+ if (cached !== null && cached !== undefined && cached.length > 0)
2098
+ return cached;
2099
+ }
2100
+ return prev;
1884
2101
  }
1885
2102
  /** Commit the cell at index i: render + cache its lines (immutable —
1886
2103
  * the force-committed form freezes at the current render), advance
@@ -1937,6 +2154,67 @@ export class Body {
1937
2154
  * denial keeps all twenty rows. The alternative is a screen that says
1938
2155
  * `✦ thought 3s · 20 reads` while a write was refused inside it.
1939
2156
  */
2157
+ /**
2158
+ * R3i — the trouble the stretch met, as the line's own terms.
2159
+ *
2160
+ * Law 1.3: an outcome is stated in WORDS, "the only form that
2161
+ * survives a pipe". So the kind is a different word, never a
2162
+ * different colour — `2 failed`, `1 denied`, `1 interrupted` — and
2163
+ * the failure's identity rides with it. In this phase the terms are
2164
+ * only DRAWN (the live line names trouble the moment it happens);
2165
+ * whether trouble still blocks the fold is the next phase's ruling.
2166
+ */
2167
+ /** R3i — is this cell one the fold must not count as work done? */
2168
+ #cellInTrouble(i) {
2169
+ const c = this.#cells[i];
2170
+ if (c === undefined || c.kind !== "tool")
2171
+ return false;
2172
+ return c.isError || c.reason !== null || c.verdict?.decision === "denied";
2173
+ }
2174
+ #segmentTroubleTerms(seg) {
2175
+ let failed = 0;
2176
+ let denied = 0;
2177
+ let interrupted = 0;
2178
+ let what = "";
2179
+ for (const j of seg.cells) {
2180
+ const c = this.#cells[j];
2181
+ if (c === undefined || c.kind !== "tool")
2182
+ continue;
2183
+ // WHICH call, and WHY. The target alone answers the first and
2184
+ // not the second, and for a policy denial the second is the
2185
+ // whole point: `sub/out.txt` does not tell a human that plan
2186
+ // mode is read-only, and that sentence is the one they act
2187
+ // on. Law 1.3's own words — an outcome is stated in words —
2188
+ // and the ladder cuts this clause last, so it degrades to the
2189
+ // target before it disappears.
2190
+ const named = () => {
2191
+ const t = toolTarget(c.name, JSON.parse(c.inputFull));
2192
+ const why = c.verdict?.reason ?? c.reason;
2193
+ return why === null || why === undefined || why === "" || why === "interrupted" ? t : `${t} (${why})`;
2194
+ };
2195
+ if (c.verdict?.decision === "denied" || (c.reason !== null && c.reason !== "interrupted" && /denied/i.test(c.reason))) {
2196
+ denied += 1;
2197
+ if (what === "")
2198
+ what = named();
2199
+ }
2200
+ else if (c.reason === "interrupted") {
2201
+ interrupted += 1;
2202
+ }
2203
+ else if (c.isError || c.reason !== null) {
2204
+ failed += 1;
2205
+ if (what === "")
2206
+ what = named();
2207
+ }
2208
+ }
2209
+ const out = [];
2210
+ if (failed > 0)
2211
+ out.push(["failed", failed, what]);
2212
+ if (denied > 0)
2213
+ out.push(["denied", denied, what]);
2214
+ if (interrupted > 0)
2215
+ out.push(["interrupted", interrupted, ""]);
2216
+ return out;
2217
+ }
1940
2218
  #segmentHasTrouble(seg) {
1941
2219
  // R3g (fable, 2026-08-28): a DENIED call is the case this rule
1942
2220
  // exists for, and it was the one case the predicate could not
@@ -2005,6 +2283,11 @@ export class Body {
2005
2283
  const cell = this.#cells[i];
2006
2284
  if (cell.kind !== "thinking" && cell.kind !== "tool")
2007
2285
  return false;
2286
+ // R3i phase 5: an answered question is WORDS (law 1.7). It commits
2287
+ // when it is done, like prose, and is never held for a fold that
2288
+ // is not going to speak for it.
2289
+ if (cell.kind === "tool" && cell.name === "ask_user")
2290
+ return false;
2008
2291
  const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
2009
2292
  if (turn === undefined || turn !== this.#turns[this.#turns.length - 1])
2010
2293
  return false;
@@ -2027,15 +2310,27 @@ export class Body {
2027
2310
  // The quiet turn is the same rule seen from one side: its single
2028
2311
  // segment never closes until the settle, so it holds exactly as
2029
2312
  // 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
2313
+ // DECLARED SUPERSESSION (R3i phase 3, owner-ruled) THE HOLD IS
2314
+ // THE SEGMENT'S AGAIN.
2315
+ //
2316
+ // R3d had made it the TURN's, so a turn's whole work waited on the
2317
+ // settle and every one of its counts then landed on ONE line above
2318
+ // all of its prose. The shape the owner asked for is one summary
2319
+ // per stretch, standing with the prose that stretch led to — which
2320
+ // requires a stretch to commit when its own text arrives.
2321
+ //
2322
+ // R3d's stated reason for leaving the segment was R3b's disease (a
2323
+ // chatty model turning every call into its own `✦ thought 2s ·
2324
+ // 1 read` row); the cures are the two rules R3b never had — a fold
2325
+ // must absorb at least two rows, and a stretch of exactly one call
2326
+ // names its TARGET rather than its count.
2327
+ //
2328
+ // The force-commit path still overrides this (a stretch too big for
2034
2329
  // the screen spills and renders normally); that is the honest
2035
2330
  // degradation, marked `spilled`.
2036
2331
  const seg = this.#segmentOf(i);
2037
2332
  if (seg !== null)
2038
- return !turn.ended;
2333
+ return seg.closedAt === null;
2039
2334
  // no segment (the pipe path's shape) — W14's original test, kept
2040
2335
  // so a cell that never got a segment behaves as it used to.
2041
2336
  if (!turn.ended && !turn.hasText)
@@ -2129,45 +2424,44 @@ export class Body {
2129
2424
  // The quiet turn keeps its fold because there IS no recap line
2130
2425
  // to carry it: a turn with no text is the fold, and W14's gates
2131
2426
  // pin that shape.
2132
- if (turn !== undefined && seg !== null && seg.closedAt !== null && !this.#turnSpilled(turn) && turn.ended && this.#turnCells(turn) >= 2 && !this.#turnHasTrouble(turn)) {
2133
- if (!turn.folded) {
2427
+ // DECLARED SUPERSESSION (R3i phase 3, owner-ruled) THE FOLD IS
2428
+ // THE SEGMENT'S, AND TROUBLE DOES NOT STOP IT.
2429
+ //
2430
+ // ① the SEGMENT. R3d folded the turn; the owner's shape is one
2431
+ // summary per stretch of work, standing with the prose that
2432
+ // stretch led to. R3b's disease is answered by the two rules
2433
+ // below rather than by leaving the segment: a fold must
2434
+ // absorb at least TWO rows, and a stretch of exactly one
2435
+ // call names its target (see stretchTerms).
2436
+ //
2437
+ // ② TROUBLE. R3b refused to fold any run holding a failure,
2438
+ // and R3g extended that to interrupts. Law 1.3 governs
2439
+ // marks versus WORDS and never granted a failure a
2440
+ // permanent row; law 1.7 says "Work folds, words do not".
2441
+ // So the work folds and the outcome words ride the line —
2442
+ // `1 denied: .env` — and the human sees, without pressing
2443
+ // anything, that trouble happened, on which call, and what
2444
+ // happened. The stderr is behind the key, because it is
2445
+ // detail, not outcome. The cost R3b priced as rare measured
2446
+ // at 2 failures in 28 calls in the 0.16.7 dogfood, with
2447
+ // zero folds as the result.
2448
+ if (turn !== undefined && seg !== null && seg.closedAt !== null && !seg.spilled && seg.cells.length >= 2) {
2449
+ if (!seg.folded) {
2134
2450
  seg.folded = true;
2135
2451
  seg.headCell = i;
2136
2452
  turn.folded = true;
2137
- // A9 (ruling R2, mock A): the user chip rides the fold —
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.
2453
+ // DECLARED SUPERSESSION (R3i phase 3) A9 NARROWS: the
2454
+ // fold carries WORK, never the human's words.
2156
2455
  //
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;
2164
- return turnFold({
2165
- words: quiet ? turn.words : "",
2166
- thoughtSeconds: seconds,
2167
- reads: turn.reads,
2168
- edits: turn.edits,
2169
- others: [...turn.others],
2170
- }, W);
2456
+ // A9 put the user's words on the fold as the SGR-7 chip.
2457
+ // Measured under R3i, that prints them twice: the chip
2458
+ // BAND commits on the frame it is pushed (it always has —
2459
+ // `#held` exempts non-thinking/tool cells), so a quiet
2460
+ // turn showed ` x ` on its own row and ` x ` again inside
2461
+ // the fold directly beneath it. The band is the record of
2462
+ // what was asked; this line is the record of what was
2463
+ // done. One fact, one row, each.
2464
+ return stretchLine({ ...this.#stretchTerms(seg), phase: "settled" }, W);
2171
2465
  }
2172
2466
  return [];
2173
2467
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.16.7",
3
+ "version": "0.17.0",
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.7"
38
+ "@vincemakes/kiso-tui-cells": "0.17.0"
39
39
  }
40
40
  }