@vincemakes/kiso-tui 0.16.6 → 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.
@@ -765,6 +765,25 @@ export class Body {
765
765
  break;
766
766
  }
767
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
+ }
768
787
  // the QUIET turn: an open thinking cell closes at the boundary —
769
788
  // its natural closer is the text's arrival (never comes here — the
770
789
  // text-less turn), so without this the fold could never commit AT
@@ -963,7 +982,8 @@ export class Body {
963
982
  // expand in this method does, and they are the cells' OWN renders,
964
983
  // so the expansion cannot drift from what was folded.
965
984
  const seg = this.#segmentOf(idx);
966
- if (seg !== null && seg.headCell === 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) {
967
987
  const p = palette();
968
988
  const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
969
989
  const back = `${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
@@ -1015,7 +1035,22 @@ export class Body {
1015
1035
  }
1016
1036
  run = [];
1017
1037
  };
1018
- for (const j of seg.cells) {
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)) {
1019
1054
  if (j < idx)
1020
1055
  continue;
1021
1056
  const c = this.#cells[j];
@@ -1035,7 +1070,11 @@ export class Body {
1035
1070
  // terms; each run below states what IT did, in the rollup's. Two
1036
1071
  // scales, one wording each — the header used to borrow the run's
1037
1072
  // sentence, which read as the same run twice.
1038
- return { kind: "appended", lines: [`${p.bold}✦${p.reset} expanded · ${escapeTerminal(foldMeta(seg))} · ${back}`, ...rows] };
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] };
1039
1078
  }
1040
1079
  if (cell.kind !== "tool")
1041
1080
  return { kind: "none" };
@@ -1675,6 +1714,17 @@ export class Body {
1675
1714
  // edge — the cap scalar is asserted by the gates). W22: the
1676
1715
  // queue band shrinks the cap by its rows (empty queue → H−4).
1677
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);
1678
1728
  this.#commitCell(this.#committed, W, ctx);
1679
1729
  liveLines = [];
1680
1730
  {
@@ -1888,7 +1938,12 @@ export class Body {
1888
1938
  * `✦ thought 3s · 20 reads` while a write was refused inside it.
1889
1939
  */
1890
1940
  #segmentHasTrouble(seg) {
1891
- return this.#segmentTools(seg).some((c) => c.isError || c.reason !== null);
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");
1892
1947
  }
1893
1948
  /** R3b — the segment's TOOL cells, in order. */
1894
1949
  #segmentTools(seg) {
@@ -1900,6 +1955,30 @@ export class Body {
1900
1955
  }
1901
1956
  return out;
1902
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
+ }
1903
1982
  /** R3b — how many cells the segment holds. The fold's threshold reads
1904
1983
  * it; nothing else needs it, so it is counted rather than tracked. */
1905
1984
  #segmentCells(seg) {
@@ -1948,9 +2027,15 @@ export class Body {
1948
2027
  // The quiet turn is the same rule seen from one side: its single
1949
2028
  // segment never closes until the settle, so it holds exactly as
1950
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`.
1951
2036
  const seg = this.#segmentOf(i);
1952
2037
  if (seg !== null)
1953
- return seg.closedAt === null;
2038
+ return !turn.ended;
1954
2039
  // no segment (the pipe path's shape) — W14's original test, kept
1955
2040
  // so a cell that never got a segment behaves as it used to.
1956
2041
  if (!turn.ended && !turn.hasText)
@@ -2027,8 +2112,25 @@ export class Body {
2027
2112
  // 0s · 1 shell` says strictly less than `shell make build ·
2028
2113
  // exit 0`. The fold exists to stop a screen filling with work
2029
2114
  // 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) {
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)) {
2133
+ if (!turn.folded) {
2032
2134
  seg.folded = true;
2033
2135
  seg.headCell = i;
2034
2136
  turn.folded = true;
@@ -2041,19 +2143,30 @@ export class Body {
2041
2143
  // (turnFold is W-aware — the ONE row never trips
2042
2144
  // invariant ①).
2043
2145
  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));
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;
2051
2164
  return turnFold({
2052
2165
  words: quiet ? turn.words : "",
2053
2166
  thoughtSeconds: seconds,
2054
- reads: seg.reads,
2055
- edits: seg.edits,
2056
- others: [...seg.others],
2167
+ reads: turn.reads,
2168
+ edits: turn.edits,
2169
+ others: [...turn.others],
2057
2170
  }, W);
2058
2171
  }
2059
2172
  return [];
@@ -2112,7 +2225,14 @@ export class Body {
2112
2225
  // text's release they are — the natural loop commits the run in
2113
2226
  // one frame; the force-commit's early commits degrade to the
2114
2227
  // individual rows, the members render normally after).
2115
- 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))
2116
2236
  return cellComponent(cell).render(W, ctx);
2117
2237
  this.#rolledHeads.add(head);
2118
2238
  let total = 0;
@@ -2634,6 +2754,32 @@ export class Body {
2634
2754
  /** Invariant ①: every emitted line fits the width — a violation is a
2635
2755
  * CRASH with the diagnostic, never a silent truncate. */
2636
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
+ }
2637
2783
  const w = visibleWidth(line);
2638
2784
  if (w > W) {
2639
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.6",
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.6"
38
+ "@vincemakes/kiso-tui-cells": "0.16.7"
39
39
  }
40
40
  }