@vincemakes/kiso-tui-cells 0.16.2 → 0.16.4

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.
@@ -171,12 +171,17 @@ export function cellComponent(cell) {
171
171
  * the ▍ rail and the indent are retired — the rail's stated pipe
172
172
  * fallback was theoretical redundancy: the CLI's pipe path is the
173
173
  * line-mode "you>" form and never renders UserMessage). The chip folds
174
- * the text at W−2 (the side pads), then pads EVERY row to the longest
175
- * row's DISPLAY width + one space each side, flush left: the block is
176
- * only as wide as what was said (never the full-width band a short
177
- * message like /think would paint a bar across the terminal). The
178
- * padding is by cells (charWidth is the width authority), so a CJK row
179
- * pads by width, never by chars, and the chip never overruns its fold.
174
+ * the text at W−2 (the side pads) and pads every row to the FULL width.
175
+ *
176
+ * R2 law 1.6's recorded reversal. This used to size the block to its
177
+ * longest row, on the argument that "a short message like /think would
178
+ * paint a bar across the terminal". That optimises the degenerate case
179
+ * at the cost of every real message, which is a paragraph and reads as
180
+ * a block only when the block has an edge. The `/think` case is the
181
+ * accepted price, and it is recorded as such in design.md §1.6.
182
+ *
183
+ * The padding is by cells (charWidth is the width authority), so a CJK
184
+ * row pads by width, never by chars, and the chip never overruns.
180
185
  * SGR 7 closed with SGR 27 — never SGR 0, the chip composes with a
181
186
  * surrounding span — and NEVER dim: reverse video inverts the CURRENT
182
187
  * colours, so dimmed text would invert into a dimmed block with no
@@ -206,22 +211,39 @@ class UserMessage {
206
211
  // was a 3000-line turn writing 260,298 bytes in one frame.
207
212
  const paras = this.cell.text.split("\n");
208
213
  let truncated = false;
214
+ // DC-6: the folded CONTENT first, then ONE width over all of it.
215
+ // The pad used to be computed per source paragraph, so a message
216
+ // with two lines drew as two bars of two different lengths — a
217
+ // ragged right edge on a block that is one block. A single
218
+ // paragraph was always correct, which is why it survived: the
219
+ // shape only appears once a message has a second line.
220
+ const content = [];
209
221
  for (const para of paras) {
210
- if (rows.length >= USER_CHIP_ROWS) {
222
+ if (content.length >= USER_CHIP_ROWS) {
211
223
  truncated = true;
212
224
  break;
213
225
  }
214
- const folded = foldLine(escapeTerminal(para), chipW);
215
- const inner = Math.max(...folded.map((r) => displayWidth(r)));
216
- for (const row of folded) {
217
- if (rows.length >= USER_CHIP_ROWS) {
226
+ for (const row of foldLine(escapeTerminal(para), chipW)) {
227
+ if (content.length >= USER_CHIP_ROWS) {
218
228
  truncated = true;
219
229
  break;
220
230
  }
221
- const pad = inner - displayWidth(row);
222
- rows.push(`${p.rv} ${row}${" ".repeat(pad)} ${p.rvEnd}`);
231
+ content.push(row);
223
232
  }
224
233
  }
234
+ // R2 (law 1.6's recorded reversal): the band is FULL WIDTH. It was
235
+ // sized to its longest row, on the argument that a one-word turn
236
+ // like `/think` would otherwise paint a bar across the terminal —
237
+ // which optimises the degenerate case at the cost of every real
238
+ // message. The human's words are the one surface that gets the
239
+ // whole row.
240
+ //
241
+ // displayWidth stays the padding authority (never `length`): a CJK
242
+ // row is two cells per character and pads by cells.
243
+ const inner = chipW;
244
+ for (const row of content) {
245
+ rows.push(`${p.rv} ${row}${" ".repeat(Math.max(0, inner - displayWidth(row)))} ${p.rvEnd}`);
246
+ }
225
247
  if (!truncated)
226
248
  return rows;
227
249
  // The notice is OUTSIDE the chip's reverse video, in the cut-row
@@ -265,19 +287,57 @@ class ThinkingFold {
265
287
  this.cell = cell;
266
288
  }
267
289
  render(W, _ctx) {
290
+ const p = palette();
268
291
  const block = this.cell.text;
269
292
  const trimmed = escapeTerminal(block.trim());
270
- // the SHORT branch is width-aware TOO: the ≤100 short-circuit was
271
- // W-blind — a short block at a narrow width returned the line
272
- // UNFOLDED and tripped invariant (the crash class still live on
273
- // npm for short /think blocks after a resize).
274
- if (trimmed.length <= 100)
275
- return [`${palette().dim}⋯${widthCut(trimmed, Math.max(1, W - 1))}${palette().reset}`];
276
- const suffix = ` (${block.length} chars · /think)`;
277
- const slice = Math.max(1, W - 1 - suffix.length);
278
- return [`${palette().dim}⋯${widthCut(trimmed, slice)}${suffix}${palette().reset}`];
293
+ // R2 (owner, 2026-08-27) three changes, each independent.
294
+ //
295
+ // ITALIC marks the row as not-the-answer without spending a colour,
296
+ // on the same argument that admitted italic to the alphabet.
297
+ //
298
+ // The cut lands on a WORD. It used to cut on a byte, so the fold
299
+ // read as `…the user's h` and the reader's eye had to reassemble a
300
+ // word it already knew.
301
+ //
302
+ // The affordance moves to the RIGHT EDGE, so the left edge of every
303
+ // row on screen is content. The char count goes with the move: it
304
+ // told the reader nothing they could act on, and the row it was
305
+ // crowding is the one thing this cell says.
306
+ //
307
+ // The ≤100 short-circuit stays width-aware: a short block at a
308
+ // narrow width returned the line UNFOLDED and tripped invariant ①.
309
+ // DC-15: the affordance is DROPPED, not squeezed. `room` floored at
310
+ // 1 while `pad` floored at 1 independently, so a narrow terminal
311
+ // produced 2 + cut + pad + 6 = 11 cells no matter what W was — and
312
+ // invariant ① does not truncate, it THROWS. Measured 11 cells at
313
+ // every W ≤ 10. Below the width where the tail and one word of
314
+ // content can both stand, the row is the CONTENT: a cell that
315
+ // cannot hold the key's name has nothing to say about the key.
316
+ const tail = "/think";
317
+ const room = W - 2 - tail.length - 1;
318
+ // the belt: cutLine is SGR-aware and single-row, so the invariant
319
+ // holds by CONSTRUCTION at every width rather than by arithmetic
320
+ // that has to be re-proved every time a span moves.
321
+ if (room < 2)
322
+ return [cutLine(`${p.dim}⋯ ${p.italic}${wordCut(trimmed, Math.max(1, W - 2))}${p.italicEnd}${p.reset}`, W)];
323
+ const cut = wordCut(trimmed, room);
324
+ const body = `⋯ ${p.italic}${cut}${p.italicEnd}`;
325
+ const pad = Math.max(1, W - 2 - visibleWidth(cut) - tail.length);
326
+ return [cutLine(`${p.dim}${body}${" ".repeat(pad)}${tail}${p.reset}`, W)];
279
327
  }
280
328
  }
329
+ /** R2 — cut at a word boundary, with the honest ellipsis. widthCut cuts
330
+ * at a cell, which is right for verbatim output and wrong for prose:
331
+ * the reader has to reassemble "h" into "home". Falls back to the cell
332
+ * cut when the first word alone overruns, because a row that cannot
333
+ * hold one word has no boundary to find. */
334
+ function wordCut(text, room) {
335
+ if (visibleWidth(text) <= room)
336
+ return text;
337
+ const hard = widthCut(text, Math.max(1, room - 1));
338
+ const at = hard.lastIndexOf(" ");
339
+ return `${at > room / 3 ? hard.slice(0, at) : hard}\u2026`;
340
+ }
281
341
  /** Fold a line's CONTENT at W−2 and prefix EVERY row with the gutter
282
342
  * (W2: a wrapped tool row keeps its state mark — the left edge alone
283
343
  * distinguishes the states at --plain; the UserMessage rail precedent,
@@ -498,7 +558,7 @@ class ToolExecution {
498
558
  // list of calls, not a body of output.
499
559
  const r = c.rolled;
500
560
  const counts = exploreCounts(parts);
501
- const head = `${p.bold}✓${p.reset} explored ${p.bold}${counts}${p.reset}`;
561
+ const head = ` explored ${p.bold}${counts}${p.reset}`; // R2: no tick
502
562
  const tail = ` (${r.elapsed}s)`;
503
563
  const room = W - visibleWidth(head) - tail.length;
504
564
  const affordance = " · ctrl+r lists them";
@@ -513,7 +573,7 @@ class ToolExecution {
513
573
  // W15 expand history — the head's commit captures it).
514
574
  const r = c.rolled;
515
575
  const noun = ROLLUP_NOUN[c.name] ?? "calls";
516
- const out = gutterCut(`${p.bold}✓${p.reset} `, `${verbCol} ${r.count} ${noun} (${kUnit(r.lines)} lines, ${r.elapsed}s)`, W);
576
+ const out = gutterCut(" ", `${verbCol} ${r.count} ${noun} (${kUnit(r.lines)} lines, ${r.elapsed}s)`, W); // R2: no tick
517
577
  const shown = r.targets.slice(0, 3);
518
578
  if (shown.length > 0)
519
579
  out.push(` ${p.dim}${CUT_ROW}${escapeTerminal(shown.join(" · "))}${p.reset}`);
@@ -531,7 +591,7 @@ class ToolExecution {
531
591
  // names the decider; a human denial (no decidedBy) has no tail.
532
592
  if (c.reason !== null) {
533
593
  const by = attribution(c);
534
- const out = gutterCut(`${p.red}✗${p.reset} `, `${p.red}${escapeTerminal(`${c.name} ${toolTargetOf(c)}`)} (${escapeTerminal(c.reason)}${by})${p.reset}`, W);
594
+ const out = gutterCut(" ", `${p.red}${escapeTerminal(`${c.name} ${toolTargetOf(c)}`)} (${escapeTerminal(c.reason)}${by})${p.reset}`, W);
535
595
  out.push(...toolBlockBody(c, W));
536
596
  return out;
537
597
  }
@@ -568,7 +628,14 @@ class ToolExecution {
568
628
  // the semantics. TUI2-R1.5 pin 4: and the parts give way in a
569
629
  // PINNED ORDER, rather than whichever happened to be last.
570
630
  const text = settledHeadText(verbCol, escapeTerminal(toolTargetOf(c)), meta, approvedBy, elapsed, W - 2 - (hidden === null ? 0 : SUFFIX_MIN));
571
- const out = c.isError ? [`${p.red}✗${p.reset} ${p.red}${text}${p.reset}`] : [`${p.bold}✓${p.reset} ${text}`];
631
+ // R2 (owner, 2026-08-27): no tick, no cross. A symbol earns its
632
+ // cell by carrying a fact the words do not, and a row that
633
+ // already says `exit 0` does not need one more thing saying it
634
+ // went fine. The gutter is two spaces; the OUTCOME lives in the
635
+ // metadata, in words, which is also the only form that survives
636
+ // a pipe with the colour stripped. A failure keeps its colour
637
+ // AND its words — see settledMeta.
638
+ const out = c.isError ? [` ${p.red}${text}${p.reset}`] : [` ${text}`];
572
639
  out[0] = appendSuffix(out[0], expandSuffix(hidden, W - visibleWidth(out[0])));
573
640
  out.push(...toolBlockBody(c, W));
574
641
  return out;
@@ -758,7 +825,7 @@ function appendSuffix(row, suffix) {
758
825
  * would put the invariant "exactly one bright token" in as many hands as
759
826
  * there are emitters. Here it has exactly one.
760
827
  *
761
- * NO_COLOR: p.code is empty, so the row's bytes are untouched.
828
+ * NO_COLOR: p.dim is empty, so the row's bytes are untouched.
762
829
  */
763
830
  export function focusToken(row, W) {
764
831
  const p = palette();
@@ -766,9 +833,17 @@ export function focusToken(row, W) {
766
833
  if (at !== -1) {
767
834
  // the row already names the key — brighten the token in place, and
768
835
  // leave every other span exactly as it was
769
- if (p.code === "")
836
+ if (p.dim === "")
770
837
  return row;
771
- return `${row.slice(0, at)}${p.code}${CTRL_R}${p.reset}${p.dim}${row.slice(at + CTRL_R.length)}`;
838
+ // DC-3: the marker takes the WASH. It used to take the inline-code
839
+ // tint and inherited its 1.54:1 — the cue naming the key that
840
+ // reveals a cell was itself the least readable thing on a white
841
+ // terminal. The wash is right for a second reason: this token has
842
+ // to be UNIQUE on the frame ("exactly one bright token"), and an
843
+ // attribute like bold is spent everywhere. It closes with washEnd
844
+ // rather than a reset, so the surrounding dim survives instead of
845
+ // having to be re-applied.
846
+ return `${row.slice(0, at)}${p.wash}${CTRL_R}${p.washEnd}${row.slice(at + CTRL_R.length)}`;
772
847
  }
773
848
  // A LIVE row does not carry the affordance today, and the live cell is
774
849
  // the one ctrl+r takes FIRST (expandNext scans the live tail before
@@ -780,7 +855,7 @@ export function focusToken(row, W) {
780
855
  const room = W - visibleWidth(row);
781
856
  if (room < SUFFIX_MIN)
782
857
  return row; // never at the cost of invariant ①
783
- return `${row}${p.dim} · ${p.reset}${p.code}${CTRL_R}${p.reset}`;
858
+ return `${row}${p.dim} · ${p.reset}${p.wash}${CTRL_R}${p.washEnd}`;
784
859
  }
785
860
  const CTRL_R = "ctrl+r";
786
861
  /** TUI2-R1 (A) — the expanded block's last row: the way back. The
@@ -894,26 +969,26 @@ export function turnFold(t, W) {
894
969
  const meta = parts.join(" · ");
895
970
  const words = escapeTerminal(t.words);
896
971
  if (words === "") {
897
- const row = `${p.bold}▞${p.reset} ${meta}`;
898
- return visibleWidth(row) <= W ? [row] : [`${p.bold}▞${p.reset} ${widthCut(meta, Math.max(1, W - 3))}…`]; // a wordless turn folds to the W14 shape
972
+ const row = `${p.bold}✦${p.reset} ${meta}`;
973
+ return visibleWidth(row) <= W ? [row] : [`${p.bold}✦${p.reset} ${widthCut(meta, Math.max(1, W - 3))}…`]; // a wordless turn folds to the W14 shape
899
974
  }
900
975
  // A9 (ruling R2, mock A): the user chip rides the fold — the human's
901
976
  // words LEAD the one line, the same SGR-7 bracket as the live user
902
977
  // row (#16f, side pads included). The words take the fold's width
903
- // budget: W − the gutter (" " = 2) − the join (" · " = 3) − the
978
+ // budget: W − the gutter (" " = 2) − the join (" · " = 3) − the
904
979
  // chip's side pads (2) − the cut-tail reserve (1, the "…") − the
905
980
  // metadata's own width — the metadata survives, the words width-cut
906
981
  // at the end with the honest "…" (the "…" alone is the honest floor:
907
982
  // the words were there, cut).
908
- const budget = Math.max(0, W - visibleWidth(`▞ ${meta}`) - 6);
983
+ const budget = Math.max(0, W - visibleWidth(`✦ ${meta}`) - 6);
909
984
  const cut = visibleWidth(words) > budget ? `${widthCut(words, budget)}…` : words;
910
- const row = `${p.bold}▞${p.reset} ${p.rv} ${cut} ${p.rvEnd} · ${meta}`;
985
+ const row = `${p.bold}✦${p.reset} ${p.rv} ${cut} ${p.rvEnd} · ${meta}`;
911
986
  if (visibleWidth(row) <= W)
912
987
  return [row];
913
988
  // the last resort: the METADATA gives way — the words hold their
914
989
  // budget, the meta cuts with the honest "…"; invariant ① never trips
915
990
  // at ANY width (a degenerate W's fold is a cut, never a crash).
916
- return [`${p.bold}▞${p.reset} ${p.rv} ${cut} ${p.rvEnd} · ${widthCut(meta, Math.max(1, W - 8 - visibleWidth(cut)))}…`];
991
+ return [`${p.bold}✦${p.reset} ${p.rv} ${cut} ${p.rvEnd} · ${widthCut(meta, Math.max(1, W - 8 - visibleWidth(cut)))}…`];
917
992
  }
918
993
  // ---- the bounded-block flow contract (W7, W8, W10) ----
919
994
  /** The caps — screen rows counted AFTER the fold, at the current width
@@ -1270,8 +1345,11 @@ class Banner {
1270
1345
  }
1271
1346
  render(W, ctx) {
1272
1347
  const p = palette();
1273
- const rows = bannerLines(W, ctx.height, this.cell.version, this.cell.extensionsText, this.cell.resume, ctx.now);
1274
- return rows.map((r) => `${p.dim}${r}${p.reset}`);
1348
+ // R2: NO blanket dim. bannerLines styles itself — the labels are
1349
+ // dim, the values are ink — and wrapping the whole thing in dim
1350
+ // made the answers as faint as the questions.
1351
+ void p;
1352
+ return bannerLines(W, ctx.height, this.cell.version, this.cell.extensionsText, this.cell.resume, ctx.now, this.cell.meta);
1275
1353
  }
1276
1354
  }
1277
1355
  /** W20 — the task block's fixed-window height: the whole live block
@@ -1355,7 +1433,7 @@ class Checklist {
1355
1433
  const fixed = done
1356
1434
  ? `task done · ${plural(items.length, "item")} · ${formatDuration(durationSeconds)}`
1357
1435
  : `task · ${plural(items.length, "item")} · ${active.length} active · ${doneCount} done`;
1358
- const header = `${p.bold}▞${p.reset} ${escapeTerminal(fixed + tail)}`;
1436
+ const header = `${p.bold}✦${p.reset} ${escapeTerminal(fixed + tail)}`;
1359
1437
  // the FULL-list forms: SETTLED — the durable record (the fold is
1360
1438
  // fine — committed content wraps naturally) — and the LIVE ctrl+r
1361
1439
  // toggle (the header CUTS — the block stays one window high; the
@@ -1457,20 +1535,38 @@ export function widthCut(text, max) {
1457
1535
  */
1458
1536
  export function selectionBar(styled, visible, W) {
1459
1537
  const p = palette();
1460
- const inner = styled.replaceAll(p.reset, `${p.reset}${p.rv}`);
1538
+ // R2 (design §2.1 — nothing dim ever sits on the wash): the bar IS a
1539
+ // wash. A dim span inside it renders grey-on-grey — 3.91:1 on the
1540
+ // light ground, under the 4.5 floor — and the dim spans are exactly
1541
+ // the descriptions and the metadata, i.e. the half of the row the
1542
+ // selection was supposed to help you read. Dim is dropped INSIDE the
1543
+ // bar and nowhere else; the same row unselected keeps it.
1544
+ const inner = (p.dim === "" ? styled : styled.replaceAll(p.dim, "")).replaceAll(p.reset, `${p.reset}${p.rv}`);
1461
1545
  return `${p.rv} ${inner}${" ".repeat(Math.max(0, W - visible - 2))} ${p.rvEnd}`;
1462
1546
  }
1463
- /** W6 — the box: the chrome's top rail. The two ╌ dotted rows become
1464
- * a rounded box (the box already says "input lives here"); the rails
1465
- * stay dim, the width is still the full W (the box is a rail with
1466
- * corners the menu/gap rows above and the status below are
1467
- * untouched). */
1547
+ /**
1548
+ * R2 the composer's rails, and the ONE edge vocabulary.
1549
+ *
1550
+ * W6 turned two \u254c dotted rows into a rounded box, reasoning that
1551
+ * "the box already says input lives here". That is reversed here, and
1552
+ * the reason is not taste: a rule is a DELIMITER and a box is a
1553
+ * CONTAINER, and the screen was carrying six edge vocabularies at once
1554
+ * (this box, the panel's \u2502 gutter and \u2514\u2500\u2500 tail, the
1555
+ * diff gutter, the quote's \u258f, the table's rails, the markdown
1556
+ * rule). One dashed rule replaces the ones that SEPARATE; the \u2502
1557
+ * gutter survives where it SCOPES.
1558
+ *
1559
+ * Row-neutral by construction: CHROME_ROWS is still 4, so every gate
1560
+ * keyed on H \u2212 4 is untouched, and the input row gains the two
1561
+ * columns the walls were taking.
1562
+ */
1468
1563
  export function boxTop(W) {
1469
- return `\x1b[2m╭${"".repeat(Math.max(0, W - 2))}╮\x1b[0m`;
1564
+ return `\x1b[2m${"\u254c".repeat(Math.max(0, W))}\x1b[0m`;
1470
1565
  }
1471
- /** W6 — the box: the chrome's bottom rail. */
1566
+ /** R2 — the same rule below. Named for its POSITION, not its shape, so
1567
+ * the compositor's two call sites did not have to move. */
1472
1568
  export function boxBottom(W) {
1473
- return `\x1b[2m╰${"".repeat(Math.max(0, W - 2))}╯\x1b[0m`;
1569
+ return `\x1b[2m${"\u254c".repeat(Math.max(0, W))}\x1b[0m`;
1474
1570
  }
1475
1571
  /** The terminal label + rhythm gap (the pipe path's v2c bytes — the
1476
1572
  * exact render the passthrough needs). */
@@ -0,0 +1,46 @@
1
+ /**
2
+ * DC-3 §1 — the GROUND: is the terminal light or dark.
3
+ *
4
+ * Every colour in the palette is chosen against a background, and until
5
+ * now kiso had no way to know what that background was: `palette()`
6
+ * returned one constant, its greys were picked on a dark terminal, and
7
+ * on a white one the inline-code token measured 1.54:1 against a 4.5:1
8
+ * floor (finding DC-3).
9
+ *
10
+ * This module is the answer, and it is deliberately PURE. The terminal's
11
+ * reply arrives as a string and the environment arrives as fields, so
12
+ * the whole decision is testable without a terminal and the terminal
13
+ * work is reduced to plumbing.
14
+ *
15
+ * `unknown` is a real answer, not a failure. It means the caller must
16
+ * use the mark that is correct on ANY ground — reverse video rather than
17
+ * a wash, the terminal's own dim rather than a chosen grey. A design
18
+ * that degrades is the price of never painting light-mode paint onto a
19
+ * dark screen.
20
+ */
21
+ export type Ground = "light" | "dark" | "unknown";
22
+ export interface Rgb {
23
+ readonly r: number;
24
+ readonly g: number;
25
+ readonly b: number;
26
+ }
27
+ export declare function parseOscColor(body: string): Rgb | null;
28
+ /** The WCAG relative luminance — the same formula the contrast ratios in
29
+ * `packages/tui/design.md` §2 are computed with, so the ground and the
30
+ * floor cannot drift apart. */
31
+ export declare function relativeLuminance({ r, g, b }: Rgb): number;
32
+ export declare function groundFrom(rgb: Rgb): Exclude<Ground, "unknown">;
33
+ export interface GroundInputs {
34
+ /** KISO_THEME — an explicit answer from the human. */
35
+ readonly theme?: string | undefined;
36
+ /** The body of the terminal's OSC 11 answer, if one arrived. */
37
+ readonly osc?: string | undefined;
38
+ /** The COLORFGBG environment variable, if it is set. */
39
+ readonly colorfgbg?: string | undefined;
40
+ }
41
+ /**
42
+ * The ladder, first hit wins. Every rung that cannot answer falls
43
+ * through rather than guessing, and the bottom of the ladder is
44
+ * `unknown` — see the module comment for why that is a result.
45
+ */
46
+ export declare function resolveGround({ theme, osc, colorfgbg }: GroundInputs): Ground;
package/dist/ground.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * DC-3 §1 — the GROUND: is the terminal light or dark.
3
+ *
4
+ * Every colour in the palette is chosen against a background, and until
5
+ * now kiso had no way to know what that background was: `palette()`
6
+ * returned one constant, its greys were picked on a dark terminal, and
7
+ * on a white one the inline-code token measured 1.54:1 against a 4.5:1
8
+ * floor (finding DC-3).
9
+ *
10
+ * This module is the answer, and it is deliberately PURE. The terminal's
11
+ * reply arrives as a string and the environment arrives as fields, so
12
+ * the whole decision is testable without a terminal and the terminal
13
+ * work is reduced to plumbing.
14
+ *
15
+ * `unknown` is a real answer, not a failure. It means the caller must
16
+ * use the mark that is correct on ANY ground — reverse video rather than
17
+ * a wash, the terminal's own dim rather than a chosen grey. A design
18
+ * that degrades is the price of never painting light-mode paint onto a
19
+ * dark screen.
20
+ */
21
+ /** `<n>;rgb:R/G/B` with 1–4 hex digits per component — the body of an
22
+ * OSC 10/11 answer, already stripped of its introducer and terminator
23
+ * by the editor (DC-7). Anything else is null: a guess about the
24
+ * ground is worse than admitting there isn't one. */
25
+ const OSC_RGB = /^\d+;rgb:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})$/i;
26
+ export function parseOscColor(body) {
27
+ const m = OSC_RGB.exec(body.trim());
28
+ if (m === null)
29
+ return null;
30
+ // a component is n hex digits of a full-scale value, so it scales by
31
+ // its own maximum — `f` is full white exactly as `ffff` is.
32
+ const comp = (h) => Math.round((Number.parseInt(h, 16) / (16 ** h.length - 1)) * 255);
33
+ return { r: comp(m[1]), g: comp(m[2]), b: comp(m[3]) };
34
+ }
35
+ /** The WCAG relative luminance — the same formula the contrast ratios in
36
+ * `packages/tui/design.md` §2 are computed with, so the ground and the
37
+ * floor cannot drift apart. */
38
+ export function relativeLuminance({ r, g, b }) {
39
+ const lin = (v) => {
40
+ const c = v / 255;
41
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
42
+ };
43
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
44
+ }
45
+ export function groundFrom(rgb) {
46
+ return relativeLuminance(rgb) > 0.5 ? "light" : "dark";
47
+ }
48
+ /** COLORFGBG is `fg;bg` or `fg;default;bg` — the BACKGROUND is the last
49
+ * field, as an ANSI index. 0–6 and 8 are the dark half of the sixteen;
50
+ * 7 and 9–15 are the light half. Absent on most terminals, which is why
51
+ * it sits below the question kiso can actually ask. */
52
+ function fromColorFgBg(value) {
53
+ const fields = value.split(";");
54
+ const bg = Number.parseInt(fields[fields.length - 1] ?? "", 10);
55
+ if (!Number.isInteger(bg) || bg < 0 || bg > 15)
56
+ return "unknown";
57
+ return bg === 7 || bg >= 9 ? "light" : "dark";
58
+ }
59
+ /**
60
+ * The ladder, first hit wins. Every rung that cannot answer falls
61
+ * through rather than guessing, and the bottom of the ladder is
62
+ * `unknown` — see the module comment for why that is a result.
63
+ */
64
+ export function resolveGround({ theme, osc, colorfgbg }) {
65
+ const explicit = theme?.trim().toLowerCase();
66
+ if (explicit === "light" || explicit === "dark")
67
+ return explicit;
68
+ if (osc !== undefined && osc !== "") {
69
+ const rgb = parseOscColor(osc);
70
+ if (rgb !== null)
71
+ return groundFrom(rgb);
72
+ }
73
+ if (colorfgbg !== undefined && colorfgbg !== "")
74
+ return fromColorFgBg(colorfgbg);
75
+ return "unknown";
76
+ }
package/dist/index.d.ts CHANGED
@@ -14,4 +14,7 @@ export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWi
14
14
  export { interactivePrompt, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView, type TrustArtifact, } from "./strings.js";
15
15
  export { extensionsBannerText, helpRows, unansweredAskView, type BannerExtension } from "./strings.js";
16
16
  export { displayVerb } from "./strings.js";
17
- export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, relativeTime, renderResumeList, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, type Palette, type ResumeMeta, } from "./render.js";
17
+ export { bannerLines, COLOR_OFF, COLOR_DARK, COLOR_LIGHT, COLOR_NEUTRAL, COLOR_ON, currentGround, setGround, escapeTerminal, foldResult, foldThinking, kUnit, palette, relativeTime, renderResumeList, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, type Palette, type ResumeMeta, } from "./render.js";
18
+ /** DC-3 — the ground: is the terminal light or dark. Pure; see the
19
+ * module comment for why `unknown` is a result and not a failure. */
20
+ export { groundFrom, parseOscColor, relativeLuminance, resolveGround, type Ground, type GroundInputs, type Rgb } from "./ground.js";
package/dist/index.js CHANGED
@@ -28,4 +28,7 @@ export { extensionsBannerText, helpRows, unansweredAskView } from "./strings.js"
28
28
  // TUI2-R2pre ④: the ONE display-verb table — the screen names the act,
29
29
  // the tool table names the call.
30
30
  export { displayVerb } from "./strings.js";
31
- export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, relativeTime, renderResumeList, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, } from "./render.js";
31
+ export { bannerLines, COLOR_OFF, COLOR_DARK, COLOR_LIGHT, COLOR_NEUTRAL, COLOR_ON, currentGround, setGround, escapeTerminal, foldResult, foldThinking, kUnit, palette, relativeTime, renderResumeList, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, } from "./render.js";
32
+ /** DC-3 — the ground: is the terminal light or dark. Pure; see the
33
+ * module comment for why `unknown` is a result and not a failure. */
34
+ export { groundFrom, parseOscColor, relativeLuminance, resolveGround } from "./ground.js";
package/dist/md.d.ts CHANGED
@@ -38,7 +38,7 @@
38
38
  */
39
39
  /** The block kinds. `fence-open`/`fence-line` are separate kinds on
40
40
  * purpose: a fence's rows must be able to freeze ONE AT A TIME. */
41
- export type MdKind = "para" | "heading" | "list" | "table" | "quote" | "rule" | "fence-open" | "fence-line";
41
+ export type MdKind = "para" | "heading" | "list" | "table" | "quote" | "rule" | "fence-open" | "fence-line" | "fence-close";
42
42
  /** One block: its SOURCE lines, never a rendered form. The render is a
43
43
  * pure function of (block, width), which is what makes the freeze
44
44
  * property a property of the scanner alone. */