@vincemakes/kiso-tui-cells 0.25.0 → 0.26.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.
@@ -25,12 +25,12 @@
25
25
  * (untouched); render.ts supplies the original text (palette, escape,
26
26
  * tint, fold wording).
27
27
  */
28
- import { displayWidth, visibleWidth } from "./width.js";
28
+ import { displayWidth, visibleWidth, widthCut } from "./width.js";
29
29
  // TUI2-R2pre ④: the ONE display-verb table (strings.ts, beside
30
30
  // KEY_BINDINGS). strings.js imports only render/width here, so this edge
31
31
  // adds no cycle.
32
32
  import { displayVerb } from "./strings.js";
33
- import { bannerLines, breathFrame, escapeTerminal, foldThinking, foldResult, renderTerminalGap, renderToolSummary, toolTarget, kUnit, palette, currentGround, } from "./render.js";
33
+ import { bannerLines, breathFrame, cutLine, escapeTerminal, foldThinking, foldResult, renderTerminalGap, renderToolSummary, toolTarget, kUnit, palette, currentGround, } from "./render.js";
34
34
  // TUI2-MD: the markdown renderer's surface reaches the tui through this
35
35
  // module (the tui's components shim re-exports it) — one import edge,
36
36
  // and it points one way: md.ts measures with the width authority, never
@@ -113,7 +113,8 @@ export function foldLine(line, W) {
113
113
  * renderer can measure without importing this module back — the
114
114
  * re-export is verbatim, so every existing importer and the barrel see
115
115
  * exactly what they saw. */
116
- export { visibleWidth } from "./width.js";
116
+ export { visibleWidth, widthCut } from "./width.js";
117
+ export { cutLine } from "./render.js";
117
118
  /** The W11 spacing formula — "a row gets one blank line above it when
118
119
  * the row is itself a block, or when the previous sibling was taller
119
120
  * than one row". One-row siblings pack tight; anything multi-row
@@ -122,7 +123,7 @@ export { visibleWidth } from "./width.js";
122
123
  * `prev` is the previous sibling's OWN rows (raw — a cell's own blank
123
124
  * must never count toward its height). The blank is a JOIN artifact:
124
125
  * the cell's own render stays blank-free, so per-cell accounting
125
- * (heights, the fold cache) never sees a fake row. */
126
+ * (heights, the line cache) never sees a fake row. */
126
127
  export function bodySpacing(prev, rows) {
127
128
  if (rows.length === 0 || prev === null || prev.length === 0)
128
129
  return rows;
@@ -168,9 +169,9 @@ export function cellComponent(cell) {
168
169
  case "user":
169
170
  return new UserMessage(cell);
170
171
  case "thinking":
171
- // R7: the committed/live surface is the BLOCK. ThinkingFold
172
- // survives for the pipe path (render.ts's foldThinking), whose
173
- // bytes are asserted by the --plain identity gate.
172
+ // R7: the committed/live surface is the BLOCK; the pipe path
173
+ // prints render.ts's one-row foldThinking instead, whose bytes
174
+ // are asserted by the --plain identity gate.
174
175
  return new ThinkingBlock(cell);
175
176
  case "tool":
176
177
  return new ToolExecution(cell);
@@ -334,19 +335,14 @@ export function pendingQueueRows(lines, W) {
334
335
  }
335
336
  return out;
336
337
  }
337
- /** The thinking fold — one dim line, width-capped so the /think suffix
338
- * rides the fold's own row (the #17 fix's slice, componentized). The
339
- * slice is DISPLAY-WIDTH-based (the char-based slice overflowed with
340
- * CJK — 2 cells per char — and tripped invariant ① on a real
341
- * Chinese session). W2: the leading ⋯ is the thinking gutter — the
342
- * midline mark (the state), never the text ellipsis (the truncation). */
343
338
  /**
344
339
  * R7 — THINKING IS A BLOCK OF WORDS.
345
340
  *
346
341
  * The owner's ruling, arrived at from a side-by-side with pi's screen:
347
342
  * the model's reasoning reads as its own paragraphs — italic, dim,
348
- * indented two — and is never folded away. `ThinkingFold` (below, kept
349
- * for the pipe path) summarised it to one row with a key; four rounds
343
+ * indented two — and is never folded away. A one-row fold (the pipe
344
+ * path's `foldThinking` is what remains of it) stood for it, with a
345
+ * key; four rounds
350
346
  * of machinery were then built to hand the rest back, and the owner's
351
347
  * complaint through all of them was the same sentence: I cannot see
352
348
  * what it was thinking.
@@ -411,79 +407,18 @@ class ThinkingBlock {
411
407
  return rows;
412
408
  }
413
409
  }
414
- class ThinkingFold {
415
- cell;
416
- constructor(cell) {
417
- this.cell = cell;
418
- }
419
- render(W, _ctx) {
420
- const p = palette();
421
- const block = this.cell.text;
422
- // R3f — the fold is ONE ROW, so its text is one line.
423
- //
424
- // `escapeTerminal` strips C0 but KEEPS \n and \t, and
425
- // `charWidth(0x0A)` is 1 — so a multi-line thinking block sailed
426
- // through every width check as a legal single row, and the
427
- // terminal then wrapped it across the chrome. That shipped in
428
- // 0.16.6 and is what smashed the composer: a model whose thinking
429
- // opens with a numbered plan ("1. …\n2. …") produces exactly it.
430
- // Invariant ①b now catches the class at the emit; this stops
431
- // producing it. Whitespace collapses because the row is a
432
- // SUMMARY — the full text is one ctrl+o away, unchanged.
433
- const trimmed = escapeTerminal(block.trim()).replace(/\s+/g, " ");
434
- // R2 (owner, 2026-08-27) — three changes, each independent.
435
- //
436
- // ITALIC marks the row as not-the-answer without spending a colour,
437
- // on the same argument that admitted italic to the alphabet.
438
- //
439
- // The cut lands on a WORD. It used to cut on a byte, so the fold
440
- // read as `…the user's h` and the reader's eye had to reassemble a
441
- // word it already knew.
442
- //
443
- // The affordance moves to the RIGHT EDGE, so the left edge of every
444
- // row on screen is content. The char count goes with the move: it
445
- // told the reader nothing they could act on, and the row it was
446
- // crowding is the one thing this cell says.
447
- //
448
- // The ≤100 short-circuit stays width-aware: a short block at a
449
- // narrow width returned the line UNFOLDED and tripped invariant ①.
450
- // DC-15: the affordance is DROPPED, not squeezed. `room` floored at
451
- // 1 while `pad` floored at 1 independently, so a narrow terminal
452
- // produced 2 + cut + pad + 6 = 11 cells no matter what W was — and
453
- // invariant ① does not truncate, it THROWS. Measured 11 cells at
454
- // every W ≤ 10. Below the width where the tail and one word of
455
- // content can both stand, the row is the CONTENT: a cell that
456
- // cannot hold the key's name has nothing to say about the key.
457
- const tail = "/think";
458
- const room = W - 2 - tail.length - 1;
459
- // the belt: cutLine is SGR-aware and single-row, so the invariant
460
- // holds by CONSTRUCTION at every width rather than by arithmetic
461
- // that has to be re-proved every time a span moves.
462
- if (room < 2)
463
- return [cutLine(`${p.dim}⋯ ${p.italic}${wordCut(trimmed, Math.max(1, W - 2))}${p.italicEnd}${p.reset}`, W)];
464
- const cut = wordCut(trimmed, room);
465
- const body = `⋯ ${p.italic}${cut}${p.italicEnd}`;
466
- const pad = Math.max(1, W - 2 - visibleWidth(cut) - tail.length);
467
- return [cutLine(`${p.dim}${body}${" ".repeat(pad)}${tail}${p.reset}`, W)];
410
+ /** The SGR spans still open at the end of `text`, given those open at
411
+ * its start. A reset closes everything; anything else stacks. */
412
+ function spansOpenAfter(text, before) {
413
+ let open = [...before];
414
+ for (const m of text.matchAll(/\x1b\[[0-9;]*m/g)) {
415
+ if (m[0] === "\x1b[0m")
416
+ open = [];
417
+ else
418
+ open.push(m[0]);
468
419
  }
420
+ return open;
469
421
  }
470
- /** R2 — cut at a word boundary, with the honest ellipsis. widthCut cuts
471
- * at a cell, which is right for verbatim output and wrong for prose:
472
- * the reader has to reassemble "h" into "home". Falls back to the cell
473
- * cut when the first word alone overruns, because a row that cannot
474
- * hold one word has no boundary to find. */
475
- function wordCut(text, room) {
476
- if (visibleWidth(text) <= room)
477
- return text;
478
- const hard = widthCut(text, Math.max(1, room - 1));
479
- const at = hard.lastIndexOf(" ");
480
- return `${at > room / 3 ? hard.slice(0, at) : hard}\u2026`;
481
- }
482
- /** Fold a line's CONTENT at W−2 and prefix EVERY row with the gutter
483
- * (W2: a wrapped tool row keeps its state mark — the left edge alone
484
- * distinguishes the states at --plain; the UserMessage rail precedent,
485
- * v5 #16f). The gutter carries its own SGR (e.g. the bold ✓). W21:
486
- * exported for the approval panel's text args (the same │ gutter). */
487
422
  /**
488
423
  * TUI2-R1.5 ⑨ (VD-10) — the WORD-aware fold, for text a human reads.
489
424
  *
@@ -502,18 +437,6 @@ function wordCut(text, room) {
502
437
  * overflowing row would violate invariant ①, and a word that cannot fit
503
438
  * has to be broken somewhere.
504
439
  */
505
- /** The SGR spans still open at the end of `text`, given those open at
506
- * its start. A reset closes everything; anything else stacks. */
507
- function spansOpenAfter(text, before) {
508
- let open = [...before];
509
- for (const m of text.matchAll(/\x1b\[[0-9;]*m/g)) {
510
- if (m[0] === "\x1b[0m")
511
- open = [];
512
- else
513
- open.push(m[0]);
514
- }
515
- return open;
516
- }
517
440
  export function foldWords(line, W) {
518
441
  if (W < 1)
519
442
  return [line];
@@ -659,25 +582,17 @@ function toolTargetOf(c) {
659
582
  }
660
583
  return toolTarget(c.name, input);
661
584
  }
662
- /** The tool execution line + the bounded block — every state is its
663
- * own render; the lines fold (the summary gives way first). W7 (the
664
- * flow contract): the block's BODY (the rows below the header) is
665
- * capped in SCREEN rows AFTER the fold, at the current width the
666
- * renderer-cut row (`└ +N · ctrl+o`) sits INSIDE the cap (a
667
- * truncated block is cap−1 output rows + the cut row); the TOOL-cut
668
- * row (`└ capped by …` the tool's OWN truncation note, W10) is a
669
- * DIFFERENT fact, never counted in the output cap. W3: the verb is
670
- * stripped of its "_file" suffix and padded to 5 columns the target
671
- * paths line up (the pipe path strips the same suffix, render.ts
672
- * both paths print the same verb; a verb 5 columns is not padded).
673
- * The block's cut note keeps the RAW name (it names the tool the
674
- * model should call again). W4: the settled row's parentheses hold
675
- * the human metadata (settledMeta) — the input summary lived in the
676
- * running row; the OUTCOME is what the settled row says. A4: the
677
- * settled row keeps the TARGET — verb + target + outcome, the running
678
- * row's summary column (the W19 pinned row keeps the full call name
679
- * instead). A5: the verdict rides the head row — a decidedBy present
680
- * on the cell appends `· approved by X` (extension auto-approvals) or
585
+ /** THE TOOL CARD one per call, every state its own render (R13):
586
+ * pad · head row · blank · the preview, capped at CAP_PREVIEW rows and
587
+ * cut with a note that names the key · blank · the outcome row · pad;
588
+ * a call with nothing to show is the three-row bodiless card. The body
589
+ * is capped in SCREEN rows at the current width; the tool's OWN
590
+ * truncation note (W10, `capped by …`) is a different fact, never
591
+ * counted against the cap. W3: the verb drops its "_file" suffix and
592
+ * pads to 5 columns so the targets line up (the pipe path prints the
593
+ * same verb). A4: the settled head row is verb + target + outcome; the
594
+ * W19 pinned deny keeps the full call name instead. A5: a decidedBy on
595
+ * the cell appends approved by X` (an extension's auto-approval) or
681
596
  * `· by X` on the pinned deny; the human decision needs no marker. */
682
597
  class ToolExecution {
683
598
  cell;
@@ -689,10 +604,9 @@ class ToolExecution {
689
604
  const c = this.cell;
690
605
  const verb = escapeTerminal(displayVerb(c.name));
691
606
  const verbCol = verb.length < 5 ? `${verb}${" ".repeat(5 - verb.length)}` : verb;
692
- // R13 the W13 rollup row and TUI2-R1 (B)'s exploration row stood
693
- // here, ahead of everything else a settled call could be. Both are
694
- // retired with the `rolled` field they read (see the compositor's
695
- // #foldOrRollup for the reversal in full).
607
+ // DECLARED REVERSAL (R13): the W13 rollup row and TUI2-R1 (B)'s
608
+ // exploration row stood here, ahead of everything else a settled
609
+ // call could be; both retired with the `rolled` field they read.
696
610
  if (c.state === "done") {
697
611
  // R3i phase 5: an answered (or declined) ask_user renders its
698
612
  // OWN block — the questions and what the human said. The row
@@ -880,7 +794,7 @@ class ToolExecution {
880
794
  // there is no card — the call keeps its head row until it
881
795
  // commits, which is the one form that fits anywhere.
882
796
  const liveRows = ctx.liveWindow ?? CAP_PREVIEW;
883
- const gutter = ctx.grouped === true ? " " : `${breathFrame(ctx.spinnerI)} `;
797
+ const gutter = `${breathFrame(ctx.spinnerI)} `;
884
798
  if (liveRows <= 0) {
885
799
  // the degraded form is the head row ALONE, so it keeps the
886
800
  // duration it would otherwise have lost with its card — cut
@@ -940,8 +854,8 @@ class ToolExecution {
940
854
  * The affordance is a statement about hidden content: a cell whose body
941
855
  * is already whole on screen must not advertise a key that would show it
942
856
  * the same thing, and a cell that already carries its own renderer cut
943
- * (`└ +N earlier rows · ctrl+o`, `└ +N more · ctrl+o`) already teaches
944
- * the key at the place the content stops. What is LEFT — and it is the
857
+ * (the `… N · ctrl+o expands` note) already teaches the key at the
858
+ * place the content stops. What is LEFT — and it is the
945
859
  * common case — is every settled non-shell call, whose collapsed body is
946
860
  * empty: the whole result sits behind the key with nothing on screen
947
861
  * saying so.
@@ -1029,23 +943,13 @@ function settledHeadText(verbCol, target, meta, attr, elapsed, room, counted = "
1029
943
  const core = join(meta, counted, `${elapsed}s`);
1030
944
  const withAttr = join(meta, counted, `${elapsed}s`, attr.replace(" · ", ""));
1031
945
  const lead = `${verbCol} `;
1032
- const fit = (t, tail) => {
1033
- const line = `${lead}${t}${tail === "" ? "" : ` · ${tail}`}`;
1034
- return visibleWidth(line) <= room ? line : null;
1035
- };
1036
- // 1. everything
1037
- const full = fit(target, withAttr);
1038
- if (full !== null)
1039
- return full;
1040
- // 2. the attribution gives way
1041
- const bare = fit(target, core);
1042
- if (bare !== null)
1043
- return bare;
1044
- // 2b. the COUNT gives way next (pin 4), where the suffix is not
1045
- // already carrying it
1046
- const short = fit(target, join(meta, `${elapsed}s`));
1047
- if (short !== null)
1048
- return short;
946
+ const row = (tail) => `${lead}${target}${tail === "" ? "" : ` · ${tail}`}`;
947
+ // 1. everything; 2. the attribution gives way; 2b. the COUNT gives
948
+ // way next (pin 4), where the suffix is not already carrying it —
949
+ // the target whole through all three
950
+ const whole = firstFit([row(withAttr), row(core), row(join(meta, `${elapsed}s`))], room);
951
+ if (whole !== null)
952
+ return whole;
1049
953
  // 3. the target truncates, the core stays whole
1050
954
  const stem = join(meta, `${elapsed}s`);
1051
955
  const budget = room - visibleWidth(lead) - visibleWidth(stem) - 4; // the ellipsis + " · "
@@ -1083,15 +987,12 @@ function attribution(c) {
1083
987
  return "";
1084
988
  return c.verdict.decision === "denied" ? " · denied" : " · approved";
1085
989
  }
1086
- export function expandSuffix(lines, room) {
990
+ /** The expand key as a row's tail, in two tiers; empty when there is
991
+ * nothing hidden. R13 moved the line COUNT off this suffix onto the
992
+ * head row's own `·` chain (VD-6: stated exactly once). */
993
+ function expandSuffix(lines, room) {
1087
994
  if (lines === null)
1088
995
  return "";
1089
- // R13 — the COUNT left this suffix for the head row's own `·` chain,
1090
- // where the bodied card keeps it too (`… · 10 lines · 0.1s`). It was
1091
- // here because the parenthesised core had nowhere to put it and the
1092
- // suffix was the only tail the row had; with one grammar for both
1093
- // cards there is one place, and VD-6's "stated exactly once" is what
1094
- // forbids leaving a copy behind.
1095
996
  for (const tier of [" · ctrl+o expands", " · ctrl+o"]) {
1096
997
  if (tier.length <= room)
1097
998
  return tier;
@@ -1106,186 +1007,43 @@ function appendSuffix(row, suffix) {
1106
1007
  const p = palette();
1107
1008
  return `${row}${p.dim}${suffix}${p.reset}`;
1108
1009
  }
1109
- /**
1110
- * TUI2-R2 ⑤ (D, candidate 1) the FOCUS tint.
1111
- *
1112
- * The cell the next ctrl+o will act on brightens its own `ctrl+o` token
1113
- * to the code tint; the rest of the suffix the separator, the count —
1114
- * stays dim, because what is being marked is the KEY's target, not the
1115
- * row. Zero new rows, zero new columns: the affordance the cell already
1116
- * prints is the marker.
1117
- *
1118
- * Applied to a row rather than composed into it on purpose. The token is
1119
- * emitted from several places (the settled suffix, the renderer's own
1120
- * `└ +N … · ctrl+o` cut rows) and threading a flag through all of them
1121
- * would put the invariant "exactly one bright token" in as many hands as
1122
- * there are emitters. Here it has exactly one.
1123
- *
1124
- * NO_COLOR: p.dim is empty, so the row's bytes are untouched.
1125
- */
1126
- export function focusToken(row, W) {
1127
- const p = palette();
1128
- const at = row.lastIndexOf(EXPAND_KEY);
1129
- if (at !== -1) {
1130
- // the row already names the key — brighten the token in place, and
1131
- // leave every other span exactly as it was
1132
- if (p.dim === "")
1133
- return row;
1134
- // DC-3: the marker takes the WASH. It used to take the inline-code
1135
- // tint and inherited its 1.54:1 — the cue naming the key that
1136
- // reveals a cell was itself the least readable thing on a white
1137
- // terminal. The wash is right for a second reason: this token has
1138
- // to be UNIQUE on the frame ("exactly one bright token"), and an
1139
- // attribute like bold is spent everywhere. It closes with washEnd
1140
- // rather than a reset, so the surrounding dim survives instead of
1141
- // having to be re-applied.
1142
- return `${row.slice(0, at)}${p.lift}${EXPAND_KEY}${p.dim}${row.slice(at + EXPAND_KEY.length)}`;
1143
- }
1144
- // A LIVE row does not carry the affordance today, and the live cell is
1145
- // the one ctrl+o takes FIRST (expandNext scans the live tail before
1146
- // the committed ring) — so the row the key is aimed at was the one row
1147
- // that never said the key existed. The affordance IS the marker here:
1148
- // it appears on the focused row and nowhere else, which is why no
1149
- // unfocused row's bytes move (every existing live-row assertion
1150
- // renders a cell with no focus and is untouched).
1151
- const room = W - visibleWidth(row);
1152
- if (room < SUFFIX_MIN)
1153
- return row; // never at the cost of invariant ①
1154
- return `${row}${p.dim} · ${p.lift}${EXPAND_KEY}${p.reset}`;
1155
- }
1156
- /** DC-41 — the label in ONE place. The key has moved once now, and
1157
- * a constant named after its binding is a comment that lies. */
1158
- const EXPAND_KEY = "ctrl+o";
1159
- /** TUI2-R1 (A) — the expanded block's last row: the way back. The
1160
- * rollup's expanded list carries a second clause (its members' full
1161
- * outputs live in /last, which the group row cannot show). */
1010
+ /* DECLARED REVERSAL (D-S2-1, owner-ruled 2026-09-06): `focusToken` and
1011
+ `EXPAND_KEY` stood here — TUI2-R2 ⑤'s bright ctrl+o token on the
1012
+ newest live card, "exactly one bright token per frame". DC-50 made
1013
+ ctrl+o a global switch, so there was no target left for a marker to
1014
+ name; the status row's idle hint names the switch (`idleHint`). */
1015
+ /** TUI2-R1 (A) the expanded card's key suffix: the way back. */
1162
1016
  const COLLAPSE_ROW = "ctrl+o collapses";
1163
- /** W13 the rollup opt-in table: which tools collapse, and the count
1164
- * NOUN (read_file calls "5 files", list_dir → "5 dirs", search_text
1165
- * → "5 matches"). Only these tools opt in — a shell burst is never
1166
- * rolled up (its rows carry meaning). The folded-turn line (W14) reuses
1167
- * the plurals for its other-tool terms ("2 dirs", "1 match"). */
1168
- export const ROLLUP_NOUN = {
1169
- read_file: "files",
1170
- list_dir: "dirs",
1171
- search_text: "matches",
1172
- };
1173
- // ---- TUI2-R1 (B): the exploration rollup ----
1174
- /** TUI2-R1 (B) — the exploration row's nouns. Deliberately NOT
1175
- * ROLLUP_NOUN: that table says what a SINGLE-tool rollup counts
1176
- * ("5 matches"), and this row counts CALLS across tools, where
1177
- * "14 searches" is what happened. Both tables stay — changing the
1178
- * older one would move an assertion this round did not declare. */
1179
- const EXPLORE_NOUN = {
1180
- read_file: ["file", "files"],
1181
- list_dir: ["dir", "dirs"],
1182
- search_text: ["search", "searches"],
1183
- };
1184
- /** TUI2-R1 (B) — the verb column of the expanded list names the ACT.
1185
- * TUI2-R2pre ④: this used to be a private three-tool table saying the
1186
- * same thing as the card head's `_file` strip, in a different way and
1187
- * for a different set of tools. Both are `displayVerb` now — the whole
1188
- * point of the ruling is that there is ONE answer to "what does the
1189
- * screen call this". The cut note, which used to be the deliberate
1190
- * exception here, moved with it (see toolCutNote). */
1191
- /** Whether a tool joins an exploration run. Exactly the read-only set —
1192
- * writes, edits, shells and extension tools never group (a burst of
1193
- * side effects is a list of things that HAPPENED, and every row of it
1194
- * carries meaning). */
1195
- export function isExploreTool(name) {
1196
- return EXPLORE_NOUN[name] !== undefined;
1197
- }
1198
- /** W14 — the folded-turn line: a whole QUIET turn (no text), once it is
1199
- * scrollback, becomes ONE line — the work order's claimed shape
1200
- * (`▞ thought 19s · 5 reads · no edits`), the counts accumulated at
1201
- * toolStart: read_file → "reads", edit_file → "edits", the other tools
1202
- * as first-call-order terms (the ROLLUP_NOUN plurals when the tool opts
1203
- * in, the verb + "s" otherwise).
1204
- * A9 (ruling R2, mock A): the user chip rides the fold — the human's
1205
- * words LEAD the one line, `✦ <chip> · thought 19s · read 5 files` —
1206
- * the chip the SAME SGR-7 bracket as the live user row (#16f, side
1207
- * pads included). The words take the fold's width budget and width-cut
1208
- * at the end with the honest "…" (never a silent truncate — invariant
1209
- * ① holds on the ONE row by construction).
1210
- *
1211
- * DECLARED SUPERSESSION (R3g, 2026-08-28) — A9 also ruled that "the
1212
- * metadata terms give way LAST". They do not any more: the KEY does.
1213
- * A9 was taken when this line carried no key, and at a width where the
1214
- * chip, the full metadata and " · ctrl+o" cannot coexist, a fold with
1215
- * no key is the turn's work behind a line with no way back to it. So
1216
- * the order is now words, then metadata, then — never — the key. The
1217
- * glyph is ✦ and the zero terms are dropped (R3b), so the example above
1218
- * is written as the code renders it rather than as A9 first wrote it. */
1219
- /**
1220
- * R3b — what a run of work DID, in words. One definition, because two
1221
- * surfaces say it: the fold line (`turnFold`, above) and the expand
1222
- * header the compositor writes when that fold is opened. A second copy
1223
- * would be a second answer to the same question, and the first thing to
1224
- * drift would be the plurals — `search_text` is "matches", not
1225
- * "searchs", and only the ROLLUP_NOUN table knows that.
1226
- *
1227
- * Zero terms are dropped (owner ruling, R3b): a term earns its place by
1228
- * having a count.
1229
- */
1230
- /**
1231
- * R3g (2026-08-28) — the fold's own terms, VERB + COUNT + NOUN.
1232
- *
1233
- * DECLARED SUPERSESSION. R3b built these terms out of ROLLUP_NOUN,
1234
- * whose own comment says not to: that table names what a single-tool
1235
- * rollup COUNTS ("5 matches" — five matched lines), and this line
1236
- * counts CALLS. So one search_text call rendered `1 match`, a sentence
1237
- * that is false whenever the search matched any other number — which is
1238
- * almost always. `shell` fell through to the verb branch and read
1239
- * `4 shells`.
1240
- *
1241
- * The phrasing is the owner's, from the shape they asked for:
1242
- * "thought 17s · read 4 files · listed 1 directory · ran 4 shell
1243
- * commands". A tool with no entry says `3 × <verb>`, which counts calls
1244
- * without inventing a noun for them.
1245
- */
1017
+ /* DECLARED REVERSAL (R13, owner-ruled 2026-09-03): the W13 rollup's
1018
+ noun table (`ROLLUP_NOUN`) and TUI2-R1 (B)'s exploration-run set
1019
+ (`EXPLORE_NOUN`, `isExploreTool`) stood here. Both collapses retired
1020
+ with the fold; the verb column is `displayVerb` for every tool. */
1246
1021
  /**
1247
- * R3i — ONE TERM TABLE, TWO TENSES: [past, progressive, singular, plural].
1248
- *
1249
- * The stretch line is the same row at every instant of a turn: while
1250
- * the work runs it says what it is DOING, and at the settle it says
1251
- * what it DID. That sentence is only true if both tenses come from one
1252
- * table. The v9 review found the alternative already happening on a
1253
- * hand-written prototype — `searching 1 pattern` live against `ran 1
1254
- * search` settled, the NOUN swapping at the settle, and `running 4
1255
- * shells`, which is verbatim the R3g defect the previous round removed.
1256
- *
1257
- * FOLD_TERM below is derived from this, so the settled vocabulary
1258
- * cannot drift from the live one by construction.
1022
+ * THE TERM TABLE [past, singular, plural], one row per tool the recap
1023
+ * names (R3g's phrasing, the owner's: "thought 17s · read 4 files ·
1024
+ * listed 1 directory · ran 4 shell commands"). A tool with no entry
1025
+ * says `3 × <verb>`, which counts calls without inventing a noun for
1026
+ * them; a zero term is dropped (R3b: a term earns its place by having a
1027
+ * count). The terms count CALLS, which is what the CLI hands them.
1028
+ *
1029
+ * DECLARED REVERSAL (R13, owner-ruled 2026-09-03): this table served
1030
+ * the folded-turn line (W14, `turnFold`) and R3i's stretch line, and
1031
+ * carried a PROGRESSIVE column so the live tense and the settled tense
1032
+ * could not drift apart. Nothing folds and nothing reads the
1033
+ * progressive; the recap (`foldTerms`) is the table's only reader.
1034
+ * R3h's object-vs-act distinction (`foldCountsObjects`) retired with
1035
+ * the fold that consumed it.
1259
1036
  */
1260
1037
  const TERM = {
1261
- read_file: ["read", "reading", "file", "files"],
1262
- edit_file: ["edited", "editing", "file", "files"],
1263
- write_file: ["wrote", "writing", "file", "files"],
1264
- list_dir: ["listed", "listing", "directory", "directories"],
1265
- search_text: ["ran", "running", "search", "searches"],
1266
- shell: ["ran", "running", "shell command", "shell commands"],
1038
+ read_file: ["read", "file", "files"],
1039
+ edit_file: ["edited", "file", "files"],
1040
+ write_file: ["wrote", "file", "files"],
1041
+ list_dir: ["listed", "directory", "directories"],
1042
+ search_text: ["ran", "search", "searches"],
1043
+ shell: ["ran", "shell command", "shell commands"],
1267
1044
  };
1268
- const FOLD_TERM = Object.fromEntries(Object.entries(TERM).map(([name, [past, , singular, plural]]) => [name, [past, singular, plural]]));
1269
- /**
1270
- * R3h (fable, 2026-08-29) — WHICH TERMS COUNT OBJECTS.
1271
- *
1272
- * The rule, one sentence: a term that counts OBJECTS counts distinct
1273
- * objects; a term that counts ACTS counts calls. `read 2 files` after
1274
- * reading ONE file twice is a false sentence, and law 1.3 does not
1275
- * become optional because the falsehood is small. `ran 2 searches`
1276
- * after searching the same pattern twice is TRUE — the acts happened.
1277
- *
1278
- * The table sits beside FOLD_TERM so the two cannot drift: a tool whose
1279
- * noun is a thing ("files", "directories") belongs here; a tool whose
1280
- * noun is an act ("searches", "shell commands") does not.
1281
- */
1282
- const FOLD_COUNTS_OBJECTS = new Set(["read_file", "edit_file", "write_file", "list_dir"]);
1283
- /** Does this tool's fold term count distinct targets rather than calls? */
1284
- export function foldCountsObjects(name) {
1285
- return FOLD_COUNTS_OBJECTS.has(name);
1286
- }
1287
1045
  function foldTerm(name, n) {
1288
- const t = FOLD_TERM[name];
1046
+ const t = TERM[name];
1289
1047
  if (t === undefined)
1290
1048
  return `${n} × ${displayVerb(name)}`;
1291
1049
  return `${t[0]} ${n} ${n === 1 ? t[1] : t[2]}`;
@@ -1303,41 +1061,6 @@ export function foldTerms(reads, edits, others) {
1303
1061
  }
1304
1062
  return parts;
1305
1063
  }
1306
- /**
1307
- * R3i — THE STRETCH LINE: the turn's one working row, in three phases.
1308
- *
1309
- * A STRETCH is the run of thinking and tool calls between two blocks of
1310
- * the model's prose. While it runs it is this line plus a bounded act
1311
- * window; when it closes it commits as this same line, frozen, with its
1312
- * key. The contract in one sentence: **the line you watch is the line
1313
- * you keep** — the settle changes the mark, the tense and the key, and
1314
- * nothing else.
1315
- *
1316
- * thinking ✧ thinking 4s
1317
- * acting ✶ reading 6 files · running 4 shell commands
1318
- * settled ✦ thought 9s · read 6 files · ran 4 shell commands · ctrl+o
1319
- *
1320
- * THE GIVE-WAY LADDER, in order, because at some width everything
1321
- * cannot fit:
1322
- *
1323
- * 1. the human's WORDS (the A9 chip on a quiet turn) — they are on
1324
- * screen above, in the chip band;
1325
- * 2. the NOUNS compact, cheapest word first, and stop as soon as the
1326
- * row fits — buying one cell must not spend every substitution;
1327
- * 3. the COUNTS cut, with the honest "…";
1328
- * 4. the TROUBLE CLAUSE cuts. The design first said it never gives
1329
- * way, and that was unimplementable: a long clause overflows after
1330
- * the counts have already cut to a bare "…", and invariant ①
1331
- * throws on that row;
1332
- * 5. the KEY gives way NEVER. A fold with no key is the turn's work
1333
- * behind a line with no way back to it, which is the one thing
1334
- * this row must not be.
1335
- *
1336
- * `…` in this file means CUT HERE and nothing else — which is why the
1337
- * live phases carry no trailing ellipsis for in-flight, though the
1338
- * reference implementation uses one. The moving mark and the present
1339
- * tense already say it twice.
1340
- */
1341
1064
  /**
1342
1065
  * R3i phase 5 — THE ANSWERED QUESTION'S BLOCK.
1343
1066
  *
@@ -1355,7 +1078,7 @@ export function foldTerms(reads, edits, others) {
1355
1078
  * is emphasis, never information). A typed answer says `(typed)`,
1356
1079
  * because where an answer came from is a fact about it.
1357
1080
  *
1358
- * It is WORDS, not work (law 1.7): it never folds into a stretch line,
1081
+ * It is WORDS, not work (law 1.7): no summary ever stands for it,
1359
1082
  * because the one thing a summary must not do is speak for the human.
1360
1083
  *
1361
1084
  * A result that is not the ask's own JSON yields NOTHING. This renderer
@@ -1393,45 +1116,11 @@ export function askedBlock(resultText, seconds, W) {
1393
1116
  }),
1394
1117
  ];
1395
1118
  }
1396
- const STRETCH_COMPACT = [
1397
- ["directories", "dirs"],
1398
- ["directory", "dir"],
1399
- ["shell commands", "commands"],
1400
- ["shell command", "command"],
1401
- ];
1402
- /** R3i — a stretch of exactly ONE call names its TARGET instead of its
1403
- * count. `thought 2s · read 1 file` replaces two rows — the thinking
1404
- * and the call — with a row that says less than either of them did,
1405
- * and "thinking plus one call" is the commonest shape a narrating
1406
- * model makes. This is the answer to the defect R3d killed R3b's
1407
- * per-segment folds over; the "absorbs at least two rows" rule alone
1408
- * does not answer it. */
1409
- function stretchTerms(t, live) {
1410
- // R4 — the tense is per TERM. A name with nothing in flight is in the
1411
- // past whatever the line's own phase is; with liveNames absent (the
1412
- // settled line) every term follows the line.
1413
- const tense = (name) => (live && (t.liveNames === undefined || t.liveNames.includes(name)) ? 1 : 0);
1414
- const total = t.calls.reduce((n, [, c]) => n + c, 0);
1415
- // R4 — the one-call TARGET form is the SETTLED line's. Live, the act
1416
- // slot directly below already names the target on its head row, so
1417
- // the line was printing the same words twice, one above the other
1418
- // (`running npm run check` over `shell npm run check`). Settled there
1419
- // is no slot, and naming the target is strictly more than counting to
1420
- // one — which is the R3i rule this keeps, where it applies.
1421
- if (total === 1 && t.targets.length === 1 && !live) {
1422
- const [name] = t.calls[0];
1423
- const e = TERM[name];
1424
- return [`${e === undefined ? name : e[0]} ${t.targets[0]}`];
1425
- }
1426
- return t.calls
1427
- .filter(([, n]) => n > 0)
1428
- .map(([name, n]) => {
1429
- const e = TERM[name];
1430
- if (e === undefined)
1431
- return `${n} × ${displayVerb(name)}`;
1432
- return `${e[tense(name)]} ${n} ${n === 1 ? e[2] : e[3]}`;
1433
- });
1434
- }
1119
+ /* DECLARED REVERSAL (R13, owner-ruled 2026-09-03): the stretch line's
1120
+ term machinery stood here — `StretchTerms`, `STRETCH_COMPACT`,
1121
+ `stretchTerms` (R3i's one-line-per-stretch, R4's per-term tense).
1122
+ The line retired with the fold; the TERM table above survives for the
1123
+ turn's recap alone. */
1435
1124
  // ---- the bounded-block flow contract (W7, W8, W10) ----
1436
1125
  /** The caps — screen rows counted AFTER the fold, at the current width
1437
1126
  * (the W7 table). The renderer-cut row is inside the cap. */
@@ -1441,14 +1130,7 @@ export const CAP_PREVIEW = 5;
1441
1130
  /** DC-46 — the running window's ceiling is the SETTLED preview's, and a
1442
1131
  * running card reaches it by growing rather than by being handed it.
1443
1132
  * `LIVE_WINDOW` (CAP_PREVIEW + 1) retires with the allocation it sized. */
1444
- /** The rows a card costs besides its window: two pads, the head, two
1445
- * blanks and the status row. Below this there is no card (DC-43). */
1446
- export const CARD_CHROME = 6;
1447
1133
  const CAP_DIFF = 12; // the approval diff: head + the named middle + tail
1448
- /** The block body rows' prefixes (W2's gutter table): │ a bounded
1449
- * block's body, └ the block's last row — what was cut, where the rest
1450
- * is — at the LEFT EDGE (the gutter column: the left edge alone
1451
- * distinguishes the states at --plain). Structural (constraint 1). */
1452
1134
  /** R8a — A TOOL BLOCK'S ROWS ARE INDENTED, NOT GUTTERED.
1453
1135
  *
1454
1136
  * `│ ` on every row drew a bar down the left of every multi-row
@@ -1462,8 +1144,7 @@ const CAP_DIFF = 12; // the approval diff: head + the named middle + tail
1462
1144
  * row (see openBlock). In-block notes take the same indent,
1463
1145
  * no glyph — because a second `└` inside one block would be the same
1464
1146
  * mark meaning two things (§4.1). CUT_ROW is unchanged for the
1465
- * surfaces that are not a tool block: the fold row's target list, the
1466
- * slot's overflow count. */
1147
+ * surfaces that are not a tool block. */
1467
1148
  /** R8a's four columns — off the surface. Inside a painted card every
1468
1149
  * row sits at column 2 (R13 E4): the head row and the outcome row
1469
1150
  * bracket the preview, so the indent is no longer what says "these
@@ -1533,10 +1214,18 @@ function slabRow(inner, W) {
1533
1214
  * metadata rows: the parts give way in a PINNED ORDER, and the part
1534
1215
  * that carries the semantics is the one reserved. */
1535
1216
  function pickTier(tiers, room) {
1217
+ return firstFit(tiers, room) ?? tiers[tiers.length - 1];
1218
+ }
1219
+ /** The widest form that fits the row, or null when none does — the
1220
+ * settled head row asks this with its target whole, and only then
1221
+ * cuts the target. The three rows that give way in a pinned order
1222
+ * (the settled head, the running head, the outcome row) share this
1223
+ * walk; their ORDERS differ by ruling and stay their own. */
1224
+ function firstFit(tiers, room) {
1536
1225
  for (const t of tiers)
1537
1226
  if (visibleWidth(t) <= room)
1538
1227
  return t;
1539
- return tiers[tiers.length - 1];
1228
+ return null;
1540
1229
  }
1541
1230
  function noteRow(text, W, tone) {
1542
1231
  const p = palette();
@@ -1874,91 +1563,12 @@ function liveCap(c, room) {
1874
1563
  liveHighWater.set(c, cap);
1875
1564
  return cap;
1876
1565
  }
1877
- /**
1878
- * R4the standing act slot.
1879
- *
1880
- * The stretch's ONE line sits above it; this is the region under it,
1881
- * and it STANDS: allocated when the stretch opens, released at the
1882
- * fold.
1883
- *
1884
- * R3i built the same window INTERMITTENTLY — a running call got its
1885
- * fixed 1+3 block (W8), a finished one got nothing — so the live
1886
- * region's height was a function of how many calls happened to be in
1887
- * flight this frame. Over one real stretch that is 2 rows, then 7,
1888
- * then 2, then 17 for a three-call batch, then 2 again, and every
1889
- * transition scrolls everything above it. The owner's report was that
1890
- * the screen "keeps jumping", and it was an accurate description of
1891
- * the design, not a defect in its execution.
1892
- *
1893
- * The cure is not a smaller window, it is a STANDING one: between two
1894
- * calls the slot keeps the call that just finished rather than
1895
- * collapsing, and before any call it keeps the thinking that is
1896
- * producing them — which is R3i ruling 5 ("thinking belongs on the
1897
- * stretch line, IN THE ACT WINDOW, and in full in expansions") finally
1898
- * wired, since R3i stated it while building no window for the thinking
1899
- * phase to live in.
1900
- *
1901
- * Four rows, deliberately the same 1+3 shape W8 gave a running call, so
1902
- * the commonest frame — exactly one call in flight — renders byte-for-
1903
- * byte what 0.17.0 shipped.
1904
- */
1905
- export const ACT_SLOT_ROWS = 4;
1906
- /**
1907
- * R4 — the slot's body rows: the tail of `text`, newest at the BOTTOM,
1908
- * bottom-padded to exactly `rows`.
1909
- *
1910
- * The same dim │ gutter a running call's window uses (W2's table), and
1911
- * the same two VD-4 rules: leading blank gutters are skipped, and the
1912
- * short-output pad goes at the BOTTOM so output starts under its own
1913
- * header and grows downward. The slot's CONTENTS change; its shape
1914
- * does not.
1915
- */
1916
- export function slotTail(text, W, rows) {
1917
- if (rows <= 0)
1918
- return [];
1919
- const p = palette();
1920
- const all = blockRows(text, W);
1921
- const from = all.findIndex((r) => visibleWidth(r) > visibleWidth(bodyRow()));
1922
- const body = from < 0 ? [] : all.slice(from);
1923
- // R7a: no pad. The slot stopped padding (see slotPad) and this was
1924
- // the same pad by another route — three blank rows under a call with
1925
- // nothing to say yet, which is the hole a7's blank-run guard prices.
1926
- // R8a: the corner opens whatever slice survives the cap.
1927
- return openBlock(body.slice(Math.max(0, body.length - rows)));
1928
- }
1929
- /** R4 — clamp or pad assembled slot rows to EXACTLY `rows`. The padding
1930
- * is what makes the slot stand; the clamp is what keeps the slot from
1931
- * ever being the thing that trips the force-commit cap (a slot that
1932
- * could overflow would commit real cells to relieve blank rows). */
1933
- export function slotPad(content, rows) {
1934
- if (rows <= 0)
1935
- return [];
1936
- // R7a — THE SLOT NO LONGER PADS. It caps, and that is all.
1937
- //
1938
- // R4 padded to a fixed height because the slot's content came and
1939
- // went: a finished call left the block, the block shrank, and every
1940
- // row above it moved. The pad bought stability with rows drawn as
1941
- // `│`, which is why a tall empty gutter ran down the screen under
1942
- // every short block — the owner's own screenshot, and law 1.3's
1943
- // case: a mark on a row with nothing to mark.
1944
- //
1945
- // Blanking the gutter revealed the hole it had been covering, and
1946
- // the a7 replay priced the hole: blank runs over 2 in 653 of 733
1947
- // frames, the screen never durably filling. So the pad had to go —
1948
- // and the height it was buying is now bought by the CONTENT, since
1949
- // R7a keeps every call's row for the life of the stretch. A block
1950
- // whose rows only accumulate cannot shrink, so there is nothing
1951
- // left for a pad to hold up. Measured: 65 of 733 at 40x24, the
1952
- // pre-R7a number exactly, with the motion gates still green.
1953
- return content.slice(0, rows);
1954
- }
1955
- /** R4 — the slot's overflow row: the calls in flight beyond the head
1956
- * budget. It lives INSIDE the slot (it is one of the four rows), which
1957
- * is what keeps a parallel burst from growing the region. */
1958
- export function moreRunningRow(n, W) {
1959
- const p = palette();
1960
- return cutLine(` ${p.dim}${CUT_ROW}+${n} more running${p.reset}`, W);
1961
- }
1566
+ /* DECLARED REVERSAL (R13, owner-ruled 2026-09-03): R4's standing act
1567
+ slot stood here`ACT_SLOT_ROWS`, `slotTail`, `slotPad`,
1568
+ `moreRunningRow` — a fixed four-row region that held the stretch's
1569
+ running call so the live region's height would not follow the call
1570
+ count. Every call is its own card now, allocated at its own height
1571
+ (E2, DC-46), so there is no slot for a call to occupy. */
1962
1572
  /** W12: the delegate's child sessions collapse to the tool row plus ONE
1963
1573
  * line — the height NEVER changes (running → settled replaces the row
1964
1574
  * in place). The running row derives from the INPUT: the parent has no
@@ -2159,45 +1769,6 @@ class Banner {
2159
1769
  * done-collapse. Every live row CUTS at W (never folds) — the block's
2160
1770
  * height is its row count. */
2161
1771
  export const CAP_TASK_LIVE = 6;
2162
- /** W20 — the live block's fixed-window row cut: an SGR-aware ONE-ROW
2163
- * truncation (foldLine wraps; a wrapped row would break the height
2164
- * cap — every live row is exactly one screen row at every width).
2165
- * A line that fits (≤ W) passes through whole; an overflow cuts the
2166
- * content at W−1 — the ellipsis's slot — and the ellipsis rides AFTER
2167
- * the reset (post-reset — the PTY needles' convention). The cut row
2168
- * never exceeds W (invariant ①). W21: exported for the approval
2169
- * panel's single-row lines (the rule line, the title, the divider,
2170
- * the options/affordance rows). */
2171
- export function cutLine(line, W) {
2172
- if (visibleWidth(line) <= W)
2173
- return line;
2174
- let out = "";
2175
- let width = 0;
2176
- for (let i = 0; i < line.length;) {
2177
- if (line[i] === "\x1b") {
2178
- // exec returns an ARRAY — copying m coerces it (the match), but
2179
- // m.length is the CAPTURE count (1), not the sequence length:
2180
- // the old `i += m.length` re-processed the sequence's bracket
2181
- // text as literal rows, doubling every code in a cut line
2182
- // (the W21 panel-slot red test). Index 0 is the sequence.
2183
- const m = /^\x1b\[[0-9;]*m/.exec(line.slice(i))?.[0] ?? line[i];
2184
- out += m;
2185
- i += m.length;
2186
- continue;
2187
- }
2188
- const cw = displayWidth(line[i]);
2189
- if (width + cw > W - 1)
2190
- break; // reserve the ellipsis's column
2191
- out += line[i];
2192
- width += cw;
2193
- i += 1;
2194
- }
2195
- // R8a: the reset comes from the PALETTE, not hardcoded. `\x1b[0m`
2196
- // here put an escape into every cut row under NO_COLOR and behind a
2197
- // pipe — the one context COLOR_OFF exists to keep clean (§1.2). A
2198
- // coloured palette is byte-identical, because its reset IS `\x1b[0m`.
2199
- return `${out}${palette().reset}…`;
2200
- }
2201
1772
  /** W20 — the settled block's duration, the `2h 14m` form (the task
2202
1773
  * narrative's long-horizon idiom): minutes+seconds under an hour,
2203
1774
  * hours+minutes past it. */
@@ -2272,7 +1843,7 @@ class Checklist {
2272
1843
  return [cutLine(header, W), ...itemRows.map((r) => cutLine(r, W))];
2273
1844
  }
2274
1845
  }
2275
- // ---- the chrome components (the status container, the slot, the footer) ----
1846
+ // ---- the chrome components (the status container, the footer) ----
2276
1847
  /** The status container's row: the status text (+ the tail) with the
2277
1848
  * right-aligned "/ commands · ↑ history" hint in the idle state —
2278
1849
  * the hint CUT FIRST when the width is short (the #16g rule); when
@@ -2295,20 +1866,28 @@ class Checklist {
2295
1866
  * survives longest because it is the door to everything; `ctrl+r`
2296
1867
  * outranks `↑ history` because pressing up is how a person finds the
2297
1868
  * history by accident, and nothing finds ctrl+r by accident. */
2298
- export function idleHint(room) {
1869
+ export function idleHint(room, expand = null) {
2299
1870
  // The third rung is today's hint, kept so that NO width loses
2300
1871
  // something that used to fit: without it, a room of 24-30 columns
2301
1872
  // fell all the way to `/ commands` even though the old form fitted.
2302
1873
  // So the ladder is not a strict ranking of the three affordances —
2303
1874
  // it is the widest honest form at each room, and ctrl+r is on the
2304
1875
  // first two rungs rather than on all of them.
2305
- for (const form of [" / commands · ↑ history · ctrl+r transcript", " / commands · ctrl+r transcript", " / commands · ↑ history", " / commands"]) {
1876
+ //
1877
+ // D-S2-1 (owner-ruled 2026-09-06): the ctrl+o SWITCH is named on the
1878
+ // two widest rungs, beside ctrl+r, and only while a card on screen
1879
+ // has something behind the key — the caller passes null otherwise,
1880
+ // and the ladder is then exactly the one above. It replaced the
1881
+ // per-card bright token (TUI2-R2 ⑤): a global switch has no single
1882
+ // target to mark.
1883
+ const switchRungs = expand === null ? [] : [` / commands · ↑ history · ctrl+o ${expand} · ctrl+r transcript`, ` / commands · ctrl+o ${expand} · ctrl+r transcript`];
1884
+ for (const form of [...switchRungs, " / commands · ↑ history · ctrl+r transcript", " / commands · ctrl+r transcript", " / commands · ↑ history", " / commands"]) {
2306
1885
  if (visibleWidth(form) <= room)
2307
1886
  return form;
2308
1887
  }
2309
1888
  return "";
2310
1889
  }
2311
- export function statusLine(status, tail, W, hint) {
1890
+ export function statusLine(status, tail, W, hint, expand = null) {
2312
1891
  const p = palette();
2313
1892
  const text = `${status}${tail === "" ? "" : ` · ${tail}`}`;
2314
1893
  // W18: the hint is a parameter — the compacting row right-aligns its
@@ -2319,25 +1898,12 @@ export function statusLine(status, tail, W, hint) {
2319
1898
  if (statusW > W) {
2320
1899
  return `${p.dim}${widthCut(text, W - 1)}…${p.reset}`;
2321
1900
  }
2322
- const hintText = hint ?? idleHint(Math.max(0, W - statusW));
1901
+ const hintText = hint ?? idleHint(Math.max(0, W - statusW), expand);
2323
1902
  const hintW = visibleWidth(hintText);
2324
1903
  if (hintW === 0 || statusW + hintW > W)
2325
1904
  return `${p.dim}${text}${p.reset}`;
2326
1905
  return `${p.dim}${text}${" ".repeat(Math.max(0, W - statusW - hintW))}${hintText}${p.reset}`;
2327
1906
  }
2328
- /** The display-width prefix of a plain (SGR-free) text. W21: exported
2329
- * for the approval panel's option-2 rule-name cut. */
2330
- export function widthCut(text, max) {
2331
- let w = 0;
2332
- let i = 0;
2333
- for (; i < text.length; i += 1) {
2334
- const cw = displayWidth(text[i]);
2335
- if (w + cw > max)
2336
- break;
2337
- w += cw;
2338
- }
2339
- return text.slice(0, i);
2340
- }
2341
1907
  /**
2342
1908
  * TUI2-R3v2 ① — THE selection bar. One engine, every selection surface.
2343
1909
  *