@vincemakes/kiso-tui-cells 0.20.0 → 0.20.2

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.
@@ -215,9 +215,13 @@ export interface PickOption {
215
215
  export interface PickSpec {
216
216
  readonly header: string;
217
217
  readonly options: readonly PickOption[];
218
- /** the `t` row \u2014 typing it directly is always available, because a
219
- * list of profiles is never the list of models that exist */
220
- readonly typeHint: string;
218
+ /** the `t` row typing directly. OPTIONAL, and its absence means the
219
+ * option list is the WHOLE world: `/model`'s profiles never are (a
220
+ * model that exists but is not configured has to be typeable), and
221
+ * `/mode`'s five tiers always are. Offering a `t` row over a closed
222
+ * set is a row that carries no fact — §1.3 — and it is what made
223
+ * the owner read the mode panel as "type the answer". */
224
+ readonly typeHint?: string;
221
225
  /** shown INSTEAD of the options when there are none. The copy is the
222
226
  * caller's and is reproduced verbatim. */
223
227
  readonly emptyNote?: string;
@@ -432,4 +436,12 @@ export declare function pickAffordance(state: PickRuntime): string;
432
436
  /** Compose a pick view. The flavor/name/title/args fields exist for the
433
437
  * approval path and are given inert values here \u2014 the pick block
434
438
  * reads none of them. */
439
+ /** DC-36 — the same shell for the MODE picker.
440
+ *
441
+ * `/model` learned to pick in TUI2-R2 ④; `/mode` never did, and the
442
+ * five tiers are a CLOSED set — the one case where making a human
443
+ * type the answer is least defensible. The pick block reads only
444
+ * `pick` and `statusText`, so a second flavour is a name and a
445
+ * fallback question, not a second mechanism. */
446
+ export declare function modePickView(spec: PickSpec, statusText: string): PanelView;
435
447
  export declare function modelPickView(spec: PickSpec, statusText: string): PanelView;
@@ -543,8 +543,10 @@ export function pickBlockRows(view, state, W, maxRows) {
543
543
  }
544
544
  }
545
545
  const typing = state.phase === "custom";
546
- const tText = cutLine(`${typing ? p.bold : ""}${typing ? "\u2192" : " "} t ${p.reset}${p.dim}${escapeTerminal(spec.typeHint)}${p.reset}`, room);
547
- rows.push(typing ? selectionBar(tText, visibleWidth(tText), W) : ` ${tText}`);
546
+ if (spec.typeHint !== undefined) {
547
+ const tText = cutLine(`${typing ? p.bold : ""}${typing ? "\u2192" : " "} t ${p.reset}${p.dim}${escapeTerminal(spec.typeHint)}${p.reset}`, room);
548
+ rows.push(typing ? selectionBar(tText, visibleWidth(tText), W) : ` ${tText}`);
549
+ }
548
550
  rows.push(` ${p.dim}${cutLine(pickAffordance(state), room)}${p.reset}`);
549
551
  rows.push(`${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`);
550
552
  return rows;
@@ -571,11 +573,36 @@ export function pickStatus(view) {
571
573
  return view.statusText;
572
574
  }
573
575
  export function pickAffordance(state) {
574
- return state.phase === "custom" ? "enter commits \u00b7 esc backs out" : "digits pick \u00b7 \u23ce confirms \u00b7 esc";
576
+ // DC-36 the row NAMES the arrows. TUI2-R2 bound ↑↓ to the pick's
577
+ // cursor and the keys sheet has said `panels: ↑↓ move` ever since,
578
+ // but this row — the one a human is actually looking at while the
579
+ // panel is up — advertised only the digits. The owner read it as
580
+ // "type the answer", which is the same lesson DC-30 filed: a hint
581
+ // that omits the gesture is why the gesture goes unused.
582
+ return state.phase === "custom" ? "enter commits \u00b7 esc backs out" : "\u2191\u2193 move \u00b7 digits pick \u00b7 \u23ce confirms \u00b7 esc";
575
583
  }
576
584
  /** Compose a pick view. The flavor/name/title/args fields exist for the
577
585
  * approval path and are given inert values here \u2014 the pick block
578
586
  * reads none of them. */
587
+ /** DC-36 — the same shell for the MODE picker.
588
+ *
589
+ * `/model` learned to pick in TUI2-R2 ④; `/mode` never did, and the
590
+ * five tiers are a CLOSED set — the one case where making a human
591
+ * type the answer is least defensible. The pick block reads only
592
+ * `pick` and `statusText`, so a second flavour is a name and a
593
+ * fallback question, not a second mechanism. */
594
+ export function modePickView(spec, statusText) {
595
+ return {
596
+ flavor: "simple",
597
+ name: "mode",
598
+ title: "mode",
599
+ speaker: "you",
600
+ statusText,
601
+ args: { kind: "text", lines: [] },
602
+ fallbackQuestion: "switch mode? (name) ",
603
+ pick: spec,
604
+ };
605
+ }
579
606
  export function modelPickView(spec, statusText) {
580
607
  return {
581
608
  flavor: "simple",
@@ -508,6 +508,21 @@ export declare function formatDuration(totalSeconds: number): string;
508
508
  * W21: the question param is gone — the old question slot retires; a
509
509
  * pending approval's status IS the panel's (the compositor derives
510
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;
511
526
  export declare function statusLine(status: string, tail: string, W: number, hint?: string): string;
512
527
  /** The display-width prefix of a plain (SGR-free) text. W21: exported
513
528
  * for the approval panel's option-2 rule-name cut. */
@@ -1018,8 +1018,10 @@ export function exploreRows(parts, W) {
1018
1018
  // outputs". /last shows the LAST call only — for a nine-call burst that
1019
1019
  // is one output out of nine, and a footer that sends the human to a
1020
1020
  // place the content is not is worse than a footer that says nothing.
1021
- rows.push(cutLine(`${p.dim}${CUT_ROW}${COLLAPSE_ROW}${p.reset}`, W));
1022
- 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);
1023
1025
  }
1024
1026
  /** The count term with the singular/plural forms — "no reads", "1 read",
1025
1027
  * "5 reads". The noun's singular drops the plural suffix ("dirs" → "dir",
@@ -1518,8 +1520,43 @@ const CAP_ERROR = 3; // the error text head
1518
1520
  * block's body, └ the block's last row — what was cut, where the rest
1519
1521
  * is — at the LEFT EDGE (the gutter column: the left edge alone
1520
1522
  * distinguishes the states at --plain). Structural (constraint 1). */
1521
- 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 = " ";
1522
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
+ }
1523
1560
  const blockMemo = new WeakMap();
1524
1561
  /** The block's body rows below the header (memoized, W9). */
1525
1562
  function toolBlockBody(c, W) {
@@ -1566,15 +1603,16 @@ function toolBlockBody(c, W) {
1566
1603
  : [];
1567
1604
  const note = c.expanded ? null : toolCutNote(c.name, c.resultText);
1568
1605
  if (note !== null)
1569
- rows.push(...foldLine(`${p.dim}${CUT_ROW}${note}${p.reset}`, W));
1606
+ rows.push(...foldLine(`${p.dim}${NOTE_ROW}${note}${p.reset}`, W));
1570
1607
  // TUI2-R1 (A): an EXPANDED block says how to put it back. The footer
1571
1608
  // rides a block that HAS rows — an expanded delegate whose summary
1572
1609
  // marker is missing renders nothing, and a lone footer under a head
1573
1610
  // row would be an affordance for an empty block.
1574
1611
  if (c.expanded && rows.length > 0)
1575
- rows.push(...foldLine(`${p.dim}${CUT_ROW}${COLLAPSE_ROW}${p.reset}`, W));
1576
- blockMemo.set(c, { width: W, state, content, rows });
1577
- 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;
1578
1616
  }
1579
1617
  /** Fold result text into dim body rows (the BODY_ROW prefix): escape,
1580
1618
  * split, fold each line at W−prefix; trailing empty rows (the result's
@@ -1601,7 +1639,7 @@ function shellTail(text, W) {
1601
1639
  if (rows.length <= CAP_SHELL_SETTLED)
1602
1640
  return rows;
1603
1641
  const kept = CAP_SHELL_SETTLED - 1;
1604
- 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);
1605
1643
  return [...rows.slice(rows.length - kept), ...cut];
1606
1644
  }
1607
1645
  /** The error text head: the FIRST rows, capped at 3 — the answer is at
@@ -1621,7 +1659,7 @@ function errorBody(c, W) {
1621
1659
  const rows = blockRows(c.resultText.split("\n").slice(skipFirst).join("\n"), W);
1622
1660
  if (rows.length <= CAP_ERROR)
1623
1661
  return rows;
1624
- 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);
1625
1663
  return [...rows.slice(0, CAP_ERROR - 1), ...cut];
1626
1664
  }
1627
1665
  /** The running tool's FIXED-height window (W8): exactly 3 rows from
@@ -1640,7 +1678,7 @@ function liveWindow(text, W) {
1640
1678
  // blanks below it — VD-4's own rule ("the output starts under its
1641
1679
  // own header and grows downward"), which the gutter rows used to
1642
1680
  // satisfy by accident and blanks made visible as a two-row gap.
1643
- return [`${p.dim}${CUT_ROW}waiting for output${p.reset}`, "", ""];
1681
+ return [`${p.dim}${NOTE_ROW}waiting for output${p.reset}`, "", ""];
1644
1682
  }
1645
1683
  const rows = blockRows(text, W);
1646
1684
  if (rows.length <= CAP_LIVE_WINDOW) {
@@ -1648,7 +1686,7 @@ function liveWindow(text, W) {
1648
1686
  rows.push(""); // R7a: blank, not a bar
1649
1687
  return rows;
1650
1688
  }
1651
- 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);
1652
1690
  return [...rows.slice(rows.length - (CAP_LIVE_WINDOW - 1)), ...cut];
1653
1691
  }
1654
1692
  /**
@@ -1692,7 +1730,7 @@ function shellLiveTail(text, W) {
1692
1730
  const kept = rows.slice(Math.max(0, rows.length - (CAP_LIVE_WINDOW - 1)));
1693
1731
  while (kept.length < CAP_LIVE_WINDOW - 1)
1694
1732
  kept.push(""); // R7a: blank, not a bar
1695
- return [...kept, cutLine(`${p.dim}${CUT_ROW}live tail · esc stop · alt+⏎ redirect${p.reset}`, W)];
1733
+ return [...kept, cutLine(`${p.dim}${NOTE_ROW}live tail · esc stop · alt+⏎ redirect${p.reset}`, W)];
1696
1734
  }
1697
1735
  /**
1698
1736
  * R4 — the standing act slot.
@@ -1743,7 +1781,8 @@ export function slotTail(text, W, rows) {
1743
1781
  // R7a: no pad. The slot stopped padding (see slotPad) and this was
1744
1782
  // the same pad by another route — three blank rows under a call with
1745
1783
  // nothing to say yet, which is the hole a7's blank-run guard prices.
1746
- return body.slice(Math.max(0, body.length - rows));
1784
+ // R8a: the corner opens whatever slice survives the cap.
1785
+ return openBlock(body.slice(Math.max(0, body.length - rows)));
1747
1786
  }
1748
1787
  /** R4 — clamp or pad assembled slot rows to EXACTLY `rows`. The padding
1749
1788
  * is what makes the slot stand; the clamp is what keeps the slot from
@@ -2000,7 +2039,11 @@ export function cutLine(line, W) {
2000
2039
  width += cw;
2001
2040
  i += 1;
2002
2041
  }
2003
- 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}…`;
2004
2047
  }
2005
2048
  /** W20 — the settled block's duration, the `2h 14m` form (the task
2006
2049
  * narrative's long-horizon idiom): minutes+seconds under an hour,
@@ -2085,6 +2128,33 @@ class Checklist {
2085
2128
  * W21: the question param is gone — the old question slot retires; a
2086
2129
  * pending approval's status IS the panel's (the compositor derives
2087
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
+ }
2088
2158
  export function statusLine(status, tail, W, hint) {
2089
2159
  const p = palette();
2090
2160
  const text = `${status}${tail === "" ? "" : ` · ${tail}`}`;
@@ -2092,13 +2162,13 @@ export function statusLine(status, tail, W, hint) {
2092
2162
  // "esc to cancel" (the same one-line-bounded shape as W12's delegate
2093
2163
  // row; the #16g rule still cuts the HINT first, then the status with
2094
2164
  // a "…" — never a fold).
2095
- const hintText = hint ?? " / commands · ↑ history";
2096
2165
  const statusW = visibleWidth(text);
2097
2166
  if (statusW > W) {
2098
2167
  return `${p.dim}${widthCut(text, W - 1)}…${p.reset}`;
2099
2168
  }
2169
+ const hintText = hint ?? idleHint(Math.max(0, W - statusW));
2100
2170
  const hintW = visibleWidth(hintText);
2101
- if (statusW + hintW > W)
2171
+ if (hintW === 0 || statusW + hintW > W)
2102
2172
  return `${p.dim}${text}${p.reset}`;
2103
2173
  return `${p.dim}${text}${" ".repeat(Math.max(0, W - statusW - hintW))}${hintText}${p.reset}`;
2104
2174
  }
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.20.0",
3
+ "version": "0.20.2",
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",