@vincemakes/kiso-tui 0.16.6 → 0.16.8

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, 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
@@ -174,7 +174,7 @@ function openSegment(turn, now) {
174
174
  const last = turn.segments[turn.segments.length - 1];
175
175
  if (last !== undefined && last.closedAt === null)
176
176
  return last;
177
- const fresh = { openedAt: now, closedAt: null, reads: 0, edits: 0, others: new Map(), folded: false, spilled: false, headCell: null, cells: [] };
177
+ const fresh = { openedAt: now, closedAt: null, reads: 0, edits: 0, others: new Map(), seen: new Map(), folded: false, spilled: false, headCell: null, cells: [] };
178
178
  turn.segments.push(fresh);
179
179
  return fresh;
180
180
  }
@@ -442,7 +442,7 @@ export class Body {
442
442
  // W14: the turn boundary — the record the fold-hold's release
443
443
  // state machine reads; the cell carries the record's index. A9:
444
444
  // 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: [] });
445
+ 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
446
  this.#cells.push({ kind: "user", text, done: true, turn: this.#turns.length - 1 });
447
447
  this.#mark();
448
448
  }
@@ -520,16 +520,41 @@ export class Body {
520
520
  // order). The CLI's recap counts the same way (edit_file).
521
521
  const turn = this.#turns[this.#turns.length - 1];
522
522
  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);
523
+ // R3h (fable, 2026-08-29): an OBJECT-counting tool counts the
524
+ // distinct thing, not the act. Reading one file twice used to
525
+ // fold as `read 2 files` — a sentence law 1.3 forbids, and one
526
+ // this product shipped. `bump` is false on the second sighting
527
+ // of a target the term has already counted; an ACT-counting
528
+ // tool (a search, a shell command) always bumps, because two
529
+ // searches for the same pattern really are two searches.
530
+ const target = foldCountsObjects(name) ? toolTarget(name, input) : null;
531
+ const bump = (rec) => {
532
+ if (target === null)
533
+ return true;
534
+ let set = rec.seen.get(name);
535
+ if (set === undefined) {
536
+ set = new Set();
537
+ rec.seen.set(name, set);
538
+ }
539
+ if (set.has(target))
540
+ return false;
541
+ set.add(target);
542
+ return true;
543
+ };
544
+ if (bump(turn)) {
545
+ if (name === "read_file")
546
+ turn.reads += 1;
547
+ else if (name === "edit_file")
548
+ turn.edits += 1;
549
+ else
550
+ turn.others.set(name, (turn.others.get(name) ?? 0) + 1);
551
+ }
529
552
  // R3b: and into the SEGMENT, which opens here when this is the
530
- // first work since the last text block.
553
+ // first work since the last text block. Its set is its OWN — a
554
+ // file read once per segment is one file in each segment's
555
+ // terms and one file in the turn's.
531
556
  const seg = openSegment(turn, Date.now());
532
- if (seg !== null) {
557
+ if (seg !== null && bump(seg)) {
533
558
  if (name === "read_file")
534
559
  seg.reads += 1;
535
560
  else if (name === "edit_file")
@@ -765,6 +790,25 @@ export class Body {
765
790
  break;
766
791
  }
767
792
  }
793
+ // R3g (fable D3, 2026-08-28): an INTERRUPTED tool never receives a
794
+ // result, so its cell stays `done: false` — and the commit loop
795
+ // stops at the first cell that is not done. One esc mid-tool
796
+ // therefore parked the commit pointer for the REST of the
797
+ // session: every later turn's rows piled up in the live region
798
+ // and only ever left it through the force-commit cap. The turn's
799
+ // end is the boundary that closes them, exactly as it closes an
800
+ // open thinking cell. `reason` is set so the row keeps its words
801
+ // AND so #segmentHasTrouble holds the turn unfolded — an
802
+ // interruption is trouble, and law 1.3 says trouble is never
803
+ // summarised away.
804
+ for (const c of this.#cells) {
805
+ if (c.kind === "tool" && !c.done) {
806
+ c.state = "done";
807
+ c.reason = "interrupted";
808
+ c.doneAt = Date.now();
809
+ c.done = true;
810
+ }
811
+ }
768
812
  // the QUIET turn: an open thinking cell closes at the boundary —
769
813
  // its natural closer is the text's arrival (never comes here — the
770
814
  // text-less turn), so without this the fold could never commit AT
@@ -963,7 +1007,8 @@ export class Body {
963
1007
  // expand in this method does, and they are the cells' OWN renders,
964
1008
  // so the expansion cannot drift from what was folded.
965
1009
  const seg = this.#segmentOf(idx);
966
- if (seg !== null && seg.headCell === idx) {
1010
+ const foldTurn = (cell.kind === "thinking" || cell.kind === "tool") && cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
1011
+ if (seg !== null && foldTurn !== undefined && seg.headCell === idx) {
967
1012
  const p = palette();
968
1013
  const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
969
1014
  const back = `${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
@@ -1015,7 +1060,22 @@ export class Body {
1015
1060
  }
1016
1061
  run = [];
1017
1062
  };
1018
- for (const j of seg.cells) {
1063
+ // R3f the expansion covers the WHOLE TURN, every segment.
1064
+ //
1065
+ // R3d moved the fold to the turn while the expansion kept
1066
+ // walking one segment, so a turn that spoke between calls
1067
+ // folded to a line claiming `3 reads · 1 edit · 1 shell` whose
1068
+ // key opened only the reads: the edit and the shell were on no
1069
+ // surface and reachable by no key. That is the one thing this
1070
+ // round's own first gate forbids — the work is never
1071
+ // unreachable — and it is worse than never folding, because the
1072
+ // line names work it then withholds.
1073
+ //
1074
+ // A run still BREAKS at a non-explore cell, so the segment
1075
+ // boundaries survive where they carry meaning (the write that
1076
+ // splits two explore runs); they simply no longer bound what
1077
+ // the key can reach.
1078
+ for (const j of foldTurn.segments.flatMap((sg) => sg.cells).sort((a, b) => a - b)) {
1019
1079
  if (j < idx)
1020
1080
  continue;
1021
1081
  const c = this.#cells[j];
@@ -1035,7 +1095,11 @@ export class Body {
1035
1095
  // terms; each run below states what IT did, in the rollup's. Two
1036
1096
  // scales, one wording each — the header used to borrow the run's
1037
1097
  // sentence, which read as the same run twice.
1038
- return { kind: "appended", lines: [`${p.bold}✦${p.reset} expanded · ${escapeTerminal(foldMeta(seg))} · ${back}`, ...rows] };
1098
+ // the header names what the FOLD said the turn's terms — so the
1099
+ // line you pressed and the block it opens agree. It used to name
1100
+ // segment 1's, which contradicted the fold above it.
1101
+ const head = foldTerms(foldTurn.reads, foldTurn.edits, [...foldTurn.others]);
1102
+ return { kind: "appended", lines: [`${p.bold}✦${p.reset} expanded · ${escapeTerminal(head.length === 0 ? "thinking" : head.join(" · "))} · ${back}`, ...rows] };
1039
1103
  }
1040
1104
  if (cell.kind !== "tool")
1041
1105
  return { kind: "none" };
@@ -1675,6 +1739,17 @@ export class Body {
1675
1739
  // edge — the cap scalar is asserted by the gates). W22: the
1676
1740
  // queue band shrinks the cap by its rows (empty queue → H−4).
1677
1741
  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)
1742
+ // R3f: the cell about to be force-committed marks its segment
1743
+ // SPILLED. The rule was written at R3b — "a segment too big for
1744
+ // the screen already has rows in the scrollback that cannot be
1745
+ // taken back, so it renders normally and does not collapse" —
1746
+ // and then never wired: `spilled` had a declaration, an
1747
+ // initializer and a read, and nothing ever set it. The read was
1748
+ // therefore vacuously true, so a 43-call turn force-committed
1749
+ // thirty expanded rows and STILL printed `✦ thought 103s · 43
1750
+ // reads` underneath them, claiming as folded the work standing
1751
+ // visible above it.
1752
+ this.#markSpilled(this.#committed);
1678
1753
  this.#commitCell(this.#committed, W, ctx);
1679
1754
  liveLines = [];
1680
1755
  {
@@ -1888,7 +1963,12 @@ export class Body {
1888
1963
  * `✦ thought 3s · 20 reads` while a write was refused inside it.
1889
1964
  */
1890
1965
  #segmentHasTrouble(seg) {
1891
- return this.#segmentTools(seg).some((c) => c.isError || c.reason !== null);
1966
+ // R3g (fable, 2026-08-28): a DENIED call is the case this rule
1967
+ // exists for, and it was the one case the predicate could not
1968
+ // see — a denial carrying no `reason` string leaves isError
1969
+ // false and reason null, so `✦ thought 3s · 20 reads` could
1970
+ // stand over a refused write. The verdict is the record of it.
1971
+ return this.#segmentTools(seg).some((c) => c.isError || c.reason !== null || c.verdict?.decision === "denied");
1892
1972
  }
1893
1973
  /** R3b — the segment's TOOL cells, in order. */
1894
1974
  #segmentTools(seg) {
@@ -1900,6 +1980,30 @@ export class Body {
1900
1980
  }
1901
1981
  return out;
1902
1982
  }
1983
+ /** R3f — the cell is leaving the live region under the screen's hard
1984
+ * cap, so its segment can no longer be represented by a fold. */
1985
+ #markSpilled(i) {
1986
+ const seg = this.#segmentOf(i);
1987
+ if (seg !== null)
1988
+ seg.spilled = true;
1989
+ }
1990
+ /** R3f — did ANY of the turn's segments spill? The fold is the
1991
+ * TURN's, so one spilled segment makes the whole turn unfoldable:
1992
+ * a line claiming the turn's counts cannot stand under rows that
1993
+ * already show part of that same work. */
1994
+ #turnSpilled(turn) {
1995
+ return turn.segments.some((seg) => seg.spilled);
1996
+ }
1997
+ /** R3d — the turn's cells and its trouble, across every segment. */
1998
+ #turnCells(turn) {
1999
+ let n = 0;
2000
+ for (const seg of turn.segments)
2001
+ n += seg.cells.length;
2002
+ return n;
2003
+ }
2004
+ #turnHasTrouble(turn) {
2005
+ return turn.segments.some((seg) => this.#segmentHasTrouble(seg));
2006
+ }
1903
2007
  /** R3b — how many cells the segment holds. The fold's threshold reads
1904
2008
  * it; nothing else needs it, so it is counted rather than tracked. */
1905
2009
  #segmentCells(seg) {
@@ -1948,9 +2052,15 @@ export class Body {
1948
2052
  // The quiet turn is the same rule seen from one side: its single
1949
2053
  // segment never closes until the settle, so it holds exactly as
1950
2054
  // it always did.
2055
+ // R3d: the hold is the TURN's. A turn's work has no committed form
2056
+ // until the turn ends, because one line stands for all of it — and
2057
+ // a row already in the scrollback cannot be replaced by that line.
2058
+ // The force-commit path still overrides this (a turn too big for
2059
+ // the screen spills and renders normally); that is the honest
2060
+ // degradation, marked `spilled`.
1951
2061
  const seg = this.#segmentOf(i);
1952
2062
  if (seg !== null)
1953
- return seg.closedAt === null;
2063
+ return !turn.ended;
1954
2064
  // no segment (the pipe path's shape) — W14's original test, kept
1955
2065
  // so a cell that never got a segment behaves as it used to.
1956
2066
  if (!turn.ended && !turn.hasText)
@@ -2027,8 +2137,25 @@ export class Body {
2027
2137
  // 0s · 1 shell` says strictly less than `shell make build ·
2028
2138
  // exit 0`. The fold exists to stop a screen filling with work
2029
2139
  // 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) {
2140
+ // R3d (owner, 2026-08-28) a segment folds only on a QUIET turn.
2141
+ //
2142
+ // R3b folded every closed segment, and in use that was wrong for
2143
+ // a reason the design questions never surfaced: a model narrates
2144
+ // between calls, so a turn is not two or three segments, it is
2145
+ // one per tool. Every call became its own `✦ thought 2s · 1 read`
2146
+ // row — the same row count the fold exists to remove, now saying
2147
+ // less. The screen is not improved by summarising one thing.
2148
+ //
2149
+ // The turn's ONE line (renderRecap, R3d) carries the work now,
2150
+ // which is where it always belonged: it is already emitted once
2151
+ // per turn, in the right place, and it only needed to say what
2152
+ // the turn DID rather than "43 tools".
2153
+ //
2154
+ // The quiet turn keeps its fold because there IS no recap line
2155
+ // to carry it: a turn with no text is the fold, and W14's gates
2156
+ // pin that shape.
2157
+ if (turn !== undefined && seg !== null && seg.closedAt !== null && !this.#turnSpilled(turn) && turn.ended && this.#turnCells(turn) >= 2 && !this.#turnHasTrouble(turn)) {
2158
+ if (!turn.folded) {
2032
2159
  seg.folded = true;
2033
2160
  seg.headCell = i;
2034
2161
  turn.folded = true;
@@ -2041,19 +2168,30 @@ export class Body {
2041
2168
  // (turnFold is W-aware — the ONE row never trips
2042
2169
  // invariant ①).
2043
2170
  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));
2171
+ // R3g (fable, 2026-08-28) DECLARED SUPERSESSION: both
2172
+ // branches read the SAME number now, `thoughtSeconds`,
2173
+ // the measure the kernel took and handed to endTurn.
2174
+ // The non-quiet branch used to re-derive a wall clock
2175
+ // from the segment's opening and print it under the word
2176
+ // "thought" a different quantity wearing the same
2177
+ // label: a turn that thought 1s and then ran a 40s shell
2178
+ // said "thought 41s". The fold only ever renders after
2179
+ // endTurn (the gate below requires `turn.ended`), so the
2180
+ // honest number is always available by the time it runs.
2181
+ //
2182
+ // The terms are the TURN's, not the segment's: R3d folds
2183
+ // a turn's work into ONE line wherever the first work
2184
+ // lands. A per-segment line put a row on screen for every
2185
+ // break in the model's narration, which on a chatty model
2186
+ // is one row per tool — the row count the fold exists to
2187
+ // remove.
2188
+ const seconds = turn.thoughtSeconds;
2051
2189
  return turnFold({
2052
2190
  words: quiet ? turn.words : "",
2053
2191
  thoughtSeconds: seconds,
2054
- reads: seg.reads,
2055
- edits: seg.edits,
2056
- others: [...seg.others],
2192
+ reads: turn.reads,
2193
+ edits: turn.edits,
2194
+ others: [...turn.others],
2057
2195
  }, W);
2058
2196
  }
2059
2197
  return [];
@@ -2112,7 +2250,14 @@ export class Body {
2112
2250
  // text's release they are — the natural loop commits the run in
2113
2251
  // one frame; the force-commit's early commits degrade to the
2114
2252
  // individual rows, the members render normally after).
2115
- if (!members.every((c) => c.done))
2253
+ // R3g (2026-08-28): ...and no member is in TROUBLE. A rollup
2254
+ // says "explored 3 paths" — a sentence a failed or interrupted
2255
+ // call makes false, and the row it replaces was the only place
2256
+ // that failure had words. Law 1.3 at the scale of a run: the
2257
+ // same rule #segmentHasTrouble applies to the fold. Found when
2258
+ // R3g's interrupt-closing made an aborted call `done`, which
2259
+ // let a run it never finished roll up as if it had.
2260
+ if (!members.every((c) => c.done && !c.isError && c.reason === null))
2116
2261
  return cellComponent(cell).render(W, ctx);
2117
2262
  this.#rolledHeads.add(head);
2118
2263
  let total = 0;
@@ -2634,6 +2779,32 @@ export class Body {
2634
2779
  /** Invariant ①: every emitted line fits the width — a violation is a
2635
2780
  * CRASH with the diagnostic, never a silent truncate. */
2636
2781
  #checked(line, W) {
2782
+ // Invariant ①b (R3f): a ROW IS ONE PHYSICAL ROW.
2783
+ //
2784
+ // The defect this catches shipped in 0.16.6 and smashed the
2785
+ // composer. `escapeTerminal` keeps `\n` (it strips C0 except tab
2786
+ // and newline), and `charWidth(0x0A)` is 1 — so a newline counts as
2787
+ // ONE CELL in `visibleWidth`, and every width check in the product,
2788
+ // invariant ① included, waves a multi-line string through as a
2789
+ // single row of legal width. `#emitDiff` then paints it as
2790
+ // `CUP(row,1) + EL + content`, the terminal's ONLCR moves the
2791
+ // cursor down at the newline, and the tail lands on whatever
2792
+ // physical row is there — the box rail, the input row. The diff
2793
+ // then adopts `desired` as the screen's truth, so the corruption
2794
+ // SURVIVES: the self-healing property this renderer is built on
2795
+ // ("a wrong row is repaired by the next frame, because the
2796
+ // difference includes it") is exactly what a lying `#screen`
2797
+ // breaks.
2798
+ //
2799
+ // Width was never the whole invariant — it was the half we
2800
+ // noticed. A row that occupies two physical rows violates the
2801
+ // geometry as surely as one that overruns the width, and it does
2802
+ // so INVISIBLY to a width check. `\r` is here for the same reason
2803
+ // (it moves the cursor to column 1).
2804
+ const bad = /[\n\r]/.exec(line);
2805
+ if (bad !== null) {
2806
+ 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))}`);
2807
+ }
2637
2808
  const w = visibleWidth(line);
2638
2809
  if (w > W) {
2639
2810
  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.6",
3
+ "version": "0.16.8",
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.6"
38
+ "@vincemakes/kiso-tui-cells": "0.16.8"
39
39
  }
40
40
  }