@vincemakes/kiso-tui-cells 0.21.1 → 0.23.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.
@@ -224,8 +224,8 @@ declare const TOOL_SUMMARY_MAX = 60;
224
224
  * compositor stays a pure writer. */
225
225
  export declare function cellComponent(cell: BodyCell): Component;
226
226
  /** W22: the pending-queue chips — queued user lines pre-render above
227
- * the input row as the SAME UserMessage chip (undimmed: §2.1 forbids
228
- * dim ON the wash, and on rung 4 reverse video would invert it into a
227
+ * the input row as the SAME UserMessage chip (undimmed: reverse video
228
+ * inverts the CURRENT colours, so a dim span would invert into a
229
229
  * dimmed block), the dim `□` gutter marking the queued
230
230
  * state (the gutter rides EVERY row — the gutterFold precedent: the
231
231
  * left edge alone distinguishes the states). Each chip folds at W−3
@@ -22,7 +22,7 @@ import { displayWidth, visibleWidth } from "./width.js";
22
22
  // KEY_BINDINGS). strings.js imports only render/width here, so this edge
23
23
  // adds no cycle.
24
24
  import { displayVerb } from "./strings.js";
25
- import { bannerLines, breathFrame, escapeTerminal, foldThinking, foldResult, renderTerminalGap, renderToolSummary, toolTarget, kUnit, palette, } from "./render.js";
25
+ import { bannerLines, breathFrame, escapeTerminal, foldThinking, foldResult, renderTerminalGap, renderToolSummary, toolTarget, kUnit, palette, currentGround, } from "./render.js";
26
26
  // TUI2-MD: the markdown renderer's surface reaches the tui through this
27
27
  // module (the tui's components shim re-exports it) — one import edge,
28
28
  // and it points one way: md.ts measures with the width authority, never
@@ -186,19 +186,22 @@ export function cellComponent(cell) {
186
186
  * The padding is by cells (charWidth is the width authority), so a CJK
187
187
  * row pads by width, never by chars, and the chip never overruns.
188
188
  *
189
- * R9 P1 the surface is the WASH (§1.6, §7.9), closed with 49 rather
190
- * than SGR 0 so the chip composes with a surrounding span. It drew with
191
- * SGR 7 on every ground until now, which §7.9 never asked for: on a
192
- * light terminal that is a full-width black band, on a dark one a white
193
- * one, and every turn has exactly one. §11 settles which side was
194
- * stale the design said washed, the code predated DC-3's ground-
195
- * resolved wash and was never moved onto it. The ladder makes the move
196
- * safe: with no ground `wash` IS reverse video (§3 rung 4), so the
197
- * unknown-ground chip is byte-identical to what shipped.
198
- *
199
- * NEVER dim inside it, on either surface: §2.1 on a resolved wash, and
200
- * on rung 4 because reverse video inverts the CURRENT colours, so
201
- * dimmed text inverts into a dimmed block with no contrast.
189
+ * THE SURFACE IS REVERSE VIDEO, on every ground (owner ruling
190
+ * 2026-09-02, reversing R9 P1 one release after it shipped).
191
+ *
192
+ * R9 P1 moved the chip onto the wash, reading §1.6's "verbatim" as one
193
+ * surface shared by the human's words and the machine's. §1.6 now
194
+ * splits the two, and the split is the reason: reverse video is THE
195
+ * HUMAN'S surface and it is full contrast by construction it inverts
196
+ * whatever the terminal is, so it is the same weight on every ground
197
+ * and cannot be under-read. The wash is the MACHINE'S verbatim surface
198
+ * (inline code, tool output), where a lighter ground is right because
199
+ * those rows are read as content rather than as an utterance.
200
+ *
201
+ * SGR 7 closed with SGR 27 never SGR 0, the chip composes with a
202
+ * surrounding span. NEVER dim inside it: reverse video inverts the
203
+ * CURRENT colours, so dimmed text inverts into a dimmed block with no
204
+ * contrast.
202
205
  */
203
206
  /**
204
207
  * REL-0152-D13 — how much of a turn the chip shows.
@@ -261,7 +264,7 @@ class UserMessage {
261
264
  // row is two cells per character and pads by cells.
262
265
  const inner = chipW;
263
266
  for (const row of content) {
264
- rows.push(`${p.wash} ${row}${" ".repeat(Math.max(0, inner - displayWidth(row)))} ${p.washEnd}`);
267
+ rows.push(`${p.rv} ${row}${" ".repeat(Math.max(0, inner - displayWidth(row)))} ${p.rvEnd}`);
265
268
  }
266
269
  if (!truncated)
267
270
  return rows;
@@ -278,8 +281,8 @@ class UserMessage {
278
281
  }
279
282
  }
280
283
  /** W22: the pending-queue chips — queued user lines pre-render above
281
- * the input row as the SAME UserMessage chip (undimmed: §2.1 forbids
282
- * dim ON the wash, and on rung 4 reverse video would invert it into a
284
+ * the input row as the SAME UserMessage chip (undimmed: reverse video
285
+ * inverts the CURRENT colours, so a dim span would invert into a
283
286
  * dimmed block), the dim `□` gutter marking the queued
284
287
  * state (the gutter rides EVERY row — the gutterFold precedent: the
285
288
  * left edge alone distinguishes the states). Each chip folds at W−3
@@ -729,9 +732,57 @@ class ToolExecution {
729
732
  // metadata, in words, which is also the only form that survives
730
733
  // a pipe with the colour stripped. A failure keeps its colour
731
734
  // AND its words — see settledMeta.
735
+ const parts = toolBlockParts(c, W);
736
+ const body = parts.rows;
737
+ if (body.length > 0 && c.expanded) {
738
+ // An EXPANDED block is already showing everything, and its own
739
+ // footer ("ctrl+o collapses") is what closes it. Giving it an
740
+ // outcome row as well would put two closing rows on one block
741
+ // and move the metadata off a head row every width gate pins.
742
+ // It takes the SURFACE and nothing else.
743
+ const head = c.isError ? ` ${p.red}${text}${p.reset}` : ` ${text}`;
744
+ return slabBlock(appendSuffix(head, expandSuffix(hidden, W - visibleWidth(head))), body, null, W);
745
+ }
746
+ if (parts.output > 0) {
747
+ // R9 P2 — THE SLAB'S SHAPE. A call with rows on screen is one
748
+ // object: the head row names it, the output sits inside, and
749
+ // the outcome CLOSES it on its own line (§7.5's words, moved
750
+ // off the head row because the head row is no longer the only
751
+ // row). D6: the target is bold there — the head row's job is
752
+ // to say WHAT was run, and the metadata has its own row now.
753
+ //
754
+ // A failure takes NO tint on the head row (R9): only the
755
+ // outcome word is coloured, which is §1.2 exactly — the
756
+ // colour rides the fact, not the object that carries it.
757
+ const target = escapeTerminal(toolTargetOf(c));
758
+ const head = cutLine(` ${verbCol} ${p.bold}${target}${p.reset}`, W);
759
+ // §7.5's outcome, in words, on its own row: what happened, how
760
+ // much of it there was, how long it took. The count is stated
761
+ // exactly ONCE (VD-6) — a meta that already counts lines does
762
+ // not get a second count beside it.
763
+ const n = countLines(c.resultText);
764
+ const counted = n > 0 && !/^\d+( of \d+)? lines?$/.test(rawMeta) ? `${n} line${n === 1 ? "" : "s"}` : "";
765
+ // pin 4's order, on the row that carries the core now: the
766
+ // ATTRIBUTION drops first, then the count, and the core —
767
+ // what happened and how long it took — is never cut open.
768
+ const join = (...xs) => xs.filter((x) => x !== "").join(" · ");
769
+ const attr = approvedBy.replace(/^ · /, "");
770
+ const words = pickTier([join(meta, counted, `${elapsed}s`, attr), join(meta, counted, `${elapsed}s`), join(meta, `${elapsed}s`), meta], W - visibleWidth(NOTE_ROW));
771
+ const outcome = c.isError ? `${p.red}${words}${p.reset}` : words;
772
+ return slabBlock(head, body, outcome, W);
773
+ }
774
+ // No output on screen: the row is PLAIN, and the outcome stays on
775
+ // it in the form every width gate already pins.
776
+ //
777
+ // Owner ruling 2026-09-02, narrowing R9's "one-row slab": §1.6
778
+ // gives the wash to the machine's VERBATIM text, and a row like
779
+ // `read loop.ts · 412 lines · 0.1s` is kiso's summary of a result
780
+ // — not one line of it. A surface with nothing verbatim on it is
781
+ // a surface making a claim it cannot keep, so the wash appears
782
+ // only where the call's own output does.
732
783
  const out = c.isError ? [` ${p.red}${text}${p.reset}`] : [` ${text}`];
733
784
  out[0] = appendSuffix(out[0], expandSuffix(hidden, W - visibleWidth(out[0])));
734
- out.push(...toolBlockBody(c, W));
785
+ out.push(...body);
735
786
  return out;
736
787
  }
737
788
  if (c.state === "approval") {
@@ -797,12 +848,12 @@ function hiddenLines(c, W) {
797
848
  return null;
798
849
  if (c.isError)
799
850
  return null; // errorBody's own cut row is the affordance there
800
- // TUI2-R1.5 ④(c) (VD-5): a settled shell renders NO body, so its whole
801
- // output is behind the key exactly like every other settled call. The
802
- // retired branch only claimed a suffix once the output passed the
803
- // five-row cap, because below that the tail was on screen; there is no
804
- // tail now, and a card hiding four lines while saying nothing is the
805
- // silence TUI2-R1 (A) set out to remove.
851
+ // R9 P2 / D4: a settled shell has its tail back on screen, and when
852
+ // the tail is cut the slab's own note row says so and names the key.
853
+ // A head-row suffix as well would be TUI2-R1's two affordances for
854
+ // one cell the thing that rule exists to forbid.
855
+ if (c.name === "shell")
856
+ return null;
806
857
  return n; // every other settled call renders NO body — all of it is behind the key
807
858
  }
808
859
  /**
@@ -1557,6 +1608,93 @@ const CAP_ERROR = 3; // the error text head
1557
1608
  const BODY_ROW = " ";
1558
1609
  const NOTE_ROW = " ";
1559
1610
  const CUT_ROW = "└ ";
1611
+ /**
1612
+ * R9 P2 — THE SLAB: a single call's block is one washed object.
1613
+ *
1614
+ * §1.6 gives the wash to the machine's verbatim text, and a call's own
1615
+ * output is exactly that. The slab is the surface that says so: full-
1616
+ * width washed rows, the head row naming the call, the output inside,
1617
+ * the outcome closing it. `└` does not open a slab — the surface is the
1618
+ * container, and a corner inside it is §1.3's empty mark one scale up.
1619
+ *
1620
+ * THE DEGRADATION IS THE POINT OF THE PREDICATE. `wash` is a chosen
1621
+ * background on the two KNOWN grounds and reverse video on the third
1622
+ * (§3's last rung). A chip inverting for one row is the design working; eight
1623
+ * output rows inverting is a blackboard in the middle of the transcript.
1624
+ * So a slab paints only where the wash is a real background, and where
1625
+ * it is not the block degrades to what it has always been — the R8a
1626
+ * four-column indent with a dim tail. Never to reverse video.
1627
+ *
1628
+ * The content shape does NOT change with the surface: the note row and
1629
+ * the outcome row exist either way, in `washDim` on the slab and in the
1630
+ * ordinary dim off it. Only the surface and its two blank rows are
1631
+ * contingent, because an unpainted blank row is §1.3's empty mark at the
1632
+ * scale of a row.
1633
+ */
1634
+ function slabPaints() {
1635
+ const p = palette();
1636
+ return p.wash !== "" && currentGround() !== "unknown";
1637
+ }
1638
+ /** One washed row, padded to the full width by DISPLAY width. A reset
1639
+ * inside the content would strand the background for the rest of the
1640
+ * row, so every reset re-opens it — the selection bar's discipline,
1641
+ * applied to a surface that spans many rows instead of one. */
1642
+ function slabRow(inner, W) {
1643
+ const p = palette();
1644
+ const body = inner.replaceAll(p.reset, `${p.reset}${p.wash}`);
1645
+ const pad = Math.max(0, W - visibleWidth(inner));
1646
+ return `${p.wash}${body}${" ".repeat(pad)}${p.washEnd}`;
1647
+ }
1648
+ /** A metadata row inside (or under) a block: `washDim` on the slab,
1649
+ * the ordinary dim off it. §2.1 is why there are two. */
1650
+ /** The widest form that fits the row, or the last one — the head row's
1651
+ * own discipline (TUI2-R1.5 ⑤, pin 4) applied to the slab's two
1652
+ * metadata rows: the parts give way in a PINNED ORDER, and the part
1653
+ * that carries the semantics is the one reserved. */
1654
+ function pickTier(tiers, room) {
1655
+ for (const t of tiers)
1656
+ if (visibleWidth(t) <= room)
1657
+ return t;
1658
+ return tiers[tiers.length - 1];
1659
+ }
1660
+ function noteRow(text, W, tone) {
1661
+ const p = palette();
1662
+ // CUT, never folded — A6's rule for the tool header, and for the same
1663
+ // reason: a metadata row that wraps costs the block a row it did not
1664
+ // budget, and at 30 columns the fold put "expands" alone on a line of
1665
+ // its own. The row names a fact; a cut names it shorter.
1666
+ const open = tone === "body" ? p.washDim : p.dim;
1667
+ const close = tone === "body" ? p.washDimEnd : p.reset;
1668
+ return [cutLine(`${open}${NOTE_ROW}${text}${close}`, W)];
1669
+ }
1670
+ /**
1671
+ * Assemble a call's block.
1672
+ *
1673
+ * `head` is the row that names the call, `body` its rows (already
1674
+ * indented and toned), `outcome` the closing line in words (§7.5) or
1675
+ * null for a call whose head row already carries it — a read has no
1676
+ * body, so nothing needs closing and its outcome stays inline.
1677
+ */
1678
+ function slabBlock(head, body, outcome, W) {
1679
+ if (!slabPaints()) {
1680
+ const out = [head, ...body];
1681
+ if (outcome !== null)
1682
+ out.push(...noteRow(outcome, W, "dim"));
1683
+ return out;
1684
+ }
1685
+ const rows = [slabRow(head, W)];
1686
+ if (body.length > 0)
1687
+ rows.push(slabRow("", W));
1688
+ for (const r of body)
1689
+ rows.push(slabRow(r, W));
1690
+ if (outcome !== null) {
1691
+ if (body.length > 0)
1692
+ rows.push(slabRow("", W));
1693
+ for (const r of noteRow(outcome, W, "body"))
1694
+ rows.push(slabRow(r, W));
1695
+ }
1696
+ return rows;
1697
+ }
1560
1698
  /** R8a — stamp `└` on a block's FIRST row, after every slice and note
1561
1699
  * has been assembled, so the mark is always on the first row actually
1562
1700
  * emitted rather than on one a cap may have dropped. */
@@ -1578,12 +1716,15 @@ function openBlock(rows) {
1578
1716
  }
1579
1717
  const blockMemo = new WeakMap();
1580
1718
  /** The block's body rows below the header (memoized, W9). */
1581
- function toolBlockBody(c, W) {
1719
+ function toolBlockParts(c, W) {
1582
1720
  const memo = blockMemo.get(c);
1583
- const state = `${c.state}:${c.isError}:${c.name}:${c.expanded ? "x" : ""}`;
1721
+ // the SURFACE is part of the key: the same cell renders different rows
1722
+ // painted and unpainted, and a ground resolved after the first frame
1723
+ // would otherwise be served the pre-ground shape forever.
1724
+ const state = `${c.state}:${c.isError}:${c.name}:${c.expanded ? "x" : ""}:${slabPaints() ? "slab" : "flat"}`;
1584
1725
  const content = c.state === "approval" ? (c.diff ?? null) : c.resultText;
1585
1726
  if (memo !== undefined && memo.width === W && memo.state === state && memo.content === content)
1586
- return memo.rows;
1727
+ return memo;
1587
1728
  const p = palette();
1588
1729
  const rows = c.expanded
1589
1730
  ? // W15: the toggle's full form — the WHOLE body, no cap, no
@@ -1602,15 +1743,19 @@ function toolBlockBody(c, W) {
1602
1743
  ? errorBody(c, W)
1603
1744
  : c.name === "delegate"
1604
1745
  ? delegateSettled(c, W)
1605
- : // TUI2-R1.5 ④(c) (VD-5): a settled shell collapses like
1606
- // every other settled call. It used to keep its last
1607
- // rows plus a "+N earlier rows · ctrl+o" cut FOREVER —
1608
- // six rows per call, so three shells owned a screen. The
1609
- // approved R1 prototype's state 2 is one line; the head
1610
- // row's own suffix already names the count and the key,
1611
- // and ctrl+o shows the whole block, not a five-row window
1612
- // of it.
1613
- []
1746
+ : // R9 P2 / D4 — DECLARED REVERSAL of TUI2-R1.5 ④(c) (VD-5),
1747
+ // owner-ruled. VD-5 collapsed a settled shell to its head
1748
+ // row because six ungrounded rows per call let three
1749
+ // shells own a screen. The slab is what changes that
1750
+ // arithmetic: the rows are inside a surface that says
1751
+ // where the call begins and ends, so five of them read as
1752
+ // one object rather than as five loose lines. The cap and
1753
+ // the tail direction are VD-5's own (CAP_SHELL_SETTLED,
1754
+ // the conclusion at the end); only the emptiness is
1755
+ // reversed.
1756
+ c.name === "shell"
1757
+ ? shellTail(c.resultText, W, slabPaints() ? "body" : "dim")
1758
+ : []
1614
1759
  : c.state === "running"
1615
1760
  ? c.name === "delegate"
1616
1761
  ? delegateRunning(c, W)
@@ -1620,6 +1765,11 @@ function toolBlockBody(c, W) {
1620
1765
  : c.state === "approval"
1621
1766
  ? diffBody(c.diff, W)
1622
1767
  : [];
1768
+ // R9 P2: how many of these rows are the call's OWN OUTPUT. A cut note
1769
+ // is kiso's sentence about a result the TOOL truncated, not a line of
1770
+ // it — a read that has only that note has nothing verbatim on screen
1771
+ // and stays a ONE-ROW slab, outcome inline, exactly as R9 draws it.
1772
+ const output = rows.length;
1623
1773
  const note = c.expanded ? null : toolCutNote(c.name, c.resultText);
1624
1774
  if (note !== null)
1625
1775
  rows.push(...foldLine(`${p.dim}${NOTE_ROW}${note}${p.reset}`, W));
@@ -1629,20 +1779,36 @@ function toolBlockBody(c, W) {
1629
1779
  // row would be an affordance for an empty block.
1630
1780
  if (c.expanded && rows.length > 0)
1631
1781
  rows.push(...foldLine(`${p.dim}${NOTE_ROW}${COLLAPSE_ROW}${p.reset}`, W));
1632
- const opened = openBlock(rows);
1633
- blockMemo.set(c, { width: W, state, content, rows: opened });
1634
- return opened;
1782
+ // R9 P2: `└` opens a block that has no surface. Inside a slab the
1783
+ // surface IS the container, and a corner in it is §1.3's empty mark
1784
+ // one scale up — so the corner and the slab are alternatives, never
1785
+ // both.
1786
+ const opened = slabPaints() ? rows : openBlock(rows);
1787
+ const parts = { width: W, state, content, rows: opened, output };
1788
+ blockMemo.set(c, parts);
1789
+ return parts;
1790
+ }
1791
+ /** The rows alone — every caller but the settled branch, which needs to
1792
+ * know whether any of them are the call's own OUTPUT. */
1793
+ function toolBlockBody(c, W) {
1794
+ return toolBlockParts(c, W).rows;
1635
1795
  }
1636
1796
  /** Fold result text into dim body rows (the BODY_ROW prefix): escape,
1637
1797
  * split, fold each line at W−prefix; trailing empty rows (the result's
1638
1798
  * final newline) drop. */
1639
- function blockRows(text, W) {
1799
+ function blockRows(text, W, tone = "dim") {
1640
1800
  const p = palette();
1641
1801
  const textW = Math.max(1, W - visibleWidth(BODY_ROW));
1642
1802
  const rows = [];
1803
+ // R9 P2: inside a SLAB the output rows are body strength, never dim —
1804
+ // §2.1 bars dim from the wash (3.91:1 light, 4.35:1 dark) and these
1805
+ // rows are the verbatim content the surface exists for. The metadata
1806
+ // rows around them take `washDim`, which was chosen for that ground.
1807
+ const open = tone === "dim" ? p.dim : "";
1808
+ const close = tone === "dim" ? p.reset : "";
1643
1809
  for (const raw of escapeTerminal(text).split("\n")) {
1644
1810
  for (const row of foldLine(raw, textW))
1645
- rows.push(`${p.dim}${BODY_ROW}${row}${p.reset}`);
1811
+ rows.push(`${open}${BODY_ROW}${row}${close}`);
1646
1812
  }
1647
1813
  while (rows.length > 0 && visibleWidth(rows[rows.length - 1]) === visibleWidth(BODY_ROW))
1648
1814
  rows.pop();
@@ -1652,14 +1818,25 @@ function blockRows(text, W) {
1652
1818
  * renderer cut at the block's bottom ("earlier rows" — the conclusion
1653
1819
  * is at the end, the reference implementation's truncateToVisualLines
1654
1820
  * direction). */
1655
- function shellTail(text, W) {
1656
- const p = palette();
1657
- const rows = blockRows(text, W);
1821
+ function shellTail(text, W, tone = "dim") {
1822
+ const rows = blockRows(text, W, tone);
1658
1823
  if (rows.length <= CAP_SHELL_SETTLED)
1659
1824
  return rows;
1660
- const kept = CAP_SHELL_SETTLED - 1;
1661
- const cut = foldLine(`${p.dim}${NOTE_ROW}+${rows.length - kept} earlier rows · ctrl+o${p.reset}`, W);
1662
- return [...rows.slice(rows.length - kept), ...cut];
1825
+ // R9 P2 / D4: FIVE output rows, and the note is a row of its own. The
1826
+ // pre-slab arithmetic spent one of the five on the cut note, because
1827
+ // the note had nowhere else to live; the slab's metadata rows are not
1828
+ // output and are not counted against the output's cap.
1829
+ const kept = CAP_SHELL_SETTLED;
1830
+ // The note goes ABOVE the tail: it says what was cut, and what was
1831
+ // cut is what came BEFORE these rows. One position on both surfaces —
1832
+ // the surface degrades, the content shape does not.
1833
+ const cut = rows.length - kept;
1834
+ const n = `${cut} earlier line${cut === 1 ? "" : "s"}`;
1835
+ // the KEY is reserved (TUI2-R1.5 ⑤): the count gives way before it,
1836
+ // because a row that says how much is hidden without saying how to
1837
+ // see it is the silence TUI2-R1 (A) set out to remove.
1838
+ const note = noteRow(pickTier([`… ${n} · ctrl+o expands`, `… ${n} · ctrl+o`, `… ${cut} · ctrl+o`, "· ctrl+o"], W - visibleWidth(NOTE_ROW)), W, tone);
1839
+ return [...note, ...rows.slice(rows.length - kept)];
1663
1840
  }
1664
1841
  /** The error text head: the FIRST rows, capped at 3 — the answer is at
1665
1842
  * the start (opencode's collapseToolOutput direction). The header row
package/dist/ground.d.ts CHANGED
@@ -31,8 +31,12 @@ export declare function parseOscColor(body: string): Rgb | null;
31
31
  export declare function relativeLuminance({ r, g, b }: Rgb): number;
32
32
  export declare function groundFrom(rgb: Rgb): Exclude<Ground, "unknown">;
33
33
  export interface GroundInputs {
34
- /** KISO_THEME — an explicit answer from the human. */
34
+ /** KISO_THEME, or the user config's `theme` — an explicit answer from
35
+ * the human. The environment wins over the file; both are rung 1. */
35
36
  readonly theme?: string | undefined;
37
+ /** The terminal's OWN account of its colour scheme, from its answer to
38
+ * `CSI ? 996 n` (`CSI ? 997 ; 1|2 n`). */
39
+ readonly colorScheme?: "dark" | "light" | undefined;
36
40
  /** The body of the terminal's OSC 11 answer, if one arrived. */
37
41
  readonly osc?: string | undefined;
38
42
  /** The COLORFGBG environment variable, if it is set. */
@@ -42,5 +46,15 @@ export interface GroundInputs {
42
46
  * The ladder, first hit wins. Every rung that cannot answer falls
43
47
  * through rather than guessing, and the bottom of the ladder is
44
48
  * `unknown` — see the module comment for why that is a result.
49
+ *
50
+ * 1 theme an explicit answer from the human (env, then config)
51
+ * 2 colorScheme the terminal's own report (CSI 997)
52
+ * 3 osc the background colour, and a luminance threshold
53
+ * 4 colorfgbg an environment variable some terminals set
54
+ * unknown reverse video, correct on any ground
55
+ *
56
+ * kiso never guesses. A terminal that answers nothing and a human who
57
+ * set nothing leave the ground `unknown`, and `unknown` degrades — it
58
+ * does not default to dark and hope.
45
59
  */
46
- export declare function resolveGround({ theme, osc, colorfgbg }: GroundInputs): Ground;
60
+ export declare function resolveGround({ theme, colorScheme, osc, colorfgbg }: GroundInputs): Ground;
package/dist/ground.js CHANGED
@@ -60,11 +60,28 @@ function fromColorFgBg(value) {
60
60
  * The ladder, first hit wins. Every rung that cannot answer falls
61
61
  * through rather than guessing, and the bottom of the ladder is
62
62
  * `unknown` — see the module comment for why that is a result.
63
+ *
64
+ * 1 theme an explicit answer from the human (env, then config)
65
+ * 2 colorScheme the terminal's own report (CSI 997)
66
+ * 3 osc the background colour, and a luminance threshold
67
+ * 4 colorfgbg an environment variable some terminals set
68
+ * unknown reverse video, correct on any ground
69
+ *
70
+ * kiso never guesses. A terminal that answers nothing and a human who
71
+ * set nothing leave the ground `unknown`, and `unknown` degrades — it
72
+ * does not default to dark and hope.
63
73
  */
64
- export function resolveGround({ theme, osc, colorfgbg }) {
74
+ export function resolveGround({ theme, colorScheme, osc, colorfgbg }) {
65
75
  const explicit = theme?.trim().toLowerCase();
66
76
  if (explicit === "light" || explicit === "dark")
67
77
  return explicit;
78
+ // The terminal's own REPORT outranks the colour it hands over. OSC 11
79
+ // gives a background colour and kiso infers a ground from its
80
+ // luminance — a threshold applied to someone else's number. `CSI 997`
81
+ // is the terminal saying which it is. When both answer they normally
82
+ // agree; when they do not, the account beats the inference.
83
+ if (colorScheme === "dark" || colorScheme === "light")
84
+ return colorScheme;
68
85
  if (osc !== undefined && osc !== "") {
69
86
  const rgb = parseOscColor(osc);
70
87
  if (rgb !== null)
package/dist/render.d.ts CHANGED
@@ -29,6 +29,17 @@ import type { Ground } from "./ground.js";
29
29
  * The functional colors are deliberately NOT moved and not
30
30
  * approximated: red stays SGR 31, green stays SGR 32. A reader who has
31
31
  * learned that colour means something must keep being right.
32
+ *
33
+ * R2's retired wordmark, re-measured 2026-09-02 and recorded so the
34
+ * question is not reopened from memory: braille (U+2800–U+28FF) IS
35
+ * available — Apple Terminal's default Menlo falls back to Apple
36
+ * Braille and draws solid dots, correcting what design.md §6 used to
37
+ * say. Rasterised through it, a four-leaf mark reads from 12×6 cells
38
+ * upward and turns to dominoes below 10×5 — the same threshold R2
39
+ * measured for block characters — and a dense tiling bands
40
+ * horizontally, because the font's dot pitch does not divide the cell
41
+ * height. The owner looked at it on the real terminal and declined it.
42
+ * §7.10 stands: no logo, the name is the mark.
32
43
  */
33
44
  export interface Palette {
34
45
  readonly bold: string;
@@ -71,7 +82,7 @@ export interface Palette {
71
82
  readonly rvEnd: string;
72
83
  /** DC-3 — the VERBATIM surface: the human's own words, and inline
73
84
  * code. A background, so it needs the ground; with no ground it is
74
- * reverse video, which is correct on any ground and is rung 4 of the
85
+ * reverse video, which is correct on any ground and is the LAST rung of the
75
86
  * ladder in `ground.ts`. Closed with 49 rather than SGR 0, for the
76
87
  * reason `rv` is closed with 27: a washed span sits inside other
77
88
  * spans and must end without stranding them. */
@@ -111,7 +122,7 @@ export interface Palette {
111
122
  * ruling's own set gaining its missing member.
112
123
  *
113
124
  * With NO ground it is NOTHING: §3.1 forbids an absolute foreground
114
- * in a palette that has not established a background, and rung 4's
125
+ * in a palette that has not established a background, and the last rung's
115
126
  * wash is reverse video, where any foreground grey inverts into a
116
127
  * grey block. Body text on the surface is the correct degradation.
117
128
  * It closes with 39 (the default foreground) rather than SGR 0, for
package/dist/render.js CHANGED
@@ -24,7 +24,7 @@ const BASE = { bold: "\x1b[1m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32
24
24
  * dark 173 `#d7875f` 5.97:1
25
25
  *
26
26
  * With NO ground established the token stays ANSI 31 — the TERMINAL's
27
- * own red, which its theme picked for its own background. That is rung
27
+ * own red, which its theme picked for its own background. That is the last rung
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
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui-cells",
3
- "version": "0.21.1",
3
+ "version": "0.23.0",
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",