@vincemakes/kiso-tui-cells 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.
@@ -32,6 +32,18 @@ export interface FrameCtx {
32
32
  * the tier table reads H, so a resize RE-TIERS instead of
33
33
  * re-folding frozen rows). */
34
34
  readonly height: number;
35
+ /** R7a — this cell is drawn UNDER an activity header that carries the
36
+ * breathing mark, so its own head row wears a plain gutter.
37
+ *
38
+ * The mark belongs to the ACTIVITY, not to each call in it. A
39
+ * four-file burst drew four breathing marks, which is four marks
40
+ * distinguishing nothing (law 1.3, the same ground R2 retired the
41
+ * tick and cross on) — and worse, on a read that finishes in
42
+ * 200ms the mark is gone before the eye lands, so per-row it is
43
+ * motion that never resolves into meaning. On the header it is lit
44
+ * for the whole stretch, which is the fact it is there to carry:
45
+ * work is in flight. Owner-ruled 2026-08-31. */
46
+ readonly grouped?: boolean;
35
47
  }
36
48
  /** ONE screen line a component emits (raw, SGR included). */
37
49
  export type RenderLine = string;
@@ -392,7 +404,11 @@ export interface StretchTerms {
392
404
  */
393
405
  export declare function stretchLine(t: StretchTerms & {
394
406
  readonly phase: "thinking" | "acting" | "settled";
407
+ readonly mark?: string;
395
408
  }, W: number): string[];
409
+ /** R6/D3: the quiet turn's fold wears no mark either — the SECOND
410
+ * emission site, and the one the D3 brief did not name. Same ruling,
411
+ * same two-space indent; see stretchLine above for the argument. */
396
412
  export declare function turnFold(t: {
397
413
  words: string;
398
414
  thoughtSeconds: number;
@@ -492,6 +508,21 @@ export declare function formatDuration(totalSeconds: number): string;
492
508
  * W21: the question param is gone — the old question slot retires; a
493
509
  * pending approval's status IS the panel's (the compositor derives
494
510
  * it from the bound panel state). */
511
+ /** R8b — THE IDLE HINT GIVES WAY IN ORDER, and `ctrl+o` is on it.
512
+ *
513
+ * The transcript viewer shipped in 0.19.0 and was reachable only from
514
+ * the `?` sheet: not on the banner's key line, not here. A feature
515
+ * whose only advertisement is a screen you have to already know to
516
+ * open is DC-30's lesson pointing the other way.
517
+ *
518
+ * It cannot simply be appended, because this hint is dropped WHOLE
519
+ * when it does not fit — a longer string would take `/ commands` down
520
+ * with it on a narrow terminal. So the forms are a ladder, and the
521
+ * order says which affordance is least replaceable: `/ commands`
522
+ * survives longest because it is the door to everything; `ctrl+o`
523
+ * outranks `↑ history` because pressing up is how a person finds the
524
+ * history by accident, and nothing finds ctrl+o by accident. */
525
+ export declare function idleHint(room: number): string;
495
526
  export declare function statusLine(status: string, tail: string, W: number, hint?: string): string;
496
527
  /** The display-width prefix of a plain (SGR-free) text. W21: exported
497
528
  * for the approval panel's option-2 rule-name cut. */
@@ -149,7 +149,10 @@ export function cellComponent(cell) {
149
149
  case "user":
150
150
  return new UserMessage(cell);
151
151
  case "thinking":
152
- return new ThinkingFold(cell);
152
+ // R7: the committed/live surface is the BLOCK. ThinkingFold
153
+ // survives for the pipe path (render.ts's foldThinking), whose
154
+ // bytes are asserted by the --plain identity gate.
155
+ return new ThinkingBlock(cell);
153
156
  case "tool":
154
157
  return new ToolExecution(cell);
155
158
  case "md":
@@ -281,6 +284,57 @@ export function pendingQueueRows(lines, W) {
281
284
  * CJK — 2 cells per char — and tripped invariant ① on a real
282
285
  * Chinese session). W2: the leading ⋯ is the thinking gutter — the
283
286
  * midline mark (the state), never the text ellipsis (the truncation). */
287
+ /**
288
+ * R7 — THINKING IS A BLOCK OF WORDS.
289
+ *
290
+ * The owner's ruling, arrived at from a side-by-side with pi's screen:
291
+ * the model's reasoning reads as its own paragraphs — italic, dim,
292
+ * indented two — and is never folded away. `ThinkingFold` (below, kept
293
+ * for the pipe path) summarised it to one row with a key; four rounds
294
+ * of machinery were then built to hand the rest back, and the owner's
295
+ * complaint through all of them was the same sentence: I cannot see
296
+ * what it was thinking.
297
+ *
298
+ * THE INDENT IS NOT DECORATION. Law 1.2 is SETTLED — "strip every
299
+ * escape sequence and no fact is lost" — and italic is SGR 3, which a
300
+ * pipe strips and `COLOR_OFF` empties outright. Stripped, an italic
301
+ * paragraph and the model's ANSWER are the same bytes, and the reader
302
+ * cannot tell the reasoning from the reply. The two-space indent is the
303
+ * byte that survives: reasoning sits in, the answer stands at the
304
+ * margin. The owner chose this over the un-indented form for exactly
305
+ * that reason, with the cost stated.
306
+ *
307
+ * Paragraphs are preserved (a blank line between them); the newlines
308
+ * INSIDE a paragraph collapse, because a hard-wrapped source line is
309
+ * the model's line width, not the reader's.
310
+ */
311
+ class ThinkingBlock {
312
+ cell;
313
+ constructor(cell) {
314
+ this.cell = cell;
315
+ }
316
+ render(W, _ctx) {
317
+ const p = palette();
318
+ const text = escapeTerminal(this.cell.text).trim();
319
+ if (text === "")
320
+ return [];
321
+ const room = Math.max(1, W - 2);
322
+ const rows = [];
323
+ for (const para of text.split(/\n\s*\n/)) {
324
+ const flat = para.replace(/\s+/g, " ").trim();
325
+ if (flat === "")
326
+ continue;
327
+ if (rows.length > 0)
328
+ rows.push("");
329
+ // foldLine is the ONE width authority and returns real rows —
330
+ // invariant ①b (a row is one physical row) holds by
331
+ // construction rather than by remembering to split.
332
+ for (const line of foldLine(flat, room))
333
+ rows.push(` ${p.dim}${p.italic}${line}${p.italicEnd}${p.reset}`);
334
+ }
335
+ return rows;
336
+ }
337
+ }
284
338
  class ThinkingFold {
285
339
  cell;
286
340
  constructor(cell) {
@@ -687,7 +741,7 @@ class ToolExecution {
687
741
  // cannot be predicted: a turning mark implies progress the
688
742
  // product does not have. With no ground the breath freezes to a
689
743
  // static `●` and says the same thing more quietly.
690
- const out = gutterCut(`${breathFrame(ctx.spinnerI)} `, `${verbCol} ${liveTarget(c)}`, Math.max(4, W - dur.length));
744
+ const out = gutterCut(ctx.grouped === true ? " " : `${breathFrame(ctx.spinnerI)} `, `${verbCol} ${liveTarget(c)}`, Math.max(4, W - dur.length));
691
745
  out[0] = `${out[0]}${p.dim}${dur}${p.reset}`;
692
746
  out.push(...toolBlockBody(c, W));
693
747
  return out;
@@ -873,7 +927,7 @@ export function focusToken(row, W) {
873
927
  // attribute like bold is spent everywhere. It closes with washEnd
874
928
  // rather than a reset, so the surrounding dim survives instead of
875
929
  // having to be re-applied.
876
- return `${row.slice(0, at)}${p.wash}${CTRL_R}${p.washEnd}${row.slice(at + CTRL_R.length)}`;
930
+ return `${row.slice(0, at)}${p.lift}${CTRL_R}${p.dim}${row.slice(at + CTRL_R.length)}`;
877
931
  }
878
932
  // A LIVE row does not carry the affordance today, and the live cell is
879
933
  // the one ctrl+r takes FIRST (expandNext scans the live tail before
@@ -885,7 +939,7 @@ export function focusToken(row, W) {
885
939
  const room = W - visibleWidth(row);
886
940
  if (room < SUFFIX_MIN)
887
941
  return row; // never at the cost of invariant ①
888
- return `${row}${p.dim} · ${p.reset}${p.wash}${CTRL_R}${p.washEnd}`;
942
+ return `${row}${p.dim} · ${p.lift}${CTRL_R}${p.reset}`;
889
943
  }
890
944
  const CTRL_R = "ctrl+r";
891
945
  /** TUI2-R1 (A) — the expanded block's last row: the way back. The
@@ -964,8 +1018,10 @@ export function exploreRows(parts, W) {
964
1018
  // outputs". /last shows the LAST call only — for a nine-call burst that
965
1019
  // is one output out of nine, and a footer that sends the human to a
966
1020
  // place the content is not is worse than a footer that says nothing.
967
- rows.push(cutLine(`${p.dim}${CUT_ROW}${COLLAPSE_ROW}${p.reset}`, W));
968
- return rows;
1021
+ // R8a: the footer is an in-block note — the same indent, no glyph —
1022
+ // and the corner opens the block's first row, like every other one.
1023
+ rows.push(cutLine(`${p.dim}${NOTE_ROW}${COLLAPSE_ROW}${p.reset}`, W));
1024
+ return openBlock(rows);
969
1025
  }
970
1026
  /** The count term with the singular/plural forms — "no reads", "1 read",
971
1027
  * "5 reads". The noun's singular drops the plural suffix ("dirs" → "dir",
@@ -1248,7 +1304,40 @@ function troubleClause(t) {
1248
1304
  export function stretchLine(t, W) {
1249
1305
  const p = palette();
1250
1306
  const live = t.phase !== "settled";
1251
- const mark = t.phase === "settled" ? `${p.bold}✦${p.reset}` : `${p.dim}${t.mark ?? "✧"}${p.reset}`;
1307
+ // DECLARED SUPERSESSION (R6/D3, owner-ruled) THE STRETCH LINE WEARS
1308
+ // NO MARK, in any phase.
1309
+ //
1310
+ // Law 1.3: a symbol earns its cell by carrying a fact the words do
1311
+ // not. When every settled fold, the live line AND the status row all
1312
+ // wear a star, none of them distinguishes anything — it is the tick
1313
+ // and the cross again (R2 retired those on exactly this ground), at
1314
+ // the stretch scale. design.md §7.4 had already ruled the principle
1315
+ // one scale down: "only the call still running carries a mark,
1316
+ // because only it is moving", with a settled call's mark "(none) —
1317
+ // the outcome is in the words. SETTLED." A settled STRETCH wearing
1318
+ // one contradicted a precedent the file had already ratified.
1319
+ //
1320
+ // Nothing settled is being reversed: §4 lists this mark PROPOSED and
1321
+ // §8 lists it OPEN. This is that proposal's ruling arriving, as a
1322
+ // decline, on the owner's own dogfood.
1323
+ //
1324
+ // The replacement is a two-space INDENT, not a column shift: the row
1325
+ // joins the settled-call family's geometry, the indent survives a
1326
+ // pipe as bytes (prose never starts at column 3), and both forms are
1327
+ // 2 cells so the width ladder below does not reflow. The status row's
1328
+ // twinkle survives as the ONE moving mark; `✦ took` survives as the
1329
+ // turn's seal.
1330
+ //
1331
+ // R7a AMENDS this by ONE case: the live ACTING line takes the
1332
+ // breathing mark, passed in. D3 declined a mark on the SETTLED fold,
1333
+ // where the words already carry the outcome; a line that means "work
1334
+ // is in flight RIGHT NOW" carries a fact its words do not, which is
1335
+ // exactly the test law 1.3 sets. It is also where the mark was
1336
+ // migrating TO: §7.4's "only the call still running carries one" now
1337
+ // applies at the stretch scale, one mark for the activity instead of
1338
+ // one per call. Owner-ruled 2026-08-31, on the ground that a fast
1339
+ // call's per-row mark is gone before the eye lands.
1340
+ const mark = t.mark ?? " ";
1252
1341
  // R4a (owner ruling, 2026-08-30) — the fold row prints NO key.
1253
1342
  //
1254
1343
  // R4 printed `· ctrl+r 3` so the row could name its own target. The
@@ -1290,6 +1379,9 @@ export function stretchLine(t, W) {
1290
1379
  const row = `${mark}${words === "" ? "" : ` ${p.rv}${words}${p.rvEnd}${p.dim} ·${p.reset}`} ${meta}${clause === "" ? "" : `${p.red}${clause}${p.reset}`}${key === "" ? "" : `${p.dim}${key}${p.reset}`}`;
1291
1380
  return [visibleWidth(row) <= W ? row : cutLine(row, W)];
1292
1381
  }
1382
+ /** R6/D3: the quiet turn's fold wears no mark either — the SECOND
1383
+ * emission site, and the one the D3 brief did not name. Same ruling,
1384
+ * same two-space indent; see stretchLine above for the argument. */
1293
1385
  export function turnFold(t, W) {
1294
1386
  const p = palette();
1295
1387
  // R3b (owner, 2026-08-27): ZERO TERMS ARE DROPPED. W14 always wrote
@@ -1353,13 +1445,13 @@ export function turnFold(t, W) {
1353
1445
  const keyW = KEY.length;
1354
1446
  const key = `${p.dim}${KEY}${p.reset}`;
1355
1447
  if (words === "") {
1356
- const keyed = `${p.bold}✦${p.reset} ${meta}${key}`;
1448
+ const keyed = ` ${meta}${key}`;
1357
1449
  if (visibleWidth(keyed) <= W)
1358
1450
  return [keyed];
1359
1451
  let tightMeta = meta;
1360
1452
  for (const [long, short] of COMPACT) {
1361
1453
  tightMeta = tightMeta.replaceAll(long, short);
1362
- const tight = `${p.bold}✦${p.reset} ${tightMeta}${key}`;
1454
+ const tight = ` ${tightMeta}${key}`;
1363
1455
  if (visibleWidth(tight) <= W)
1364
1456
  return [tight];
1365
1457
  }
@@ -1375,22 +1467,22 @@ export function turnFold(t, W) {
1375
1467
  // closed in the thinking fold, still open here. A width with no
1376
1468
  // room for the mark is a width with nothing to say: the row is
1377
1469
  // a hard cut of what it would have said.
1378
- const cut = `${p.bold}✦${p.reset} ${widthCut(tightMeta, Math.max(1, W - 3))}…`;
1379
- return [visibleWidth(cut) <= W ? cut : cutLine(`${p.bold}✦${p.reset} ${tightMeta}`, W)];
1470
+ const cut = ` ${widthCut(tightMeta, Math.max(1, W - 3))}…`;
1471
+ return [visibleWidth(cut) <= W ? cut : cutLine(` ${tightMeta}`, W)];
1380
1472
  }
1381
- return [`${p.bold}✦${p.reset} ${widthCut(tightMeta, room)}…${key}`];
1473
+ return [` ${widthCut(tightMeta, room)}…${key}`];
1382
1474
  }
1383
1475
  // A9 (ruling R2, mock A): the user chip rides the fold — the human's
1384
1476
  // words LEAD the one line, the same SGR-7 bracket as the live user
1385
1477
  // row (#16f, side pads included). The words take the fold's width
1386
- // budget: W − the gutter ("✦ " = 2) − the join (" · " = 3) − the
1478
+ // budget: W − the gutter (two spaces = 2, R6/D3) − the join (3) − the
1387
1479
  // chip's side pads (2) − the cut-tail reserve (1, the "…") − the
1388
1480
  // metadata's own width — the metadata survives, the words width-cut
1389
1481
  // at the end with the honest "…" (the "…" alone is the honest floor:
1390
1482
  // the words were there, cut).
1391
- const budget = Math.max(0, W - visibleWidth(`✦ ${meta}`) - 6 - keyW);
1483
+ const budget = Math.max(0, W - visibleWidth(` ${meta}`) - 6 - keyW);
1392
1484
  const cut = visibleWidth(words) > budget ? `${widthCut(words, budget)}…` : words;
1393
- const row = `${p.bold}✦${p.reset} ${p.rv} ${cut} ${p.rvEnd} · ${meta}${key}`;
1485
+ const row = ` ${p.rv} ${cut} ${p.rvEnd} · ${meta}${key}`;
1394
1486
  if (visibleWidth(row) <= W)
1395
1487
  return [row];
1396
1488
  // R3h: the same COMPACT ladder the wordless branch walks — the nouns
@@ -1398,7 +1490,7 @@ export function turnFold(t, W) {
1398
1490
  let chipMeta = meta;
1399
1491
  for (const [long, short] of COMPACT) {
1400
1492
  chipMeta = chipMeta.replaceAll(long, short);
1401
- const tighter = `${p.bold}✦${p.reset} ${p.rv} ${cut} ${p.rvEnd} · ${chipMeta}${key}`;
1493
+ const tighter = ` ${p.rv} ${cut} ${p.rvEnd} · ${chipMeta}${key}`;
1402
1494
  if (visibleWidth(tighter) <= W)
1403
1495
  return [tighter];
1404
1496
  }
@@ -1412,10 +1504,10 @@ export function turnFold(t, W) {
1412
1504
  // bracket's pads, the join and the "…" are ten cells before a
1413
1505
  // single character of content, so every width below ten threw
1414
1506
  // invariant ① — see the wordless branch above for the same class.
1415
- const tail = `${p.bold}✦${p.reset} ${p.rv} ${cut} ${p.rvEnd} · ${widthCut(compactAll(meta), Math.max(1, W - 8 - visibleWidth(cut)))}…`;
1416
- return [visibleWidth(tail) <= W ? tail : cutLine(`${p.bold}✦${p.reset} ${p.rv} ${cut} ${p.rvEnd}`, W)];
1507
+ const tail = ` ${p.rv} ${cut} ${p.rvEnd} · ${widthCut(compactAll(meta), Math.max(1, W - 8 - visibleWidth(cut)))}…`;
1508
+ return [visibleWidth(tail) <= W ? tail : cutLine(` ${p.rv} ${cut} ${p.rvEnd}`, W)];
1417
1509
  }
1418
- return [`${p.bold}✦${p.reset} ${p.rv} ${cut} ${p.rvEnd} · ${widthCut(compactAll(meta), room)}…${key}`];
1510
+ return [` ${p.rv} ${cut} ${p.rvEnd} · ${widthCut(compactAll(meta), room)}…${key}`];
1419
1511
  }
1420
1512
  // ---- the bounded-block flow contract (W7, W8, W10) ----
1421
1513
  /** The caps — screen rows counted AFTER the fold, at the current width
@@ -1428,8 +1520,43 @@ const CAP_ERROR = 3; // the error text head
1428
1520
  * block's body, └ the block's last row — what was cut, where the rest
1429
1521
  * is — at the LEFT EDGE (the gutter column: the left edge alone
1430
1522
  * distinguishes the states at --plain). Structural (constraint 1). */
1431
- const BODY_ROW = "│ ";
1523
+ /** R8a A TOOL BLOCK'S ROWS ARE INDENTED, NOT GUTTERED.
1524
+ *
1525
+ * `│ ` on every row drew a bar down the left of every multi-row
1526
+ * output, which is what the owner kept pointing at. The fact the bar
1527
+ * carried — "these rows are the call's output, not prose" — is real
1528
+ * and law 1.2 requires it survive a pipe, so it moves into the
1529
+ * INDENT: four columns, one level deeper than the header (2) and than
1530
+ * prose (2). Bytes still tell them apart; no column of glyphs.
1531
+ *
1532
+ * `└` survives as the mark that OPENS the block, once, on its first
1533
+ * row (see openBlock). In-block notes take NOTE_ROW — the same indent,
1534
+ * no glyph — because a second `└` inside one block would be the same
1535
+ * mark meaning two things (§4.1). CUT_ROW is unchanged for the
1536
+ * surfaces that are not a tool block: the fold row's target list, the
1537
+ * slot's overflow count. */
1538
+ const BODY_ROW = " ";
1539
+ const NOTE_ROW = " ";
1432
1540
  const CUT_ROW = "└ ";
1541
+ /** R8a — stamp `└` on a block's FIRST row, after every slice and note
1542
+ * has been assembled, so the mark is always on the first row actually
1543
+ * emitted rather than on one a cap may have dropped. */
1544
+ function openBlock(rows) {
1545
+ // the corner goes on the first row that HAS something on it. A cap
1546
+ // or a blank leading output line can put an empty row first, and a
1547
+ // corner there would be a mark on a row with nothing to mark — law
1548
+ // 1.3, which is the rule this whole change is serving.
1549
+ const i = rows.findIndex((r) => visibleWidth(r) > visibleWidth(BODY_ROW));
1550
+ if (i < 0)
1551
+ return rows;
1552
+ const first = rows[i];
1553
+ const at = first.indexOf(BODY_ROW);
1554
+ if (at < 0)
1555
+ return rows;
1556
+ // the corner REPLACES two of the four indent columns, so the text
1557
+ // stays in the same column as every other row of the block.
1558
+ return [...rows.slice(0, i), `${first.slice(0, at)} \u2514 ${first.slice(at + BODY_ROW.length)}`, ...rows.slice(i + 1)];
1559
+ }
1433
1560
  const blockMemo = new WeakMap();
1434
1561
  /** The block's body rows below the header (memoized, W9). */
1435
1562
  function toolBlockBody(c, W) {
@@ -1476,15 +1603,16 @@ function toolBlockBody(c, W) {
1476
1603
  : [];
1477
1604
  const note = c.expanded ? null : toolCutNote(c.name, c.resultText);
1478
1605
  if (note !== null)
1479
- rows.push(...foldLine(`${p.dim}${CUT_ROW}${note}${p.reset}`, W));
1606
+ rows.push(...foldLine(`${p.dim}${NOTE_ROW}${note}${p.reset}`, W));
1480
1607
  // TUI2-R1 (A): an EXPANDED block says how to put it back. The footer
1481
1608
  // rides a block that HAS rows — an expanded delegate whose summary
1482
1609
  // marker is missing renders nothing, and a lone footer under a head
1483
1610
  // row would be an affordance for an empty block.
1484
1611
  if (c.expanded && rows.length > 0)
1485
- rows.push(...foldLine(`${p.dim}${CUT_ROW}${COLLAPSE_ROW}${p.reset}`, W));
1486
- blockMemo.set(c, { width: W, state, content, rows });
1487
- return rows;
1612
+ rows.push(...foldLine(`${p.dim}${NOTE_ROW}${COLLAPSE_ROW}${p.reset}`, W));
1613
+ const opened = openBlock(rows);
1614
+ blockMemo.set(c, { width: W, state, content, rows: opened });
1615
+ return opened;
1488
1616
  }
1489
1617
  /** Fold result text into dim body rows (the BODY_ROW prefix): escape,
1490
1618
  * split, fold each line at W−prefix; trailing empty rows (the result's
@@ -1511,7 +1639,7 @@ function shellTail(text, W) {
1511
1639
  if (rows.length <= CAP_SHELL_SETTLED)
1512
1640
  return rows;
1513
1641
  const kept = CAP_SHELL_SETTLED - 1;
1514
- const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - kept} earlier rows · ctrl+r${p.reset}`, W);
1642
+ const cut = foldLine(`${p.dim}${NOTE_ROW}+${rows.length - kept} earlier rows · ctrl+r${p.reset}`, W);
1515
1643
  return [...rows.slice(rows.length - kept), ...cut];
1516
1644
  }
1517
1645
  /** The error text head: the FIRST rows, capped at 3 — the answer is at
@@ -1531,7 +1659,7 @@ function errorBody(c, W) {
1531
1659
  const rows = blockRows(c.resultText.split("\n").slice(skipFirst).join("\n"), W);
1532
1660
  if (rows.length <= CAP_ERROR)
1533
1661
  return rows;
1534
- const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - (CAP_ERROR - 1)} more · ctrl+r${p.reset}`, W);
1662
+ const cut = foldLine(`${p.dim}${NOTE_ROW}+${rows.length - (CAP_ERROR - 1)} more · ctrl+r${p.reset}`, W);
1535
1663
  return [...rows.slice(0, CAP_ERROR - 1), ...cut];
1536
1664
  }
1537
1665
  /** The running tool's FIXED-height window (W8): exactly 3 rows from
@@ -1542,15 +1670,23 @@ function errorBody(c, W) {
1542
1670
  function liveWindow(text, W) {
1543
1671
  const p = palette();
1544
1672
  if (text === "") {
1545
- return [`${p.dim}${BODY_ROW}${p.reset}`, `${p.dim}${BODY_ROW}${p.reset}`, `${p.dim}${CUT_ROW}waiting for output${p.reset}`];
1673
+ // R7a: blank, not two bare gutters. A `│` marks a row that HAS
1674
+ // content; two of them above "waiting for output" drew a tall
1675
+ // empty bar under every command that had not printed yet — which
1676
+ // is most of them, for their first second.
1677
+ // ...and the waiting row sits DIRECTLY under its header, with the
1678
+ // blanks below it — VD-4's own rule ("the output starts under its
1679
+ // own header and grows downward"), which the gutter rows used to
1680
+ // satisfy by accident and blanks made visible as a two-row gap.
1681
+ return [`${p.dim}${NOTE_ROW}waiting for output${p.reset}`, "", ""];
1546
1682
  }
1547
1683
  const rows = blockRows(text, W);
1548
1684
  if (rows.length <= CAP_LIVE_WINDOW) {
1549
1685
  while (rows.length < CAP_LIVE_WINDOW)
1550
- rows.push(`${p.dim}${BODY_ROW}${p.reset}`);
1686
+ rows.push(""); // R7a: blank, not a bar
1551
1687
  return rows;
1552
1688
  }
1553
- const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - (CAP_LIVE_WINDOW - 1)} earlier rows · ctrl+r${p.reset}`, W);
1689
+ const cut = foldLine(`${p.dim}${NOTE_ROW}+${rows.length - (CAP_LIVE_WINDOW - 1)} earlier rows · ctrl+r${p.reset}`, W);
1554
1690
  return [...rows.slice(rows.length - (CAP_LIVE_WINDOW - 1)), ...cut];
1555
1691
  }
1556
1692
  /**
@@ -1593,8 +1729,8 @@ function shellLiveTail(text, W) {
1593
1729
  return liveWindow("", W);
1594
1730
  const kept = rows.slice(Math.max(0, rows.length - (CAP_LIVE_WINDOW - 1)));
1595
1731
  while (kept.length < CAP_LIVE_WINDOW - 1)
1596
- kept.push(`${p.dim}${BODY_ROW}${p.reset}`);
1597
- return [...kept, cutLine(`${p.dim}${CUT_ROW}live tail · esc stop · alt+⏎ redirect${p.reset}`, W)];
1732
+ kept.push(""); // R7a: blank, not a bar
1733
+ return [...kept, cutLine(`${p.dim}${NOTE_ROW}live tail · esc stop · alt+⏎ redirect${p.reset}`, W)];
1598
1734
  }
1599
1735
  /**
1600
1736
  * R4 — the standing act slot.
@@ -1642,10 +1778,11 @@ export function slotTail(text, W, rows) {
1642
1778
  const all = blockRows(text, W);
1643
1779
  const from = all.findIndex((r) => visibleWidth(r) > visibleWidth(BODY_ROW));
1644
1780
  const body = from < 0 ? [] : all.slice(from);
1645
- const kept = body.slice(Math.max(0, body.length - rows));
1646
- while (kept.length < rows)
1647
- kept.push(`${p.dim}${BODY_ROW}${p.reset}`);
1648
- return kept;
1781
+ // R7a: no pad. The slot stopped padding (see slotPad) and this was
1782
+ // the same pad by another route — three blank rows under a call with
1783
+ // nothing to say yet, which is the hole a7's blank-run guard prices.
1784
+ // R8a: the corner opens whatever slice survives the cap.
1785
+ return openBlock(body.slice(Math.max(0, body.length - rows)));
1649
1786
  }
1650
1787
  /** R4 — clamp or pad assembled slot rows to EXACTLY `rows`. The padding
1651
1788
  * is what makes the slot stand; the clamp is what keeps the slot from
@@ -1654,11 +1791,24 @@ export function slotTail(text, W, rows) {
1654
1791
  export function slotPad(content, rows) {
1655
1792
  if (rows <= 0)
1656
1793
  return [];
1657
- const p = palette();
1658
- const out = content.slice(0, rows);
1659
- while (out.length < rows)
1660
- out.push(`${p.dim}${BODY_ROW}${p.reset}`);
1661
- return out;
1794
+ // R7a THE SLOT NO LONGER PADS. It caps, and that is all.
1795
+ //
1796
+ // R4 padded to a fixed height because the slot's content came and
1797
+ // went: a finished call left the block, the block shrank, and every
1798
+ // row above it moved. The pad bought stability with rows drawn as
1799
+ // `│`, which is why a tall empty gutter ran down the screen under
1800
+ // every short block — the owner's own screenshot, and law 1.3's
1801
+ // case: a mark on a row with nothing to mark.
1802
+ //
1803
+ // Blanking the gutter revealed the hole it had been covering, and
1804
+ // the a7 replay priced the hole: blank runs over 2 in 653 of 733
1805
+ // frames, the screen never durably filling. So the pad had to go —
1806
+ // and the height it was buying is now bought by the CONTENT, since
1807
+ // R7a keeps every call's row for the life of the stretch. A block
1808
+ // whose rows only accumulate cannot shrink, so there is nothing
1809
+ // left for a pad to hold up. Measured: 65 of 733 at 40x24, the
1810
+ // pre-R7a number exactly, with the motion gates still green.
1811
+ return content.slice(0, rows);
1662
1812
  }
1663
1813
  /** R4 — the slot's overflow row: the calls in flight beyond the head
1664
1814
  * budget. It lives INSIDE the slot (it is one of the four rows), which
@@ -1889,7 +2039,11 @@ export function cutLine(line, W) {
1889
2039
  width += cw;
1890
2040
  i += 1;
1891
2041
  }
1892
- return `${out}\x1b[0m…`;
2042
+ // R8a: the reset comes from the PALETTE, not hardcoded. `\x1b[0m`
2043
+ // here put an escape into every cut row under NO_COLOR and behind a
2044
+ // pipe — the one context COLOR_OFF exists to keep clean (§1.2). A
2045
+ // coloured palette is byte-identical, because its reset IS `\x1b[0m`.
2046
+ return `${out}${palette().reset}…`;
1893
2047
  }
1894
2048
  /** W20 — the settled block's duration, the `2h 14m` form (the task
1895
2049
  * narrative's long-horizon idiom): minutes+seconds under an hour,
@@ -1974,6 +2128,33 @@ class Checklist {
1974
2128
  * W21: the question param is gone — the old question slot retires; a
1975
2129
  * pending approval's status IS the panel's (the compositor derives
1976
2130
  * it from the bound panel state). */
2131
+ /** R8b — THE IDLE HINT GIVES WAY IN ORDER, and `ctrl+o` is on it.
2132
+ *
2133
+ * The transcript viewer shipped in 0.19.0 and was reachable only from
2134
+ * the `?` sheet: not on the banner's key line, not here. A feature
2135
+ * whose only advertisement is a screen you have to already know to
2136
+ * open is DC-30's lesson pointing the other way.
2137
+ *
2138
+ * It cannot simply be appended, because this hint is dropped WHOLE
2139
+ * when it does not fit — a longer string would take `/ commands` down
2140
+ * with it on a narrow terminal. So the forms are a ladder, and the
2141
+ * order says which affordance is least replaceable: `/ commands`
2142
+ * survives longest because it is the door to everything; `ctrl+o`
2143
+ * outranks `↑ history` because pressing up is how a person finds the
2144
+ * history by accident, and nothing finds ctrl+o by accident. */
2145
+ export function idleHint(room) {
2146
+ // The third rung is today's hint, kept so that NO width loses
2147
+ // something that used to fit: without it, a room of 24-30 columns
2148
+ // fell all the way to `/ commands` even though the old form fitted.
2149
+ // So the ladder is not a strict ranking of the three affordances —
2150
+ // it is the widest honest form at each room, and ctrl+o is on the
2151
+ // first two rungs rather than on all of them.
2152
+ for (const form of [" / commands · ↑ history · ctrl+o transcript", " / commands · ctrl+o transcript", " / commands · ↑ history", " / commands"]) {
2153
+ if (visibleWidth(form) <= room)
2154
+ return form;
2155
+ }
2156
+ return "";
2157
+ }
1977
2158
  export function statusLine(status, tail, W, hint) {
1978
2159
  const p = palette();
1979
2160
  const text = `${status}${tail === "" ? "" : ` · ${tail}`}`;
@@ -1981,13 +2162,13 @@ export function statusLine(status, tail, W, hint) {
1981
2162
  // "esc to cancel" (the same one-line-bounded shape as W12's delegate
1982
2163
  // row; the #16g rule still cuts the HINT first, then the status with
1983
2164
  // a "…" — never a fold).
1984
- const hintText = hint ?? " / commands · ↑ history";
1985
2165
  const statusW = visibleWidth(text);
1986
2166
  if (statusW > W) {
1987
2167
  return `${p.dim}${widthCut(text, W - 1)}…${p.reset}`;
1988
2168
  }
2169
+ const hintText = hint ?? idleHint(Math.max(0, W - statusW));
1989
2170
  const hintW = visibleWidth(hintText);
1990
- if (statusW + hintW > W)
2171
+ if (hintW === 0 || statusW + hintW > W)
1991
2172
  return `${p.dim}${text}${p.reset}`;
1992
2173
  return `${p.dim}${text}${" ".repeat(Math.max(0, W - statusW - hintW))}${hintText}${p.reset}`;
1993
2174
  }
package/dist/render.d.ts CHANGED
@@ -77,6 +77,23 @@ export interface Palette {
77
77
  * spans and must end without stranding them. */
78
78
  readonly wash: string;
79
79
  readonly washEnd: string;
80
+ /** R7a — THE FOCUS MARKER'S EMPHASIS, and it is not a background.
81
+ *
82
+ * DC-3 gave the `ctrl+r` token the wash, which is a BACKGROUND once
83
+ * a ground is resolved: `48;5;236` on dark reads as a black block
84
+ * behind the key, on a row that is otherwise plain text. The owner
85
+ * asked for it gone. The invariant DC-3 was serving — exactly one
86
+ * bright token per frame, because the key has exactly one target —
87
+ * never required a background; it required CONTRAST against the dim
88
+ * siblings, and full-strength bold foreground is more of it than a
89
+ * wash was.
90
+ *
91
+ * Three escapes because "dim" has two forms here: SGR 2 in the
92
+ * neutral palette, a 256-colour foreground in the resolved ones.
93
+ * 22 cancels the attribute, 39 restores the default foreground, 1
94
+ * is the emphasis. It closes by re-opening the palette's own dim,
95
+ * like `washEnd`, so the surrounding span survives. */
96
+ readonly lift: string;
80
97
  readonly reset: string;
81
98
  }
82
99
  /**
package/dist/render.js CHANGED
@@ -28,7 +28,7 @@ const BASE = { bold: "\x1b[1m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32
28
28
  * 4's principle exactly: when the ground is unknown, use the thing that
29
29
  * is correct on any ground rather than guessing one.
30
30
  */
31
- const withWash = (wash, washEnd, red = BASE.red, dim = BASE.dim) => ({ ...BASE, red, dim, wash, washEnd, code: wash });
31
+ const withWash = (wash, washEnd, red = BASE.red, dim = BASE.dim) => ({ ...BASE, red, dim, wash, washEnd, code: wash, lift: "\x1b[22m\x1b[39m\x1b[1m" });
32
32
  /**
33
33
  * DC-3 — one table per ground.
34
34
  *
@@ -56,7 +56,7 @@ export const COLOR_DARK = withWash("\x1b[48;5;236m", "\x1b[49m", "\x1b[38;5;173m
56
56
  * not been established. Unchanged in every byte except `code`, which
57
57
  * was the defect. */
58
58
  export const COLOR_ON = COLOR_NEUTRAL;
59
- export const COLOR_OFF = { bold: "", dim: "", red: "", green: "", warn: "", code: "", italic: "", italicEnd: "", underline: "", underlineEnd: "", rv: "", rvEnd: "", wash: "", washEnd: "", reset: "" };
59
+ export const COLOR_OFF = { bold: "", dim: "", red: "", green: "", warn: "", code: "", italic: "", italicEnd: "", underline: "", underlineEnd: "", rv: "", rvEnd: "", wash: "", washEnd: "", lift: "", reset: "" };
60
60
  /** DC-3 — the resolved ground, set once at startup when the terminal
61
61
  * answers (see `ground.ts`). It starts UNKNOWN and may stay that way
62
62
  * forever; that is a supported state, not a failure. */
package/dist/strings.d.ts CHANGED
@@ -176,6 +176,13 @@ export declare const PANEL_KEYS_ROW = "panels: \u2191\u2193 move \u00B7 \u23CE c
176
176
  * screens pretending to be one. A narrow terminal shows fewer columns
177
177
  * of the same truth, which is the honest degradation.
178
178
  */
179
+ /** R8b — the band's own opening row: a labelled rule at full width.
180
+ *
181
+ * Moved here from the @ picker, unchanged in every byte, because the
182
+ * keys sheet needs it too and `components.ts` already imports this
183
+ * module — the dependency only runs one way. `at-picker.ts` re-exports
184
+ * it, so every existing import site is untouched. */
185
+ export declare function bandHeader(label: string, W: number): string;
179
186
  export declare function keysSheetRows(W: number): string[];
180
187
  /** TUI2-R1 (D) — the keys as ONE line, for /help. The same table the
181
188
  * sheet renders, joined — so the two can disagree only by deleting a
package/dist/strings.js CHANGED
@@ -18,7 +18,7 @@
18
18
  * looked up here.
19
19
  */
20
20
  import { escapeTerminal, palette } from "./render.js";
21
- import { displayWidth } from "./width.js";
21
+ import { displayWidth, visibleWidth } from "./width.js";
22
22
  /** v2a: the interactive prompt — the identity accent. readline owns the
23
23
  * echo of what the user types; we own the prompt's color. (v2c: the
24
24
  * readline prompt keeps "you> " — the brick ▌ is the dock's row only;
@@ -296,6 +296,27 @@ const SHEET_STOPS = [
296
296
  * screens pretending to be one. A narrow terminal shows fewer columns
297
297
  * of the same truth, which is the honest degradation.
298
298
  */
299
+ /** R8b — the band's own opening row: a labelled rule at full width.
300
+ *
301
+ * Moved here from the @ picker, unchanged in every byte, because the
302
+ * keys sheet needs it too and `components.ts` already imports this
303
+ * module — the dependency only runs one way. `at-picker.ts` re-exports
304
+ * it, so every existing import site is untouched. */
305
+ export function bandHeader(label, W) {
306
+ const p = palette();
307
+ const head = `\u2500\u2500\u2500 ${label} `;
308
+ const line = `${head}${"\u2500".repeat(Math.max(1, W - head.length))}`;
309
+ let out = "";
310
+ let w = 0;
311
+ for (const ch of line) {
312
+ const cw = displayWidth(ch);
313
+ if (w + cw > Math.max(1, W))
314
+ break;
315
+ out += ch;
316
+ w += cw;
317
+ }
318
+ return `${p.dim}${out}${p.reset}`;
319
+ }
299
320
  export function keysSheetRows(W) {
300
321
  const p = palette();
301
322
  const cell = (i) => {
@@ -309,7 +330,14 @@ export function keysSheetRows(W) {
309
330
  const b = KEY_BINDINGS[i];
310
331
  return b === undefined ? "" : `${b.keys} ${b.what}`;
311
332
  };
312
- const rows = [`${p.bold}keys${p.reset}`];
333
+ // R8b THE SHEET NAMES ITSELF, in the band vocabulary.
334
+ //
335
+ // Every other overlay does: `─── commands ───`, `─── files ───`,
336
+ // `─── sessions ───`, `── transcript · N folds ──`. This one opened
337
+ // with a bare bold word at column 0, which is the exact condition
338
+ // TUI2-R1.5 ⑦(b) named when it made the rule — with scrollback
339
+ // behind an overlay, nothing said where the surface began.
340
+ const rows = [bandHeader("keys", W)];
313
341
  for (let r = 0; r < SHEET_GRID.length; r += 1) {
314
342
  const indexes = SHEET_GRID[r];
315
343
  let row = "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui-cells",
3
- "version": "0.19.1",
3
+ "version": "0.20.1",
4
4
  "description": "kiso tui-cells — the components cell renderer (components, diff, width, the render slice). Zero runtime dependencies: input is data, output is bytes.",
5
5
  "type": "module",
6
6
  "license": "MIT",