@vincemakes/kiso-tui 0.19.1 → 0.20.1

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.
@@ -12,6 +12,7 @@
12
12
  * lexical order of the path (never localeCompare, whose result depends
13
13
  * on the machine's locale).
14
14
  */
15
+ import { bandHeader } from "@vincemakes/kiso-tui-cells/strings";
15
16
  /** The bound source's item — a repo-relative path and nothing else.
16
17
  * Structural: the CLI passes whatever it likes as long as it has a
17
18
  * path (slice 5 passes exactly this). */
@@ -168,4 +169,4 @@ export declare function atPanelRows(state: {
168
169
  * composer and every panel now share, so a band opens the way everything
169
170
  * else does and the label tells you WHICH band in the same row.
170
171
  */
171
- export declare function bandHeader(label: string, W: number): string;
172
+ export { bandHeader };
package/dist/at-picker.js CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { escapeTerminal, palette } from "./render.js";
16
16
  import { selectionBar, visibleWidth, widthCut } from "./components.js";
17
+ import { bandHeader } from "@vincemakes/kiso-tui-cells/strings";
17
18
  /**
18
19
  * KC3 §5 — the ONE cap. The file list is computed per open with no
19
20
  * index and no watcher, so its cost is bounded here rather than
@@ -263,8 +264,8 @@ export function atPanelRows(state, W) {
263
264
  * composer and every panel now share, so a band opens the way everything
264
265
  * else does and the label tells you WHICH band in the same row.
265
266
  */
266
- export function bandHeader(label, W) {
267
- const p = palette();
268
- const head = `\u2500\u2500\u2500 ${label} `;
269
- return `${p.dim}${widthCut(`${head}${"\u2500".repeat(Math.max(1, W - head.length))}`, Math.max(1, W))}${p.reset}`;
270
- }
267
+ // R8b: bandHeader MOVED to tui-cells/strings.ts — the keys sheet needs
268
+ // it and lives there, and `components.ts` already imports that module,
269
+ // so the dependency only runs one way. Re-exported here so every
270
+ // existing import site is untouched.
271
+ export { bandHeader };
@@ -43,7 +43,7 @@
43
43
  * line-mode bytes byte-for-byte (the e2e guards them).
44
44
  */
45
45
  import { truncateDiff } from "./diff.js";
46
- import { displayWidth } from "./editor.js";
46
+ import { MENU_ITEMS, displayWidth } from "./editor.js";
47
47
  import { leadWidth } from "./width.js"; // W23: the ONE width authority (the editor, #inputRow, and editCol share it)
48
48
  // KC3.5: the panel-slot reads come from the DISPATCHERS — one source
49
49
  // for four reads, so an ask can never render half as an approval.
@@ -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 { ACT_SLOT_ROWS, Container, ROLLUP_NOUN, MOTION_FRAMES, MdStream, bodySpacing, boxBottom, boxTop, cellComponent, exploreCounts, foldCountsObjects, foldTerms, focusToken, exploreRows, foldLine, cutLine, isExploreTool, moreRunningRow, pendingQueueRows, slotPad, slotTail, statusLine, stretchLine, turnFold, visibleWidth, twinkleFrame, } from "./components.js";
55
+ import { ACT_SLOT_ROWS, Container, ROLLUP_NOUN, MOTION_FRAMES, MdStream, bodySpacing, boxBottom, boxTop, cellComponent, exploreCounts, foldCountsObjects, foldTerms, focusToken, exploreRows, foldLine, gutterCut, cutLine, isExploreTool, moreRunningRow, pendingQueueRows, slotPad, slotTail, statusLine, stretchLine, turnFold, visibleWidth, breathFrame, } 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
  // R5 — the transcript viewer's PURE projection. The compositor supplies
@@ -77,6 +77,9 @@ const CHROME_ROWS = 4; // box top + input + box bottom + status — the design
77
77
  * same unbounded height the projection exists to remove; the rest are
78
78
  * COUNTED, never dropped silently. */
79
79
  const LIVE_ACT_HEADS = 3;
80
+ /** R8 — the command band's window: five rows plus a counter, the same
81
+ * budget the composer's own ceiling can afford above it. */
82
+ const MENU_WINDOW = 5;
80
83
  /** W13 / TUI2-R1 (B) — a rolled run's TITLE: the exploration sentence on
81
84
  * a mixed run, W13's verb+count on a single-name one. */
82
85
  function rolledTitle(cell) {
@@ -476,7 +479,7 @@ export class Body {
476
479
  // W14: the turn boundary — the record the fold-hold's release
477
480
  // state machine reads; the cell carries the record's index. A9:
478
481
  // the user's own words ride the record — the fold's leading chip.
479
- this.#turns.push({ ended: false, hasText: false, thoughtSeconds: 0, reads: 0, edits: 0, others: new Map(), seen: new Map(), words: text, folded: false, segments: [] });
482
+ this.#turns.push({ ended: false, hasText: false, begun: false, thoughtSeconds: 0, reads: 0, edits: 0, others: new Map(), seen: new Map(), words: text, folded: false, segments: [] });
480
483
  this.#cells.push({ kind: "user", text, done: true, turn: this.#turns.length - 1 });
481
484
  this.#mark();
482
485
  }
@@ -491,21 +494,35 @@ export class Body {
491
494
  }
492
495
  else {
493
496
  this.#cells.push({ kind: "thinking", text, done: false, turn: this.#turns.length - 1 });
494
- // R3b: thinking is WORK, so it opens a segment too a turn that
495
- // thinks, speaks, then thinks again has two segments, and the
496
- // second one's clock starts here rather than at a tool call it
497
- // may never make.
497
+ // DECLARED SUPERSESSION (R7, owner-ruled 2026-08-31)THINKING
498
+ // IS WORDS, NOT WORK.
498
499
  //
499
- // OPEN then STAMP, in that order: the stamp records the segment
500
- // the cell belongs to, and a stamp taken first records the
501
- // PREVIOUS segment (or none at all) which left the thinking
502
- // row standing outside the fold it should have led.
503
- const seg = openSegment(this.#turns[this.#turns.length - 1], Date.now());
504
- // R3i: the stretch's thinking clock starts HERE at the first
505
- // delta of this stretch, the same moment the CLI starts the
506
- // turn's and stops at the next non-thinking event below.
507
- if (seg !== null && seg.thinkingSince === null)
508
- seg.thinkingSince = Date.now();
500
+ // R3b made thinking open a segment, on the reading that it is
501
+ // work like a tool call. Four rounds of consequences followed
502
+ // from that one classification: folded away with the calls, it
503
+ // became unreachable, and R4's printed ordinal, R5's viewer,
504
+ // R6's subject index and a look-back viewport were each built
505
+ // to hand it back. The owner's ruling is to stop hiding it
506
+ // and then none of those mechanisms is answering a question
507
+ // anyone still asks.
508
+ //
509
+ // So thinking CLOSES the open segment, exactly as text does
510
+ // (see textAppend): a segment is what sits between two of
511
+ // these. It must close rather than merely not-open, because
512
+ // `#committed` is a PREFIX count — a thinking cell cannot
513
+ // commit past a held call, so think → call → think would
514
+ // otherwise flush at the segment's close with the second
515
+ // thought printing BELOW the fold that contains the later
516
+ // call.
517
+ //
518
+ // Consequence, and it is wanted: the segment's thinking clock
519
+ // never starts, so `thought Ns` drops off every fold line by
520
+ // R3h's own zero-term rule. The line stops claiming a fact the
521
+ // paragraph above it already states in full.
522
+ const t0 = this.#turns[this.#turns.length - 1];
523
+ closeSegment(t0, Date.now());
524
+ if (t0 !== undefined)
525
+ t0.begun = true; // R6/D1: the block allocates here
509
526
  // R3i: and the beat starts HERE. Law 1.4 says "a running thought
510
527
  // twinkles", and `#armSpinner`'s own predicate has always
511
528
  // included an open thinking cell — but the only caller was
@@ -514,7 +531,6 @@ export class Body {
514
531
  // derivation, so without the beat they also never ticked: the
515
532
  // row read `thinking 0s` for as long as the model thought.
516
533
  this.#armSpinner();
517
- this.#stampSegment();
518
534
  }
519
535
  this.#mark();
520
536
  }
@@ -630,6 +646,9 @@ export class Body {
630
646
  }
631
647
  }
632
648
  this.#stampSegment();
649
+ const t1 = this.#turns[this.#turns.length - 1];
650
+ if (t1 !== undefined)
651
+ t1.begun = true; // R6/D1: the block allocates here
633
652
  this.#mark();
634
653
  }
635
654
  toolApproval(callId, diff) {
@@ -1410,7 +1429,9 @@ export class Body {
1410
1429
  // always the part a reader could use.
1411
1430
  `${p.bold}✦${p.reset} expanded · ${escapeTerminal(head.length === 0 ? "thinking" : head.join(" · "))} · ${back}`,
1412
1431
  ...body,
1413
- ` ${p.dim}└ end of expansion · ctrl+r opens the one before it${p.reset}`,
1432
+ // R8a: an in-block note takes the block's indent, not a
1433
+ // second corner — the corner opens the body above it.
1434
+ `${p.dim} end of expansion · ctrl+r opens the one before it${p.reset}`,
1414
1435
  ],
1415
1436
  };
1416
1437
  }
@@ -1934,8 +1955,10 @@ export class Body {
1934
1955
  const openSeg = open !== null && open.closedAt === null ? open : null;
1935
1956
  let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
1936
1957
  let stretchDrawn = false;
1958
+ let lastIdx = this.#committed;
1937
1959
  for (let i = this.#committed; i < this.#cells.length; i += 1) {
1938
1960
  const cell = this.#cells[i];
1961
+ lastIdx = i;
1939
1962
  const inOpen = openSeg !== null && openSeg.cells.includes(i);
1940
1963
  if (inOpen) {
1941
1964
  // R4 — the open stretch is ONE contiguous block: its line
@@ -1950,11 +1973,35 @@ export class Body {
1950
1973
  if (stretchDrawn)
1951
1974
  continue;
1952
1975
  stretchDrawn = true;
1976
+ // R7a — a ONE-CELL stretch draws NO line.
1977
+ //
1978
+ // It said `running 1 shell command` directly above a row
1979
+ // reading `● shell npm run check`: the same fact twice, and
1980
+ // fable's R4 review had already named the duplication. R7
1981
+ // then made it a SWALLOW as well — a one-cell segment does
1982
+ // not fold, so that line has no committed counterpart and
1983
+ // vanished at the settle, taking a row off the screen. The
1984
+ // call's own head row is the line; a summary of one thing
1985
+ // is the thing.
1986
+ const single = openSeg.cells.filter((j) => this.#cells[j]?.kind === "tool").length <= 1;
1953
1987
  const rows = [
1954
- ...stretchLine({ ...this.#stretchTerms(openSeg), liveNames: this.#liveNames(openSeg), phase: this.#stretchPhase(openSeg), mark: twinkleFrame(this.#spinnerI) }, W),
1988
+ ...(single
1989
+ ? []
1990
+ : stretchLine({ ...this.#stretchTerms(openSeg), liveNames: this.#liveNames(openSeg), phase: this.#stretchPhase(openSeg), ...(this.#inFlight(openSeg) ? { mark: breathFrame(this.#spinnerI) } : {}) }, W)),
1955
1991
  ...this.#actSlot(openSeg, W, ctx, budget, focus),
1956
1992
  ];
1957
- out.push(...this.#space(i, prev, rows));
1993
+ // R7a — the block TAKES the W11 blank, like everything else.
1994
+ //
1995
+ // CORRECTION of my own R6/D1 change, which removed it. The
1996
+ // reasoning then was that the committed fold is a single row
1997
+ // and single rows take no blank — but `bodySpacing` gives a
1998
+ // blank after any MULTI-row sibling, and the thinking block
1999
+ // above the work is exactly that. So the committed side had
2000
+ // one and the live side did not, and a blank row APPEARED at
2001
+ // every settle, shoving everything below it down. The owner
2002
+ // saw it and said the blank is the correct form; it is, and
2003
+ // the fix is to have it on both sides rather than neither.
2004
+ out.push(...this.#blockSpace(i, prev, rows));
1958
2005
  prev = rows;
1959
2006
  continue;
1960
2007
  }
@@ -1966,8 +2013,52 @@ export class Body {
1966
2013
  out.push(...this.#space(i, prev, rows));
1967
2014
  prev = rows;
1968
2015
  }
2016
+ // R6/D1 — THE BLOCK STANDS FOR THE TURN, not for the stretch.
2017
+ //
2018
+ // R4 made the block's height constant WITHIN a stretch. Between
2019
+ // two stretches it was released and rebuilt, so the live region
2020
+ // breathed by its whole height twice per stretch — and the live
2021
+ // region is anchored to the bottom (liveTop below), so every
2022
+ // committed row on screen moved with it. The owner's report, and
2023
+ // their own formulation of the cure: "once something is at a line
2024
+ // it should not jump up or down — hold the absolute position and
2025
+ // update the content there."
2026
+ //
2027
+ // So when the turn has begun work and no open stretch drew the
2028
+ // block above, it is drawn HERE, after the live cells, with its
2029
+ // top row swapped to the slot's own pad: the closed stretch's
2030
+ // terms are already the committed fold row further up, and
2031
+ // printing them again one screen apart is the duplication A9's
2032
+ // narrowing forbids.
2033
+ //
2034
+ // Projection-only. Commit order, commit timing and every
2035
+ // committed byte are untouched — which is why the transcript this
2036
+ // leaves behind cannot regress: it cannot differ.
2037
+ if (!stretchDrawn && turn !== undefined && !turn.ended && turn.begun) {
2038
+ // R7a: the top row is BLANK, not a `│`. It stands where the
2039
+ // stretch line stands while a stretch is open, and between two
2040
+ // stretches there is no line to draw — a bare gutter there is
2041
+ // a mark on a row with nothing to mark (law 1.3), and it is
2042
+ // the "long vertical line" the owner saw under a finished
2043
+ // turn. The ROW is what holds the height; the glyph never was.
2044
+ const rows = ["", ...this.#actSlot(null, W, ctx, budget, focus)];
2045
+ out.push(...this.#blockSpace(lastIdx, prev, rows)); // R7a: the same blank
2046
+ }
1969
2047
  return out;
1970
2048
  }
2049
+ /** R6/D1 — the turn's most recent CLOSED segment: what the block
2050
+ * shows in the gap between two stretches. */
2051
+ #lastClosedSegment() {
2052
+ const turn = this.#turns[this.#turns.length - 1];
2053
+ if (turn === undefined)
2054
+ return null;
2055
+ for (let i = turn.segments.length - 1; i >= 0; i -= 1) {
2056
+ const seg = turn.segments[i];
2057
+ if (seg.cells.some((j) => j >= this.#committed))
2058
+ return seg;
2059
+ }
2060
+ return null;
2061
+ }
1971
2062
  /**
1972
2063
  * R4 — the standing act slot's rows. EXACTLY `budget` rows in every
1973
2064
  * phase, so the live region's height changes twice per stretch (once
@@ -1995,7 +2086,16 @@ export class Body {
1995
2086
  rows[0] = focusToken(rows[0], W);
1996
2087
  return rows;
1997
2088
  };
1998
- const live = seg.cells.filter((i) => i >= this.#committed);
2089
+ // R6/D1: with no open stretch (the gap BETWEEN two of them) the
2090
+ // slot looks at the turn's last closed segment instead — the call
2091
+ // that just finished keeps its head and its tail, which is R4 B's
2092
+ // rule extended across the boundary. Cells outlive their commit,
2093
+ // so these are live repaints of live rows, never a rewrite of a
2094
+ // committed one.
2095
+ const src = seg ?? this.#lastClosedSegment();
2096
+ if (src === null)
2097
+ return [];
2098
+ const live = src.cells.filter((i) => i >= this.#committed);
1999
2099
  const tools = [];
2000
2100
  for (const i of live)
2001
2101
  if (this.#cells[i]?.kind === "tool")
@@ -2042,31 +2142,142 @@ export class Body {
2042
2142
  out.push(moreRunningRow(hidden, W));
2043
2143
  return out;
2044
2144
  }
2045
- const flight = tools.filter((i) => !toolAt(i).done);
2046
- if (flight.length > 0) {
2047
- // the commonest frame exactly one call, the full budget is
2048
- // the W8 block verbatim, which is what 0.17.0 already drew.
2049
- if (flight.length === 1 && budget >= ACT_SLOT_ROWS)
2050
- return slotPad(tint(flight[0], cellComponent(this.#cells[flight[0]]).render(W, ctx)), budget);
2051
- const heads = flight.slice(0, Math.max(1, Math.min(flight.length, budget - 1, LIVE_ACT_HEADS)));
2052
- const hidden = flight.length - heads.length;
2145
+ // R7a ONE PATH, whether or not anything is in flight.
2146
+ //
2147
+ // There used to be two: the in-flight composition, and a
2148
+ // "last finished call plus its output" composition for the gap
2149
+ // between stretches. The moment the last call of a burst
2150
+ // returned, the block re-composed from four head rows to one
2151
+ // head and a tail a row shorter, so on a full screen every
2152
+ // row above slid DOWN. The block is supposed to change its
2153
+ // CONTENTS, not its shape; the last call returning is not a
2154
+ // reason to redraw the stretch differently.
2155
+ if (tools.length > 0) {
2156
+ // the one-call special case is GONE: the path below draws a
2157
+ // lone running call by its own component (the W8 block
2158
+ // verbatim, which is what 0.17.0 drew) and a lone finished
2159
+ // one as its head plus its output. The special case only
2160
+ // differed once the call SETTLED, where it collapsed to the
2161
+ // bare head row — a three-row shrink the moment a single
2162
+ // call returned.
2163
+ // R7a — EVERY call of the stretch keeps its row, not just the
2164
+ // ones still in flight.
2165
+ //
2166
+ // R4 showed the in-flight calls only, so a finished one left
2167
+ // the block and its target went with it: a four-file burst
2168
+ // ended having shown four names and left none of them, while
2169
+ // the rows below shuffled up one at a time. Two complaints in
2170
+ // one — "I can't see what it read" and "the rows keep moving".
2171
+ //
2172
+ // A call now takes a row when it STARTS and changes in place
2173
+ // when it finishes: `● read x · 1s` becomes the settled head.
2174
+ // Nothing moves, every target stays, and exactly ONE row wears
2175
+ // the breathing mark — the running one — which is the mark's
2176
+ // whole job (§7.4: only the call still running carries one,
2177
+ // because only it is moving).
2178
+ //
2179
+ // The slot's fixed height is what pays for this: the rows are
2180
+ // already allocated, so the names fill blanks rather than
2181
+ // pushing anything.
2182
+ // TRUNCATION NEVER DROPS A CALL THAT IS STILL RUNNING.
2183
+ //
2184
+ // Taking the first N is wrong the moment a burst outlives the
2185
+ // slot: four reads that finished held every row while the
2186
+ // shell still running was cut, so the screen said "4 files"
2187
+ // and showed nothing of the work actually in flight. The
2188
+ // in-flight set is admitted first, then the most RECENT
2189
+ // finished calls fill what is left — newest first, because
2190
+ // the oldest is the one the eye has already read.
2191
+ const live = tools.filter((i) => !toolAt(i).done);
2192
+ const past = tools.filter((i) => toolAt(i).done);
2193
+ // WHAT IS HAPPENING NOW OUTRANKS WHAT HAPPENED. In order:
2194
+ // the in-flight rows, then that call's output when it is the
2195
+ // only one running, then the finished NAMES, newest first.
2196
+ //
2197
+ // This is the line between R3i P1 and the owner's R7a ruling,
2198
+ // which look contradictory and are not. The ruling is about a
2199
+ // parallel burst — four reads whose names vanished one at a
2200
+ // time, so the turn ended having shown four files and left
2201
+ // none of them. P1 is about a burst that is OVER and a new
2202
+ // call running: there the finished names have had their time
2203
+ // on screen and the work in flight has not. Recency decides
2204
+ // both, and neither gate has to give.
2205
+ // the lone in-flight call is drawn by its OWN component, head
2206
+ // and tail together — that is where the waiting row, VD-4's
2207
+ // never-blank-first-row rule and the shell's live window all
2208
+ // already live. Reaching past it to slotTail() lost every one
2209
+ // of them: a running shell with no output yet drew three
2210
+ // blank rows where `└ waiting for output` belongs.
2211
+ // `grouped` says "an activity line above wears the mark for
2212
+ // us". A stretch of ONE call draws no such line (R7a), so
2213
+ // there is nothing above to carry it and the head keeps its
2214
+ // own — otherwise a lone running call breathes nowhere.
2215
+ const grouped = { ...ctx, grouped: tools.length > 1 };
2216
+ const soloRows = live.length === 1 ? tint(live[0], cellComponent(this.#cells[live[0]]).render(W, grouped)) : [];
2217
+ const tailWant = Math.max(0, soloRows.length - 1);
2218
+ // the overflow row is itself a row: an in-flight set larger
2219
+ // than the slot gives one back so `+N more running` fits.
2220
+ const liveRows = live.slice(0, live.length > budget ? Math.max(1, budget - 1) : budget);
2221
+ const spare = Math.max(0, budget - liveRows.length - tailWant);
2222
+ const nameRoom = past.length > spare ? Math.max(0, spare - 1) : spare;
2223
+ const keep = new Set([...liveRows, ...past.slice(past.length - nameRoom)]);
2224
+ const shown = tools.filter((i) => keep.has(i));
2225
+ // `+N more running` COUNTS ONLY CALLS THAT ARE RUNNING.
2226
+ //
2227
+ // Counting every dropped call said "+1 more running" over a
2228
+ // read that had already returned — a false sentence of the
2229
+ // R3h class, and the stretch line above had ALREADY counted
2230
+ // that read ("read 1 file"), so the row was both wrong and
2231
+ // redundant. A finished name giving way to live work is the
2232
+ // recency rule doing its job, not an overflow.
2233
+ const hidden = live.length - shown.filter((i) => !toolAt(i).done).length;
2053
2234
  const rows = [];
2054
- for (const i of heads)
2055
- rows.push(tint(i, cellComponent(this.#cells[i]).render(W, ctx))[0] ?? "");
2056
- const rest = budget - rows.length - (hidden > 0 ? 1 : 0);
2057
- if (rest > 0)
2058
- rows.push(...slotTail(toolAt(heads[heads.length - 1]).resultText, W, rest));
2235
+ // the mark lives on the ACTIVITY line above, so the members
2236
+ // wear a plain gutter — see FrameCtx.grouped.
2237
+ for (const i of shown) {
2238
+ if (i === live[0] && live.length === 1)
2239
+ rows.push(...soloRows.slice(0, Math.max(1, budget - rows.length)));
2240
+ else
2241
+ rows.push(tint(i, cellComponent(this.#cells[i]).render(W, grouped))[0] ?? "");
2242
+ }
2059
2243
  if (hidden > 0)
2060
2244
  rows.push(moreRunningRow(hidden, W));
2245
+ // R3i P3 SURVIVES: the call in flight keeps its row AND its
2246
+ // output. R7a gave every call a row, which spent the budget
2247
+ // the tail used to hold — but a running shell with no output
2248
+ // on screen is the defect R3i named, and the owner's ruling
2249
+ // was about the finished calls' NAMES, not about this. The
2250
+ // tail takes whatever the head rows leave, so it is full
2251
+ // height for a lone call and gives way to the names first.
2252
+ // R4 B SURVIVES THE UNIFICATION: between two calls — nothing
2253
+ // in flight — the slot still shows the call that just
2254
+ // finished AND its output. It is appended UNDER the head
2255
+ // rows now instead of replacing them, so the block's shape
2256
+ // does not change when the last call of a burst returns.
2257
+ const rest = budget - rows.length;
2258
+ if (live.length === 0 && rest > 0 && tools.length > 0)
2259
+ rows.push(...slotTail(toolAt(tools[tools.length - 1]).resultText, W, rest));
2061
2260
  return slotPad(rows, budget);
2062
2261
  }
2063
- const settled = tools.length > 0 ? tools[tools.length - 1] : null;
2064
- if (settled !== null) {
2065
- const head = tint(settled, cellComponent(this.#cells[settled]).render(W, ctx))[0] ?? "";
2066
- return slotPad([head, ...slotTail(toolAt(settled).resultText, W, budget - 1)], budget);
2067
- }
2068
2262
  const think = [...live].reverse().find((i) => this.#cells[i]?.kind === "thinking");
2069
- return slotPad(think === undefined ? [] : slotTail(this.#cells[think].text, W, budget), budget);
2263
+ // R7a A SLOT WITH NOTHING TO SHOW TAKES NO ROWS.
2264
+ //
2265
+ // R7 moved thinking OUT of the slot (it is words, and words do
2266
+ // not fold), which left this branch — the pre-tool phase of a
2267
+ // stretch — with nothing to put in the rows it was still
2268
+ // reserving. It padded them anyway: six blank rows between the
2269
+ // thought and the composer, on 653 of a 733-frame dogfood
2270
+ // replay. Until today those rows were drawn as `│`, so the
2271
+ // blank-run guard never saw them and the owner saw a gutter
2272
+ // running down the screen marking nothing; blanking the gutter
2273
+ // (law 1.3) revealed the hole the gutter had been covering.
2274
+ //
2275
+ // Reserving height buys stability only where the content
2276
+ // CHANGES under it — a stretch whose calls come and go. Before
2277
+ // the first call there is nothing to stabilise, so the rows are
2278
+ // pure cost, and both complaints are the same complaint.
2279
+ const tail = think === undefined ? [] : slotTail(this.#cells[think].text, W, budget);
2280
+ return tail.length === 0 ? [] : slotPad(tail, budget);
2070
2281
  }
2071
2282
  /** R4 — the tool names with a call still IN FLIGHT in this segment.
2072
2283
  * The stretch line's tense is per term, so a finished shell reads
@@ -2454,6 +2665,37 @@ export class Body {
2454
2665
  * the only place that knows it. Every other pair is untouched,
2455
2666
  * including the boundary INTO a markdown message (the blank under the
2456
2667
  * user chip is still W11's). */
2668
+ /** R7a — the live block's spacing is the spacing its COMMITTED form
2669
+ * will get, never its own.
2670
+ *
2671
+ * W11 gives a blank when either side is multi-row. The block is
2672
+ * always multi-row and the fold it commits into is always ONE row,
2673
+ * so the two sides disagreed by construction and a blank appeared
2674
+ * or vanished at every settle, shoving the whole transcript by a
2675
+ * row. Both directions occur: after a two-row thought the settle
2676
+ * ADDED one (my R6/D1 note saw only this case and removed the
2677
+ * block's blank, which fixed that direction and broke the other);
2678
+ * after a one-row thought it REMOVED one.
2679
+ *
2680
+ * Deciding on a one-row stand-in makes the block spaced exactly as
2681
+ * its fold will be, so the settle changes the row's CONTENT and
2682
+ * never its position — which is the whole claim of the standing
2683
+ * block. */
2684
+ /** R7a — is any call of this stretch actually RUNNING?
2685
+ *
2686
+ * The phase is not the same question. A stretch stays "acting" from
2687
+ * its first tool to its close, so between two bursts — every call
2688
+ * returned, the model is composing the next one — the phase still
2689
+ * said acting and the activity line went on breathing over four
2690
+ * finished reads. A mark that is lit when nothing moves is the
2691
+ * spinner-implies-progress error §5.3 forbids, one scale up. */
2692
+ #inFlight(seg) {
2693
+ return seg.cells.some((i) => { const c = this.#cells[i]; return c?.kind === "tool" && !c.done; });
2694
+ }
2695
+ #blockSpace(i, prev, rows) {
2696
+ const lead = bodySpacing(this.#lastDrawn(i, prev), ["x"]).length > 1 ? [""] : [];
2697
+ return [...lead, ...rows];
2698
+ }
2457
2699
  #space(i, prev, rows) {
2458
2700
  if (i > 0 && this.#cells[i]?.kind === "md" && this.#cells[i - 1]?.kind === "md")
2459
2701
  return rows;
@@ -2687,6 +2929,12 @@ export class Body {
2687
2929
  // is not going to speak for it.
2688
2930
  if (cell.kind === "tool" && cell.name === "ask_user")
2689
2931
  return false;
2932
+ // R7: and neither is THINKING. It is words now (law 1.7 — work
2933
+ // folds, words do not), so it commits when it is done, like prose,
2934
+ // and is never held for a fold that no longer speaks for it. The
2935
+ // ask_user exemption one line up is the precedent this follows.
2936
+ if (cell.kind === "thinking")
2937
+ return false;
2690
2938
  const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
2691
2939
  if (turn === undefined || turn !== this.#turns[this.#turns.length - 1])
2692
2940
  return false;
@@ -3018,14 +3266,40 @@ export class Body {
3018
3266
  // picker's does. Both render frameless directly above the composer,
3019
3267
  // so with scrollback behind them there was nothing to say where the
3020
3268
  // surface began — the rows read as more history.
3269
+ // R8 — THE BAND IS A WINDOW, and the rows are a table.
3270
+ //
3271
+ // It used to draw every match and fold each long description over
3272
+ // as many rows as it took, which is why a bare `/` could not open
3273
+ // it: eleven commands plus wraps is most of a short terminal. A
3274
+ // fixed window is what lets the trigger be the `/` the banner
3275
+ // advertises (see the editor's #menuFiltered).
3276
+ //
3277
+ // Three shape rules, all of them §1.3 or §1.2:
3278
+ // - the leading `/` comes off the rows. It is already on the
3279
+ // input line directly below, so printing it eleven more times
3280
+ // is a mark carrying no fact the screen does not have.
3281
+ // - the name column is padded to the longest command in the
3282
+ // WHOLE list, not the visible slice, so the descriptions do
3283
+ // not shift sideways as the window scrolls.
3284
+ // - a description is CUT, never folded — a folded row would
3285
+ // break the window's height, which is the thing being bought.
3286
+ const items = menu.items;
3287
+ const col = MENU_ITEMS.reduce((n, m) => Math.max(n, m.name.length - 1), 0);
3288
+ const windowed = items.length > MENU_WINDOW;
3289
+ // the window's top is derived from the selection alone (this
3290
+ // method is re-entered per frame and keeps no state): centre it,
3291
+ // clamped to the ends.
3292
+ const top = windowed ? Math.max(0, Math.min(menu.selected - ((MENU_WINDOW - 1) >> 1), items.length - MENU_WINDOW)) : 0;
3021
3293
  const rows = [bandHeader("commands", W)];
3022
- for (let i = 0; i < menu.items.length; i += 1) {
3023
- const item = menu.items[i];
3024
- const text = i === menu.selected
3025
- ? `${p.bold} ${item.name}${p.reset} ${item.desc}`
3026
- : `${p.dim} ${item.name} ${item.desc}${p.reset}`;
3027
- rows.push(...foldLine(text, W));
3028
- }
3294
+ for (let i = top; i < Math.min(items.length, top + MENU_WINDOW); i += 1) {
3295
+ const item = items[i];
3296
+ const label = `${item.name.slice(1).padEnd(col)} ${item.desc}`;
3297
+ rows.push(...(i === menu.selected ? gutterCut(`${p.bold}▸${p.reset} `, `${p.bold}${label}${p.reset}`, W) : gutterCut(" ", `${p.dim}${label}${p.reset}`, W)));
3298
+ }
3299
+ // the counter earns its row only when the list is CUT — over a
3300
+ // list you can see all of, it says nothing the rows do not.
3301
+ if (windowed)
3302
+ rows.push(` ${p.dim}(${menu.selected + 1}/${items.length})${p.reset}`);
3029
3303
  return rows;
3030
3304
  }
3031
3305
  /** ONE input row's bytes — the marker embedded at `embedAt` (the
@@ -3225,6 +3499,21 @@ export class Body {
3225
3499
  // march below is clamped to the window instead, which makes the
3226
3500
  // sheet displace content ON SCREEN. Closing takes the full-redraw
3227
3501
  // path with the same #lastSkip and every displaced row comes back.
3502
+ // R7a — a monotone skip was TRIED HERE AND REJECTED, measured.
3503
+ //
3504
+ // The seam it aimed at is real: the turn boundary releases the
3505
+ // block into a one-row fold, so a full screen's computed skip
3506
+ // drops and every row above slides down one. Holding skip at its
3507
+ // high-water mark removes that motion exactly.
3508
+ //
3509
+ // It also holds the window BELOW the content, and the a7 dogfood
3510
+ // replay prices that at 40x24: the frame from which the screen
3511
+ // durably fills (no blank run over 2) goes 65 -> 692 of 733 —
3512
+ // a three-row hole above the composer through most of a real
3513
+ // session. One row of motion once per turn, at the moment the
3514
+ // answer lands and the eye is on it, is the cheaper of the two.
3515
+ // The A8b guard is written against a real session; this is what
3516
+ // it exists to catch.
3228
3517
  const skip = overlay
3229
3518
  ? this.#lastSkip
3230
3519
  : Math.max(0, all.length + CHROME_ROWS + inputExtra + queueRows.length + menuRows.length - H);
package/dist/editor.js CHANGED
@@ -542,11 +542,20 @@ export class Editor {
542
542
  return null;
543
543
  return { items: this.#menuFiltered(), selected: this.#menuSel };
544
544
  }
545
- /** v3 §04: the filtered command list for the current buffer — open
546
- * only while the line is "/" + something (a bare "/" waits). */
545
+ /** v3 §04: the filtered command list for the current buffer.
546
+ *
547
+ * A BARE `/` OPENS IT (owner-ruled 2026-09-01). It used to wait for
548
+ * a second character, which made the key the banner advertises —
549
+ * `/ commands` — a thing you had to already know the answer to: the
550
+ * list that tells you the commands appeared only once you had typed
551
+ * one. The reason for the wait was real and is fixed on the other
552
+ * side: the band drew EVERY match with no window, so a bare `/`
553
+ * would have piled eleven rows plus wraps above the composer. The
554
+ * band windows now (see the compositor's #menuRows), so the trigger
555
+ * no longer has to do the rationing. */
547
556
  #menuFiltered() {
548
557
  const line = this.line();
549
- if (!line.startsWith("/") || line === "/")
558
+ if (!line.startsWith("/"))
550
559
  return [];
551
560
  return MENU_ITEMS.filter((m) => m.name.startsWith(line));
552
561
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.19.1",
3
+ "version": "0.20.1",
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.19.1"
38
+ "@vincemakes/kiso-tui-cells": "0.20.1"
39
39
  }
40
40
  }