@vincemakes/kiso-tui-cells 0.16.2 → 0.16.3

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.
@@ -193,7 +193,8 @@ function panelRuleText(view) {
193
193
  const base = view.amended === true
194
194
  ? `${head}${p.dim}needs approval · (amended) — asked by${p.reset}${tail}`
195
195
  : `${head}${p.dim}needs approval — asked by${p.reset}${tail}`;
196
- return hint ? `${base}${p.dim} ·${p.reset} ${p.code}${escapeTerminal(hint)}${p.reset}` : base;
196
+ // DC-3: the fix hint is metadata it borrowed the inline-code tint.
197
+ return hint ? `${base}${p.dim} · ${escapeTerminal(hint)}${p.reset}` : base;
197
198
  }
198
199
  /**
199
200
  * TUI2-R3v2 ① — ONE ROW PER OPTION, and the cursor's row is a bar.
@@ -17,7 +17,7 @@
17
17
  * (untouched); render.ts supplies the original text (palette, escape,
18
18
  * tint, fold wording).
19
19
  */
20
- import { foldThinking, foldResult, renderToolSummary, type ResumeMeta } from "./render.js";
20
+ import { foldThinking, foldResult, renderToolSummary, type ResumeMeta, type BannerMeta } from "./render.js";
21
21
  import { type MdBlock } from "./md.js";
22
22
  export { MdStream, renderBlock, renderMarkdown, type MdBlock, type MdKind } from "./md.js";
23
23
  /** The spinner glyphs, cycled by the compositor's on-demand tick. */
@@ -168,6 +168,7 @@ export type BodyCell = {
168
168
  version: string;
169
169
  extensionsText: string;
170
170
  resume: ResumeMeta[];
171
+ meta?: BannerMeta | undefined;
171
172
  done: true;
172
173
  } | {
173
174
  kind: "raw";
@@ -244,7 +245,7 @@ export declare function expandSuffix(lines: number | null, room: number): string
244
245
  * would put the invariant "exactly one bright token" in as many hands as
245
246
  * there are emitters. Here it has exactly one.
246
247
  *
247
- * NO_COLOR: p.code is empty, so the row's bytes are untouched.
248
+ * NO_COLOR: p.dim is empty, so the row's bytes are untouched.
248
249
  */
249
250
  export declare function focusToken(row: string, W: number): string;
250
251
  /** W13 — the rollup opt-in table: which tools collapse, and the count
@@ -374,11 +375,21 @@ export declare function widthCut(text: string, max: number): string;
374
375
  * keeps the columns from moving as the bar walks the list.
375
376
  */
376
377
  export declare function selectionBar(styled: string, visible: number, W: number): string;
377
- /** W6 — the box: the chrome's top rail. The two ╌ dotted rows become
378
- * a rounded box (the box already says "input lives here"); the rails
379
- * stay dim, the width is still the full W (the box is a rail with
380
- * corners the menu/gap rows above and the status below are
381
- * untouched). */
378
+ /**
379
+ * R2 the composer's rails, and the ONE edge vocabulary.
380
+ *
381
+ * W6 turned two ╌ dotted rows into a rounded box, reasoning that "the
382
+ * box already says input lives here". That is reversed here, and the
383
+ * reason is not taste: a rule is a DELIMITER and a box is a CONTAINER,
384
+ * and the screen was carrying six edge vocabularies at once (this box,
385
+ * the panel's │ gutter and └── tail, the diff gutter, the quote's ▏, the
386
+ * table's ├─┼─┤ rails, the markdown rule's ─). One dashed rule replaces
387
+ * the ones that separate; the │ gutter survives where it SCOPES.
388
+ *
389
+ * Row-neutral by construction: CHROME_ROWS is still 4, so every gate
390
+ * keyed on H − 4 is untouched, and the input row gains the two columns
391
+ * the walls were taking.
392
+ */
382
393
  export declare function boxTop(W: number): string;
383
394
  /** W6 — the box: the chrome's bottom rail. */
384
395
  export declare function boxBottom(W: number): string;
@@ -206,22 +206,32 @@ class UserMessage {
206
206
  // was a 3000-line turn writing 260,298 bytes in one frame.
207
207
  const paras = this.cell.text.split("\n");
208
208
  let truncated = false;
209
+ // DC-6: the folded CONTENT first, then ONE width over all of it.
210
+ // The pad used to be computed per source paragraph, so a message
211
+ // with two lines drew as two bars of two different lengths — a
212
+ // ragged right edge on a block that is one block. A single
213
+ // paragraph was always correct, which is why it survived: the
214
+ // shape only appears once a message has a second line.
215
+ const content = [];
209
216
  for (const para of paras) {
210
- if (rows.length >= USER_CHIP_ROWS) {
217
+ if (content.length >= USER_CHIP_ROWS) {
211
218
  truncated = true;
212
219
  break;
213
220
  }
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) {
221
+ for (const row of foldLine(escapeTerminal(para), chipW)) {
222
+ if (content.length >= USER_CHIP_ROWS) {
218
223
  truncated = true;
219
224
  break;
220
225
  }
221
- const pad = inner - displayWidth(row);
222
- rows.push(`${p.rv} ${row}${" ".repeat(pad)} ${p.rvEnd}`);
226
+ content.push(row);
223
227
  }
224
228
  }
229
+ // displayWidth stays the padding authority (never `length`): a CJK
230
+ // row is two cells per character and pads by cells.
231
+ const inner = content.length === 0 ? 0 : Math.max(...content.map((r) => displayWidth(r)));
232
+ for (const row of content) {
233
+ rows.push(`${p.rv} ${row}${" ".repeat(inner - displayWidth(row))} ${p.rvEnd}`);
234
+ }
225
235
  if (!truncated)
226
236
  return rows;
227
237
  // The notice is OUTSIDE the chip's reverse video, in the cut-row
@@ -758,7 +768,7 @@ function appendSuffix(row, suffix) {
758
768
  * would put the invariant "exactly one bright token" in as many hands as
759
769
  * there are emitters. Here it has exactly one.
760
770
  *
761
- * NO_COLOR: p.code is empty, so the row's bytes are untouched.
771
+ * NO_COLOR: p.dim is empty, so the row's bytes are untouched.
762
772
  */
763
773
  export function focusToken(row, W) {
764
774
  const p = palette();
@@ -766,9 +776,17 @@ export function focusToken(row, W) {
766
776
  if (at !== -1) {
767
777
  // the row already names the key — brighten the token in place, and
768
778
  // leave every other span exactly as it was
769
- if (p.code === "")
779
+ if (p.dim === "")
770
780
  return row;
771
- return `${row.slice(0, at)}${p.code}${CTRL_R}${p.reset}${p.dim}${row.slice(at + CTRL_R.length)}`;
781
+ // DC-3: the marker takes the WASH. It used to take the inline-code
782
+ // tint and inherited its 1.54:1 — the cue naming the key that
783
+ // reveals a cell was itself the least readable thing on a white
784
+ // terminal. The wash is right for a second reason: this token has
785
+ // to be UNIQUE on the frame ("exactly one bright token"), and an
786
+ // attribute like bold is spent everywhere. It closes with washEnd
787
+ // rather than a reset, so the surrounding dim survives instead of
788
+ // having to be re-applied.
789
+ return `${row.slice(0, at)}${p.wash}${CTRL_R}${p.washEnd}${row.slice(at + CTRL_R.length)}`;
772
790
  }
773
791
  // A LIVE row does not carry the affordance today, and the live cell is
774
792
  // the one ctrl+r takes FIRST (expandNext scans the live tail before
@@ -780,7 +798,7 @@ export function focusToken(row, W) {
780
798
  const room = W - visibleWidth(row);
781
799
  if (room < SUFFIX_MIN)
782
800
  return row; // never at the cost of invariant ①
783
- return `${row}${p.dim} · ${p.reset}${p.code}${CTRL_R}${p.reset}`;
801
+ return `${row}${p.dim} · ${p.reset}${p.wash}${CTRL_R}${p.washEnd}`;
784
802
  }
785
803
  const CTRL_R = "ctrl+r";
786
804
  /** TUI2-R1 (A) — the expanded block's last row: the way back. The
@@ -1270,7 +1288,7 @@ class Banner {
1270
1288
  }
1271
1289
  render(W, ctx) {
1272
1290
  const p = palette();
1273
- const rows = bannerLines(W, ctx.height, this.cell.version, this.cell.extensionsText, this.cell.resume, ctx.now);
1291
+ const rows = bannerLines(W, ctx.height, this.cell.version, this.cell.extensionsText, this.cell.resume, ctx.now, this.cell.meta);
1274
1292
  return rows.map((r) => `${p.dim}${r}${p.reset}`);
1275
1293
  }
1276
1294
  }
@@ -1460,17 +1478,27 @@ export function selectionBar(styled, visible, W) {
1460
1478
  const inner = styled.replaceAll(p.reset, `${p.reset}${p.rv}`);
1461
1479
  return `${p.rv} ${inner}${" ".repeat(Math.max(0, W - visible - 2))} ${p.rvEnd}`;
1462
1480
  }
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). */
1481
+ /**
1482
+ * R2 the composer's rails, and the ONE edge vocabulary.
1483
+ *
1484
+ * W6 turned two ╌ dotted rows into a rounded box, reasoning that "the
1485
+ * box already says input lives here". That is reversed here, and the
1486
+ * reason is not taste: a rule is a DELIMITER and a box is a CONTAINER,
1487
+ * and the screen was carrying six edge vocabularies at once (this box,
1488
+ * the panel's │ gutter and └── tail, the diff gutter, the quote's ▏, the
1489
+ * table's ├─┼─┤ rails, the markdown rule's ─). One dashed rule replaces
1490
+ * the ones that separate; the │ gutter survives where it SCOPES.
1491
+ *
1492
+ * Row-neutral by construction: CHROME_ROWS is still 4, so every gate
1493
+ * keyed on H − 4 is untouched, and the input row gains the two columns
1494
+ * the walls were taking.
1495
+ */
1468
1496
  export function boxTop(W) {
1469
- return `\x1b[2m╭${"".repeat(Math.max(0, W - 2))}╮\x1b[0m`;
1497
+ return `\x1b[2m\u256d${"\u2500".repeat(Math.max(0, W - 2))}\u256e\x1b[0m`;
1470
1498
  }
1471
1499
  /** W6 — the box: the chrome's bottom rail. */
1472
1500
  export function boxBottom(W) {
1473
- return `\x1b[2m╰${"".repeat(Math.max(0, W - 2))}╯\x1b[0m`;
1501
+ return `\x1b[2m\u2570${"\u2500".repeat(Math.max(0, W - 2))}\u256f\x1b[0m`;
1474
1502
  }
1475
1503
  /** The terminal label + rhythm gap (the pipe path's v2c bytes — the
1476
1504
  * 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. */
package/dist/md.js CHANGED
@@ -41,6 +41,10 @@ import { breakable, charWidth, displayWidth } from "./width.js";
41
41
  import { escapeTerminal } from "./render.js";
42
42
  import { visibleWidth } from "./components.js";
43
43
  // ---- line classification -------------------------------------------
44
+ /** E2 — the rail a fenced block is drawn with. Three backticks: what
45
+ * the model wrote, and what a human gets back when they copy the block
46
+ * out of the terminal. */
47
+ const RAIL = "\u0060\u0060\u0060";
44
48
  const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
45
49
  /** ATX only, and the space is REQUIRED: `#hashtag` is prose. */
46
50
  const HEADING = /^ {0,3}(#{1,6}) +(\S.*)$/;
@@ -164,11 +168,16 @@ export class MdStream {
164
168
  }
165
169
  #line(line) {
166
170
  if (this.#fence !== null) {
167
- // the closer emits no block: a bottom border is drawn only by an
168
- // actual close, and under committed lines a phantom one would be
169
- // a lie the force-commit path could freeze.
171
+ // E2: the closer emits its OWN block now. The rule it used to
172
+ // obey "a bottom border is drawn only by an actual close, and
173
+ // under committed lines a phantom one would be a lie the
174
+ // force-commit path could freeze" — is UNCHANGED and is why this
175
+ // is safe: the rail appears here, on an actual close, and never
176
+ // before. An unterminated fence still draws no bottom, which is
177
+ // the truth about an unterminated fence.
170
178
  if (closesFence(line, this.#fence)) {
171
179
  this.#fence = null;
180
+ this.#push({ kind: "fence-close", lines: [line], gap: false, lang: "" });
172
181
  return;
173
182
  }
174
183
  this.#push({ kind: "fence-line", lines: [line], gap: false, lang: "" });
@@ -230,22 +239,38 @@ function blockBody(b, W) {
230
239
  const p = palette();
231
240
  switch (b.kind) {
232
241
  case "heading": {
233
- // the marker is stripped, the numbering kept, and the levels are
234
- // NOT differentiated by colour attributes only. A `**bold**`
235
- // inside a heading is therefore a no-op, which is the mono
236
- // discipline paying for itself: the nested-style restore machinery
237
- // both reference implementations need for this exact input has
238
- // nothing to restore here.
242
+ // DC-4: the LEVEL is information and it used to be discarded
243
+ // `#`, `##` and `###` all rendered as the same bold line, so a
244
+ // structured answer arrived flat. Levels are NOT differentiated
245
+ // by colour: 1 adds an underline, 2 is bold alone, and 3 and
246
+ // below print their own `###`, because attributes have run out
247
+ // and a marker is the only carrier that survives a pipe. A
248
+ // `**bold**` inside a heading is still a no-op, which is the mono
249
+ // discipline paying for itself.
239
250
  const m = HEADING.exec(b.lines[0] ?? "");
240
- return wrap(`${p.bold}${inlineSpans(m?.[2] ?? b.lines[0] ?? "", p.bold)}${p.reset}`, W, "", "");
251
+ const level = (m?.[1] ?? "#").length;
252
+ const text = m?.[2] ?? b.lines[0] ?? "";
253
+ const style = level === 1 ? `${p.bold}${p.underline}` : p.bold;
254
+ const marker = level >= 3 ? `${"#".repeat(level)} ` : "";
255
+ return wrap(`${style}${marker}${inlineSpans(text, style)}${p.reset}`, W, "", "");
241
256
  }
242
257
  case "rule":
243
- return [`${p.dim}${"─".repeat(Math.min(W, 28))}${p.reset}`];
258
+ // R2: the dashed rule, at the block's own width. The 28 was a
259
+ // guess that read as a short line rather than a divider, and ─
260
+ // belonged to the box vocabulary this round is collapsing.
261
+ return [`${p.dim}${"\u254c".repeat(Math.max(1, W))}${p.reset}`];
244
262
  case "fence-open":
245
- // the dim gutter names the block; the language tag rides the
246
- // opening row. Zero highlighting which is exactly what makes a
247
- // fence body line committable on its own.
248
- return [`${p.dim}│${b.lang === "" ? "" : ` ${b.lang}`}${p.reset}`];
263
+ // E2: the RAIL, not a gutter. A block drawn with ``` is still a
264
+ // fenced block when a human selects it and pastes it somewhere
265
+ // else; a block drawn with a gutter is not. Zero highlighting —
266
+ // which is exactly what makes a fence body line committable on
267
+ // its own.
268
+ return [`${p.dim}${RAIL}${b.lang}${p.reset}`];
269
+ case "fence-close":
270
+ // only ever reached by an ACTUAL close (see MdStream#line): an
271
+ // unterminated fence draws no bottom, which is the truth about
272
+ // an unterminated fence.
273
+ return [`${p.dim}${RAIL}${p.reset}`];
249
274
  case "fence-line": {
250
275
  // a fence body's INDENTATION is its content. The wrapper drops
251
276
  // leading spaces \u2014 right for prose, a lie for code \u2014 so the indent
@@ -253,12 +278,21 @@ function blockBody(b, W) {
253
278
  // under it rather than returning to the gutter.
254
279
  const src = (b.lines[0] ?? "").replace(/\t/g, " ");
255
280
  const indent = /^ */.exec(src)[0];
256
- const gutter = `${p.dim}\u2502${p.reset} `;
257
- return foldLineWidth(`${p.code}${src.slice(indent.length)}${p.reset}`, W - visibleWidth(gutter), indent).map((r) => `${gutter}${r}`);
281
+ const gutter = " "; // E2: the rails bound the block; the body just insets
282
+ // DC-3: a fenced BODY carries no colour token. It used to take
283
+ // `code` — 1.54:1 on a white terminal, applied to whole blocks,
284
+ // which made the code the model just wrote the least readable
285
+ // thing on screen. The `│` gutter already says "this block is
286
+ // verbatim"; saying it twice cost legibility and bought nothing.
287
+ return foldLineWidth(src.slice(indent.length), W - visibleWidth(gutter), indent).map((r) => `${gutter}${r}`);
258
288
  }
259
289
  case "quote": {
260
290
  const text = b.lines.map((l) => QUOTE.exec(l)?.[1] ?? l).join(" ");
261
- const gutter = `${p.dim}\u258f${p.reset} `;
291
+ // R2: one gutter glyph. A quote and a fenced block both say "this
292
+ // text is not mine", and the screen was saying it two ways — ▏
293
+ // here and │ for code. The fences took their own ``` rails, so │
294
+ // is free and the quote takes it.
295
+ const gutter = `${p.dim}\u2502${p.reset} `;
262
296
  return wrap(`${p.dim}${inlineSpans(text, p.dim)}${p.reset}`, W - visibleWidth(gutter), "", "").map((r) => `${gutter}${r}`);
263
297
  }
264
298
  case "list":
@@ -310,7 +344,10 @@ export function inlineSpans(text, base) {
310
344
  if (end > i) {
311
345
  // a code span's content is LITERAL — no markers inside it mean
312
346
  // anything, which is what makes `x | y` survive a table split
313
- out += `${p.code}${text.slice(i + 1, end)}${p.reset}${base}`;
347
+ // DC-3: inline code is a SURFACE (`wash`), closed with washEnd
348
+ // rather than a reset so the span composes inside a heading's
349
+ // or a quote's own style.
350
+ out += `${p.wash}${text.slice(i + 1, end)}${p.washEnd}${base}`;
314
351
  i = end + 1;
315
352
  continue;
316
353
  }
@@ -456,9 +493,12 @@ function listRows(b, W) {
456
493
  }
457
494
  flush();
458
495
  const depth = Math.min(5, Math.floor(m[1].length / 2));
459
- // `•` normalization for bullets; a numbered list KEEPS its numbers
460
- // (they are the author's meaning, not decoration).
461
- const marker = /^\d/.test(m[2]) ? `${m[2]} ` : "• ";
496
+ // E1: normalization stays `-`, `*` and `+` all render as ONE
497
+ // marker, so the model's arbitrary choice never leaks onto the
498
+ // screen but the marker is `- ` rather than `•`, so a copied list
499
+ // is still a list. A numbered list KEEPS its numbers (they are the
500
+ // author's meaning, not decoration).
501
+ const marker = /^\d/.test(m[2]) ? `${m[2]} ` : "- ";
462
502
  lead = `${" ".repeat(depth + 1)}${marker}`;
463
503
  text = m[3];
464
504
  }
@@ -484,17 +524,17 @@ function tableRows(b, W) {
484
524
  if (t === null)
485
525
  return b.lines.flatMap((l) => wrap(l, W, "", ""));
486
526
  const cols = t.header.map((h, i) => Math.max(cellWidth(h), ...t.rows.map((r) => cellWidth(r[i] ?? ""))));
487
- // the drawn width: one rail, then each column as "│ cell " + its pad
488
- const total = cols.reduce((n, w) => n + w + 3, 1);
527
+ // R2: no rails. The drawn width is two columns of inset plus the
528
+ // columns and their two-space gutters a table is bounded by the
529
+ // blank lines above and below it, exactly as every other block on the
530
+ // screen is, and it was the last box left on a screen that has decided
531
+ // not to have boxes. Alignment does the work the rails were doing, and
532
+ // a copied table is closer to markdown without them.
533
+ const total = cols.reduce((n, w) => n + w + 2, 2);
489
534
  if (total > W)
490
535
  return recordRows(t, W);
491
- const rail = `${p.dim}│${p.reset}`;
492
- const row = (cells, bold) => `${rail}${cells.map((c, i) => ` ${pad(c, cols[i], t.align[i], bold)} `).join(rail)}${rail}`;
493
- return [
494
- row(t.header, true),
495
- `${p.dim}├${cols.map((w) => "─".repeat(w + 2)).join("┼")}┤${p.reset}`,
496
- ...t.rows.map((r) => row(r, false)),
497
- ];
536
+ const row = (cells, bold) => ` ${cells.map((c, i) => pad(c, cols[i], t.align[i], bold)).join(" ")}`.replace(/\s+$/, "");
537
+ return [row(t.header, true), ...t.rows.map((r) => row(r, false))];
498
538
  }
499
539
  /** A cell's column count: what a human sees, styling removed. */
500
540
  function cellWidth(cell) {
package/dist/render.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * produce the bytes a human sees. Colors are raw ANSI — zero
6
6
  * dependencies (the tui-cells package has none).
7
7
  */
8
+ import type { Ground } from "./ground.js";
8
9
  /**
9
10
  * v2a — the palette, centralized (no hard-coded codes elsewhere); v5
10
11
  * (TUI v5 #16e, the v4.1 design): the decorative blue (38;5;75) is
@@ -42,6 +43,12 @@ export interface Palette {
42
43
  * addressed to the human. This is the ruling's own set gaining its
43
44
  * missing member, not a fourth colour. */
44
45
  readonly warn: string;
46
+ /** DC-3 — RETIRED as a tint; kept as an alias of `wash` so nothing
47
+ * reading it gets the old absolute grey. It was 256-colour index 252
48
+ * (#d0d0d0): 1.54:1 on a white terminal, against a 4.5:1 floor, and
49
+ * five call sites shared it. Inline code is a SURFACE now — never a
50
+ * foreground tint, and never applied to a whole fenced block, whose
51
+ * `│` gutter already says the same thing more cheaply. */
45
52
  readonly code: string;
46
53
  /** TUI2-MD (MD-1, the owner's circle) — the markdown round's ONE new
47
54
  * member. `*italic*` needs a rendering, and under the mono discipline
@@ -53,12 +60,44 @@ export interface Palette {
53
60
  * SGR-0 that would strand the heading's own style. */
54
61
  readonly italic: string;
55
62
  readonly italicEnd: string;
63
+ /** DC-4 — the heading round's ONE new member, on the italic precedent:
64
+ * SGR 4 is an ATTRIBUTE, so it costs the alphabet nothing chromatic
65
+ * and a terminal without underlines simply draws the text. It carries
66
+ * the level-1 heading; levels 3 and below carry their own `###`,
67
+ * because attributes run out and a marker survives a pipe. */
68
+ readonly underline: string;
69
+ readonly underlineEnd: string;
56
70
  readonly rv: string;
57
71
  readonly rvEnd: string;
72
+ /** DC-3 — the VERBATIM surface: the human's own words, and inline
73
+ * 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
75
+ * ladder in `ground.ts`. Closed with 49 rather than SGR 0, for the
76
+ * reason `rv` is closed with 27: a washed span sits inside other
77
+ * spans and must end without stranding them. */
78
+ readonly wash: string;
79
+ readonly washEnd: string;
58
80
  readonly reset: string;
59
81
  }
82
+ /**
83
+ * DC-3 — one table per ground.
84
+ *
85
+ * `dim` is the same in all three ON tables and that is the point: SGR 2
86
+ * is an ATTRIBUTE, it dims whatever the terminal's own foreground is, so
87
+ * it adapts to the ground instead of asserting one. Only the background
88
+ * genuinely needs to know, which is why `wash` is the only member that
89
+ * varies.
90
+ */
91
+ export declare const COLOR_NEUTRAL: Palette;
92
+ export declare const COLOR_LIGHT: Palette;
93
+ export declare const COLOR_DARK: Palette;
94
+ /** The historical name — the palette for a colour TTY whose ground has
95
+ * not been established. Unchanged in every byte except `code`, which
96
+ * was the defect. */
60
97
  export declare const COLOR_ON: Palette;
61
98
  export declare const COLOR_OFF: Palette;
99
+ export declare function setGround(g: Ground): void;
100
+ export declare function currentGround(): Ground;
62
101
  export declare function palette(): Palette;
63
102
  /**
64
103
  * E group/round 8: strip terminal-injection vectors from MODEL/TOOL text before it
@@ -113,6 +152,13 @@ export declare function renderTerminalGap(statusLine: string | null): string;
113
152
  * Pure.
114
153
  */
115
154
  export declare const TAGLINE = "the coding agent that survives kill -9";
155
+ /** R2 — what the opening knows about the session. Optional because the
156
+ * off-TTY caller prints a banner before a model is bound. */
157
+ export interface BannerMeta {
158
+ readonly model: string;
159
+ readonly mode: string;
160
+ readonly cwd: string;
161
+ }
116
162
  /** v3 §01 (W1): truncate a row at `width`, marking the hidden span
117
163
  * " (+N)". W1: the width math is the charWidth authority (the banner's
118
164
  * brick glyphs are 1 cell — the art's 38 columns clear 40), and the
@@ -129,7 +175,7 @@ export declare function truncateRow(row: string, width: number): string;
129
175
  * text row does not repeat the name — then extensions — then the W5
130
176
  * resume list (BIG only, W5). Every row truncates at the terminal width
131
177
  * with a " (+N)" marker. Pure. */
132
- export declare function bannerLines(W: number, H: number, version: string, extensionsText: string, resume?: readonly ResumeMeta[], now?: number): string[];
178
+ export declare function bannerLines(W: number, H: number, version: string, extensionsText: string, resume?: readonly ResumeMeta[], now?: number, meta?: BannerMeta | undefined): string[];
133
179
  /** W5 — the opening-screen resume list. Every field already exists
134
180
  * behind renderSessionLine / `kiso sessions`: the relative time, the
135
181
  * title, then the right-aligned "N events · M runs". The columns are
package/dist/render.js CHANGED
@@ -6,8 +6,35 @@
6
6
  * dependencies (the tui-cells package has none).
7
7
  */
8
8
  import { charWidth, displayWidth } from "./width.js";
9
- export const COLOR_ON = { bold: "\x1b[1m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", warn: "\x1b[33m", code: "\x1b[38;5;252m", italic: "\x1b[3m", italicEnd: "\x1b[23m", rv: "\x1b[7m", rvEnd: "\x1b[27m", reset: "\x1b[0m" };
10
- export const COLOR_OFF = { bold: "", dim: "", red: "", green: "", warn: "", code: "", italic: "", italicEnd: "", rv: "", rvEnd: "", reset: "" };
9
+ const BASE = { bold: "\x1b[1m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", warn: "\x1b[33m", italic: "\x1b[3m", italicEnd: "\x1b[23m", underline: "\x1b[4m", underlineEnd: "\x1b[24m", rv: "\x1b[7m", rvEnd: "\x1b[27m", reset: "\x1b[0m" };
10
+ const withWash = (wash, washEnd) => ({ ...BASE, wash, washEnd, code: wash });
11
+ /**
12
+ * DC-3 — one table per ground.
13
+ *
14
+ * `dim` is the same in all three ON tables and that is the point: SGR 2
15
+ * is an ATTRIBUTE, it dims whatever the terminal's own foreground is, so
16
+ * it adapts to the ground instead of asserting one. Only the background
17
+ * genuinely needs to know, which is why `wash` is the only member that
18
+ * varies.
19
+ */
20
+ export const COLOR_NEUTRAL = withWash("\x1b[7m", "\x1b[27m");
21
+ export const COLOR_LIGHT = withWash("\x1b[48;5;255m", "\x1b[49m");
22
+ export const COLOR_DARK = withWash("\x1b[48;5;236m", "\x1b[49m");
23
+ /** The historical name — the palette for a colour TTY whose ground has
24
+ * not been established. Unchanged in every byte except `code`, which
25
+ * was the defect. */
26
+ export const COLOR_ON = COLOR_NEUTRAL;
27
+ export const COLOR_OFF = { bold: "", dim: "", red: "", green: "", warn: "", code: "", italic: "", italicEnd: "", underline: "", underlineEnd: "", rv: "", rvEnd: "", wash: "", washEnd: "", reset: "" };
28
+ /** DC-3 — the resolved ground, set once at startup when the terminal
29
+ * answers (see `ground.ts`). It starts UNKNOWN and may stay that way
30
+ * forever; that is a supported state, not a failure. */
31
+ let ground = "unknown";
32
+ export function setGround(g) {
33
+ ground = g;
34
+ }
35
+ export function currentGround() {
36
+ return ground;
37
+ }
11
38
  export function palette() {
12
39
  // PH-1a (finding PH-F5): the no-color.org contract is "present AND
13
40
  // non-empty" — the old `=== undefined` check let an EMPTY `NO_COLOR=`
@@ -16,7 +43,9 @@ export function palette() {
16
43
  // UI with them. The v4-round plan recorded this as debugging pitfall ①;
17
44
  // it was a bug.
18
45
  const noColor = process.env.NO_COLOR;
19
- return (noColor === undefined || noColor === "") && process.stdout.isTTY ? COLOR_ON : COLOR_OFF;
46
+ if (!((noColor === undefined || noColor === "") && process.stdout.isTTY))
47
+ return COLOR_OFF;
48
+ return ground === "light" ? COLOR_LIGHT : ground === "dark" ? COLOR_DARK : COLOR_NEUTRAL;
20
49
  }
21
50
  /**
22
51
  * E group/round 8: strip terminal-injection vectors from MODEL/TOOL text before it
@@ -162,13 +191,30 @@ export function renderTerminalGap(statusLine) {
162
191
  * Pure.
163
192
  */
164
193
  export const TAGLINE = "the coding agent that survives kill -9";
165
- /** TT-1B (VD-14) — the ONE wordmark, 2 rows. The 36x6 pixel art and the
166
- * 3-row compact logo both retire: a tall pixel banner's mid-scroll cut
167
- * state renders as glyph garbage (VD-14 — frames 03/04/s2-05 of the
168
- * 2026-08-17 walkthrough), inherent to the height. This is the compact
169
- * font with its base row folded into lower half-blocks same alphabet,
170
- * 15 columns, and the cut window shrinks to nothing a reader catches. */
171
- const WORDMARK_ROWS = ["█ ▀█▀ █▀▀ █▀█", "█▀▄ ▄█▄ ▄▄█ █▄█"];
194
+ /**
195
+ * R2 the wordmark is retired (2026-08-27, the nineteen-screen review).
196
+ *
197
+ * TT-1B had already cut the 36x6 pixel art down to two rows because a
198
+ * tall banner's mid-scroll cut state renders as glyph garbage. The
199
+ * remaining two rows go now for a different reason: they say the word
200
+ * `kiso` in fifteen columns of block glyphs, and the word `kiso` says it
201
+ * in four. A rendered clover mark was tried first, at 4x2, 10x5, 14x7
202
+ * and 16x8, and rejected on measurement — below fourteen columns the
203
+ * centre star closes and the mark reads as a domino, and at fourteen it
204
+ * costs seven rows.
205
+ *
206
+ * What takes the room is not decoration. A first screen is asked three
207
+ * questions — what model, where am I, what is loaded — and it now
208
+ * answers them in one aligned column.
209
+ */
210
+ /** R2 — the keys a first screen teaches. One dim row, and deliberately
211
+ * NOT derived from KEY_BINDINGS: the sheet is the complete list and
212
+ * this is the opening's five, chosen rather than generated. */
213
+ const BANNER_KEYS = "esc interrupt · ctrl+c exit · / commands · ! bash · ctrl+r expand";
214
+ /** R2 — the labels. Uppercase mono, dim, letter-spaced by the column
215
+ * rather than by SGR: they mark sections and are never content. */
216
+ const BANNER_LABELS = ["MODEL", "WORKSPACE", "EXTENSIONS"];
217
+ const LABEL_STOP = Math.max(...BANNER_LABELS.map((l) => l.length)) + 2;
172
218
  /** v3 §01 (W1): truncate a row at `width`, marking the hidden span
173
219
  * " (+N)". W1: the width math is the charWidth authority (the banner's
174
220
  * brick glyphs are 1 cell — the art's 38 columns clear 40), and the
@@ -209,17 +255,45 @@ export function truncateRow(row, width) {
209
255
  * text row does not repeat the name — then extensions — then the W5
210
256
  * resume list (BIG only, W5). Every row truncates at the terminal width
211
257
  * with a " (+N)" marker. Pure. */
212
- export function bannerLines(W, H, version, extensionsText, resume = [], now = Date.now()) {
213
- const rows = [];
214
- if (W >= 40 && H >= 14) {
215
- for (const r of WORDMARK_ROWS)
216
- rows.push(truncateRow(` ${r}`, W));
258
+ export function bannerLines(W, H, version, extensionsText, resume = [], now = Date.now(), meta) {
259
+ const rows = [truncateRow(`kiso ${version}`, W)];
260
+ const facts = [];
261
+ if (meta !== undefined) {
262
+ facts.push([BANNER_LABELS[0], `${meta.model}${meta.mode === "" ? "" : ` · ${meta.mode}`}`], [BANNER_LABELS[1], meta.cwd]);
217
263
  }
218
- if (rows.length > 0)
219
- rows.push("");
220
- rows.push(truncateRow(`v${version} — ${TAGLINE}`, W));
221
264
  if (extensionsText !== "")
222
- rows.push(truncateRow(extensionsText, W));
265
+ facts.push([BANNER_LABELS[2], extensionsText]);
266
+ if (facts.length > 0) {
267
+ rows.push("");
268
+ // The value column HANGS rather than truncating. The label costs
269
+ // columns the value used to have, and an extension list cut at the
270
+ // width would hide which extensions loaded — on the one screen whose
271
+ // job is to say what is loaded. Words wrap; a word longer than the
272
+ // column still truncates, with truncateRow's honest marker.
273
+ const room = Math.max(8, W - 2 - LABEL_STOP);
274
+ for (const [label, value] of facts) {
275
+ const lead = ` ${label}${" ".repeat(LABEL_STOP - label.length)}`;
276
+ const hang = " ".repeat(displayWidth(lead));
277
+ let line = "";
278
+ const out = [];
279
+ for (const word of value.split(" ")) {
280
+ if (line === "")
281
+ line = word;
282
+ else if (displayWidth(`${line} ${word}`) <= room)
283
+ line += ` ${word}`;
284
+ else {
285
+ out.push(line);
286
+ line = word;
287
+ }
288
+ }
289
+ if (line !== "")
290
+ out.push(line);
291
+ for (const [i, l] of out.entries())
292
+ rows.push(truncateRow(`${i === 0 ? lead : hang}${l}`, W));
293
+ }
294
+ }
295
+ if (meta !== undefined && W >= 40)
296
+ rows.push("", truncateRow(` ${BANNER_KEYS}`, W));
223
297
  if (W >= 40 && H >= 20 && resume.length > 0) {
224
298
  rows.push("", ...renderResumeList(resume, W, now));
225
299
  }
package/dist/strings.js CHANGED
@@ -276,7 +276,10 @@ export function keysSheetRows(W) {
276
276
  const p = palette();
277
277
  const cell = (i) => {
278
278
  const b = KEY_BINDINGS[i];
279
- return b === undefined ? "" : `${p.code}${b.keys}${p.reset} ${b.what}`;
279
+ // DC-3: the key NAMES are the sheet's content, not a code span —
280
+ // they borrowed the inline-code tint and became the least readable
281
+ // thing on the one screen whose whole job is being read.
282
+ return b === undefined ? "" : `${p.bold}${b.keys}${p.reset} ${b.what}`;
280
283
  };
281
284
  const plainCell = (i) => {
282
285
  const b = KEY_BINDINGS[i];
@@ -299,13 +302,40 @@ export function keysSheetRows(W) {
299
302
  }
300
303
  rows.push(row);
301
304
  }
302
- rows.push(`${p.dim}${PANEL_KEYS_ROW}${p.reset}`);
305
+ rows.push(`${p.dim}${panelKeysRow(W)}${p.reset}`);
303
306
  return rows.map((row) => cutRow(row, W));
304
307
  }
308
+ /**
309
+ * DC-2 — the panel row degrades by CLAUSE.
310
+ *
311
+ * The row is 76 columns of independent clauses joined by ` · `. At 72 it
312
+ * used to lose the tail of the last one, so `t types` became `t`: the
313
+ * reader was told a key existed and not told what it did, on a row that
314
+ * still looked complete. Dropping a whole clause says less; it never
315
+ * says something false. `cutRow`'s ellipsis is the floor below this, for
316
+ * a width that cannot hold even the first clause.
317
+ */
318
+ function panelKeysRow(W) {
319
+ const clauses = PANEL_KEYS_ROW.split(" \u00b7 ");
320
+ for (let n = clauses.length; n > 1; n -= 1) {
321
+ const row = clauses.slice(0, n).join(" \u00b7 ");
322
+ if (displayWidth(row) <= W)
323
+ return row;
324
+ }
325
+ return clauses[0];
326
+ }
305
327
  /** One row, cut at the width — SGR-aware, the ellipsis after the reset
306
328
  * (the cutLine convention; duplicated here rather than imported so the
307
329
  * strings module keeps its no-components-dependency shape). */
308
330
  function cutRow(row, W) {
331
+ // DC-2: a cut is MARKED. This returned the surviving prefix with
332
+ // nothing to say it was a prefix, so a row that had lost its tail read
333
+ // as a whole row — the one thing the tree's own fold rule forbids
334
+ // ("the honest …, never a silent truncate"). The mark costs a column,
335
+ // so the cut lands one column earlier to pay for it.
336
+ if (displayWidth(row.replace(/\x1b\[[0-9;]*m/g, "")) <= W)
337
+ return row;
338
+ const limit = Math.max(0, W - 1);
309
339
  let out = "";
310
340
  let width = 0;
311
341
  for (let i = 0; i < row.length;) {
@@ -316,13 +346,13 @@ function cutRow(row, W) {
316
346
  continue;
317
347
  }
318
348
  const cw = displayWidth(row[i]);
319
- if (width + cw > W)
320
- return `${out}${palette().reset}`;
349
+ if (width + cw > limit)
350
+ break;
321
351
  out += row[i];
322
352
  width += cw;
323
353
  i += 1;
324
354
  }
325
- return out;
355
+ return `${out}${palette().reset}\u2026`;
326
356
  }
327
357
  /** TUI2-R1 (D) — the keys as ONE line, for /help. The same table the
328
358
  * sheet renders, joined — so the two can disagree only by deleting a
@@ -343,24 +373,38 @@ export function keysHelpRow() {
343
373
  */
344
374
  export function helpRows() {
345
375
  const p = palette();
346
- const cmd = (name, desc) => `${p.bold}${name}${p.reset} ${desc}`;
347
- return [
348
- cmd("/help", "print this list of commands"),
349
- cmd("/think", "show the last full thinking block"),
350
- cmd("/last", "show the most recent tool call's input and output"),
351
- cmd("/status", "show session id, event count, and context estimate"),
352
- cmd("/mode", "show the approval tier; /mode <name> switches (manual/default/accept-edits/plan/bypass)"),
353
- cmd("/model", "list model profiles; /model <name|provider/model> switches"),
354
- cmd("/compact", "summarize the older conversation to free context"),
355
- cmd("/clear", "start a fresh conversation (the old session stays resumable)"),
356
- cmd("/resume", "switch to another session; /resume <id> goes directly"),
357
- // TUI2-R1 (D): DELIBERATELY UNCHANGED. Deriving this sentence from
358
- // KEY_BINDINGS would be an improvement and it would also move an
359
- // assertion outside the round's two declared supersession classes,
360
- // so the sheet is the derived surface and this row keeps its bytes.
361
- // `keysHelpRow()` exists for the round that is allowed to make the
362
- // swap; until then the drift guard is the test that every binding
363
- // in the table is mentioned here.
364
- `${cmd("exit", "leave the session")}\n${cmd("keys", "enter sends · ctrl+J newline (shift+enter where encoded) · esc stops the run · alt+⏎ stops it and sends this instead · @ files · 1-4 answers an ask")}`,
376
+ // DC-1: ONE description column. The gap used to be four spaces after
377
+ // the name whatever the name's length, so `/help`'s description began
378
+ // three columns left of `/compact`'s and the second column wandered
379
+ // down the list. displayWidth is the authority, as everywhere else.
380
+ const table = [
381
+ ["/help", "print this list of commands"],
382
+ ["/think", "show the last full thinking block"],
383
+ ["/last", "show the most recent tool call's input and output"],
384
+ ["/status", "show session id, event count, and context estimate"],
385
+ ["/mode", "show the approval tier; /mode <name> switches (manual/default/accept-edits/plan/bypass)"],
386
+ ["/model", "list model profiles; /model <name|provider/model> switches"],
387
+ ["/compact", "summarize the older conversation to free context"],
388
+ ["/clear", "start a fresh conversation (the old session stays resumable)"],
389
+ ["/resume", "switch to another session; /resume <id> goes directly"],
390
+ ["exit", "leave the session"],
391
+ // TUI2-R1 (D): the SENTENCE is deliberately unchanged. Deriving it
392
+ // from KEY_BINDINGS would be an improvement and it would also move
393
+ // an assertion outside the round's declared supersession classes,
394
+ // so the sheet stays the derived surface and this row keeps its
395
+ // words. `keysHelpRow()` exists for the round allowed to swap it;
396
+ // until then the drift guard is the test that every binding in the
397
+ // table is mentioned here. DC-1 changes the PADDING, not the words.
398
+ ["keys", "enter sends \u00b7 ctrl+J newline (shift+enter where encoded) \u00b7 esc stops the run \u00b7 alt+\u23ce stops it and sends this instead \u00b7 @ files \u00b7 1-4 answers an ask"],
365
399
  ];
400
+ const stop = Math.max(...table.map(([name]) => displayWidth(name))) + 4;
401
+ const cmd = (name, desc) => `${p.bold}${name}${p.reset}${" ".repeat(stop - displayWidth(name))}${desc}`;
402
+ const rows = table.slice(0, -2).map(([name, desc]) => cmd(name, desc));
403
+ // the last call carries its own newline exactly as it did inline:
404
+ // bodyLog splits on \n, so `exit` and `keys` land as two rows from one
405
+ // call — the shape the KC1/KC2/KC3 gestures were added to, unchanged.
406
+ const [exitName, exitDesc] = table[table.length - 2];
407
+ const [keysName, keysDesc] = table[table.length - 1];
408
+ rows.push(`${cmd(exitName, exitDesc)}\n${cmd(keysName, keysDesc)}`);
409
+ return rows;
366
410
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui-cells",
3
- "version": "0.16.2",
4
- "description": "kiso tui-cells \u2014 the components cell renderer (components, diff, width, the render slice). Zero runtime dependencies: input is data, output is bytes.",
3
+ "version": "0.16.3",
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",
7
7
  "exports": {