@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.
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,23 @@ 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 block's own ``` RAILS already say "this
286
+ // is verbatim" (E2 replaced the `│` gutter this comment used to
287
+ // name with them); saying it twice cost legibility and bought
288
+ // nothing.
289
+ return foldLineWidth(src.slice(indent.length), W - visibleWidth(gutter), indent).map((r) => `${gutter}${r}`);
258
290
  }
259
291
  case "quote": {
260
292
  const text = b.lines.map((l) => QUOTE.exec(l)?.[1] ?? l).join(" ");
261
- const gutter = `${p.dim}\u258f${p.reset} `;
293
+ // R2: one gutter glyph. A quote and a fenced block both say "this
294
+ // text is not mine", and the screen was saying it two ways — ▏
295
+ // here and │ for code. The fences took their own ``` rails, so │
296
+ // is free and the quote takes it.
297
+ const gutter = `${p.dim}\u2502${p.reset} `;
262
298
  return wrap(`${p.dim}${inlineSpans(text, p.dim)}${p.reset}`, W - visibleWidth(gutter), "", "").map((r) => `${gutter}${r}`);
263
299
  }
264
300
  case "list":
@@ -310,7 +346,10 @@ export function inlineSpans(text, base) {
310
346
  if (end > i) {
311
347
  // a code span's content is LITERAL — no markers inside it mean
312
348
  // anything, which is what makes `x | y` survive a table split
313
- out += `${p.code}${text.slice(i + 1, end)}${p.reset}${base}`;
349
+ // DC-3: inline code is a SURFACE (`wash`), closed with washEnd
350
+ // rather than a reset so the span composes inside a heading's
351
+ // or a quote's own style.
352
+ out += `${p.wash}${text.slice(i + 1, end)}${p.washEnd}${base}`;
314
353
  i = end + 1;
315
354
  continue;
316
355
  }
@@ -456,9 +495,12 @@ function listRows(b, W) {
456
495
  }
457
496
  flush();
458
497
  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]} ` : "• ";
498
+ // E1: normalization stays `-`, `*` and `+` all render as ONE
499
+ // marker, so the model's arbitrary choice never leaks onto the
500
+ // screen but the marker is `- ` rather than `•`, so a copied list
501
+ // is still a list. A numbered list KEEPS its numbers (they are the
502
+ // author's meaning, not decoration).
503
+ const marker = /^\d/.test(m[2]) ? `${m[2]} ` : "- ";
462
504
  lead = `${" ".repeat(depth + 1)}${marker}`;
463
505
  text = m[3];
464
506
  }
@@ -484,17 +526,17 @@ function tableRows(b, W) {
484
526
  if (t === null)
485
527
  return b.lines.flatMap((l) => wrap(l, W, "", ""));
486
528
  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);
529
+ // R2: no rails. The drawn width is two columns of inset plus the
530
+ // columns and their two-space gutters a table is bounded by the
531
+ // blank lines above and below it, exactly as every other block on the
532
+ // screen is, and it was the last box left on a screen that has decided
533
+ // not to have boxes. Alignment does the work the rails were doing, and
534
+ // a copied table is closer to markdown without them.
535
+ const total = cols.reduce((n, w) => n + w + 2, 2);
489
536
  if (total > W)
490
537
  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
- ];
538
+ const row = (cells, bold) => ` ${cells.map((c, i) => pad(c, cols[i], t.align[i], bold)).join(" ")}`.replace(/\s+$/, "");
539
+ return [row(t.header, true), ...t.rows.map((r) => row(r, false))];
498
540
  }
499
541
  /** A cell's column count: what a human sees, styling removed. */
500
542
  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
@@ -102,17 +141,14 @@ export declare function kUnit(value: number | null): string;
102
141
  * this verbatim; the render tests pin the sequence.
103
142
  */
104
143
  export declare function renderTerminalGap(statusLine: string | null): string;
105
- /**
106
- * v3 §01 (V6-2) — the banner, block-split. The logo is THREE BRICK rows
107
- * (the logo.svg pixel form — K I S O), then a BLANK, then the info rows:
108
- * "kiso vX — tagline" + extensions. The tagline rides the version line
109
- * (the old logo MIDDLE row was the tagline — a text row masquerading as
110
- * the logo's centre). Every row truncates at the terminal width with a
111
- * " (+N)" marker (N = the hidden display width); a window narrower than
112
- * 40 columns skips the logo + the blank entirely — only the info rows.
113
- * Pure.
114
- */
115
144
  export declare const TAGLINE = "the coding agent that survives kill -9";
145
+ /** R2 — what the opening knows about the session. Optional because the
146
+ * off-TTY caller prints a banner before a model is bound. */
147
+ export interface BannerMeta {
148
+ readonly model: string;
149
+ readonly mode: string;
150
+ readonly cwd: string;
151
+ }
116
152
  /** v3 §01 (W1): truncate a row at `width`, marking the hidden span
117
153
  * " (+N)". W1: the width math is the charWidth authority (the banner's
118
154
  * brick glyphs are 1 cell — the art's 38 columns clear 40), and the
@@ -129,7 +165,7 @@ export declare function truncateRow(row: string, width: number): string;
129
165
  * text row does not repeat the name — then extensions — then the W5
130
166
  * resume list (BIG only, W5). Every row truncates at the terminal width
131
167
  * with a " (+N)" marker. Pure. */
132
- export declare function bannerLines(W: number, H: number, version: string, extensionsText: string, resume?: readonly ResumeMeta[], now?: number): string[];
168
+ export declare function bannerLines(W: number, H: number, version: string, extensionsText: string, resume?: readonly ResumeMeta[], now?: number, meta?: BannerMeta | undefined): string[];
133
169
  /** W5 — the opening-screen resume list. Every field already exists
134
170
  * behind renderSessionLine / `kiso sessions`: the relative time, the
135
171
  * title, then the right-aligned "N events · M runs". The columns are
package/dist/render.js CHANGED
@@ -6,8 +6,56 @@
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
+ /**
11
+ * DC-9 (design §2.3) — the failure colour is theme-resolved.
12
+ *
13
+ * ANSI 31 is 5.89:1 on a white ground and 2.83:1 on a dark one: the one
14
+ * token in the alphabet whose whole job is "this went wrong" was the
15
+ * least readable thing on the screen exactly where a dark-terminal user
16
+ * reads it. A failure is CONTENT (law 1.2 admits colour there), so it
17
+ * cannot degrade to an attribute the way `dim` does — it needs a value
18
+ * per ground, and the ground is what §3's ladder is for.
19
+ *
20
+ * 256-cube indices, never truecolor (§2). Measured against the grounds
21
+ * §2 measures against — white, and #1E1E1E:
22
+ *
23
+ * light 124 `#af0000` 7.44:1
24
+ * dark 173 `#d7875f` 5.97:1
25
+ *
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
28
+ * 4's principle exactly: when the ground is unknown, use the thing that
29
+ * is correct on any ground rather than guessing one.
30
+ */
31
+ const withWash = (wash, washEnd, red = BASE.red) => ({ ...BASE, red, wash, washEnd, code: wash });
32
+ /**
33
+ * DC-3 — one table per ground.
34
+ *
35
+ * `dim` is the same in all three ON tables and that is the point: SGR 2
36
+ * is an ATTRIBUTE, it dims whatever the terminal's own foreground is, so
37
+ * it adapts to the ground instead of asserting one. Only the background
38
+ * genuinely needs to know, which is why `wash` is the only member that
39
+ * varies.
40
+ */
41
+ export const COLOR_NEUTRAL = withWash("\x1b[7m", "\x1b[27m");
42
+ export const COLOR_LIGHT = withWash("\x1b[48;5;255m", "\x1b[49m", "\x1b[38;5;124m");
43
+ export const COLOR_DARK = withWash("\x1b[48;5;236m", "\x1b[49m", "\x1b[38;5;173m");
44
+ /** The historical name — the palette for a colour TTY whose ground has
45
+ * not been established. Unchanged in every byte except `code`, which
46
+ * was the defect. */
47
+ export const COLOR_ON = COLOR_NEUTRAL;
48
+ export const COLOR_OFF = { bold: "", dim: "", red: "", green: "", warn: "", code: "", italic: "", italicEnd: "", underline: "", underlineEnd: "", rv: "", rvEnd: "", wash: "", washEnd: "", reset: "" };
49
+ /** DC-3 — the resolved ground, set once at startup when the terminal
50
+ * answers (see `ground.ts`). It starts UNKNOWN and may stay that way
51
+ * forever; that is a supported state, not a failure. */
52
+ let ground = "unknown";
53
+ export function setGround(g) {
54
+ ground = g;
55
+ }
56
+ export function currentGround() {
57
+ return ground;
58
+ }
11
59
  export function palette() {
12
60
  // PH-1a (finding PH-F5): the no-color.org contract is "present AND
13
61
  // non-empty" — the old `=== undefined` check let an EMPTY `NO_COLOR=`
@@ -16,7 +64,9 @@ export function palette() {
16
64
  // UI with them. The v4-round plan recorded this as debugging pitfall ①;
17
65
  // it was a bug.
18
66
  const noColor = process.env.NO_COLOR;
19
- return (noColor === undefined || noColor === "") && process.stdout.isTTY ? COLOR_ON : COLOR_OFF;
67
+ if (!((noColor === undefined || noColor === "") && process.stdout.isTTY))
68
+ return COLOR_OFF;
69
+ return ground === "light" ? COLOR_LIGHT : ground === "dark" ? COLOR_DARK : COLOR_NEUTRAL;
20
70
  }
21
71
  /**
22
72
  * E group/round 8: strip terminal-injection vectors from MODEL/TOOL text before it
@@ -161,14 +211,49 @@ export function renderTerminalGap(statusLine) {
161
211
  * 40 columns skips the logo + the blank entirely — only the info rows.
162
212
  * Pure.
163
213
  */
214
+ /** DC-18: the display-width prefix of PLAIN text. `widthCut` lives in
215
+ * components.ts, which imports this module — the dependency runs one
216
+ * way, so the four lines live here rather than inverting it. */
217
+ function plainCut(text, max) {
218
+ let w = 0;
219
+ let i = 0;
220
+ for (; i < text.length; i += 1) {
221
+ const cw = charWidth(text.codePointAt(i));
222
+ if (w + cw > max)
223
+ break;
224
+ w += cw;
225
+ }
226
+ return text.slice(0, i);
227
+ }
164
228
  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 = ["█ ▀█▀ █▀▀ █▀█", "█▀▄ ▄█▄ ▄▄█ █▄█"];
229
+ /**
230
+ * R2 the wordmark is retired (2026-08-27, the nineteen-screen review).
231
+ *
232
+ * TT-1B had already cut the 36x6 pixel art down to two rows because a
233
+ * tall banner's mid-scroll cut state renders as glyph garbage. The
234
+ * remaining two rows go now for a different reason: they say the word
235
+ * `kiso` in fifteen columns of block glyphs, and the word `kiso` says it
236
+ * in four. A rendered clover mark was tried first, at 4x2, 10x5, 14x7
237
+ * and 16x8, and rejected on measurement — below fourteen columns the
238
+ * centre star closes and the mark reads as a domino, and at fourteen it
239
+ * costs seven rows.
240
+ *
241
+ * What takes the room is not decoration. A first screen is asked three
242
+ * questions — what model, where am I, what is loaded — and it now
243
+ * answers them in one aligned column.
244
+ */
245
+ /** R2 — the keys a first screen teaches. One dim row, and deliberately
246
+ * NOT derived from KEY_BINDINGS: the sheet is the complete list and
247
+ * this is the opening's five, chosen rather than generated. */
248
+ // R2: the keys row names bindings the product ACTUALLY has. The first
249
+ // draft advertised `! bash` — there is no bang passthrough in kiso and
250
+ // KEY_BINDINGS never had one, so the opening screen was teaching a key
251
+ // that does nothing. A first screen that lies is worse than a short one.
252
+ const BANNER_KEYS = "esc interrupt · ctrl+c exit · / commands · @ files · ? keys";
253
+ /** R2 — the labels. Uppercase mono, dim, letter-spaced by the column
254
+ * rather than by SGR: they mark sections and are never content. */
255
+ const BANNER_LABELS = ["MODEL", "WORKSPACE", "EXTENSIONS"];
256
+ const LABEL_STOP = Math.max(...BANNER_LABELS.map((l) => l.length)) + 2;
172
257
  /** v3 §01 (W1): truncate a row at `width`, marking the hidden span
173
258
  * " (+N)". W1: the width math is the charWidth authority (the banner's
174
259
  * brick glyphs are 1 cell — the art's 38 columns clear 40), and the
@@ -179,6 +264,13 @@ export function truncateRow(row, width) {
179
264
  const total = displayWidth(row);
180
265
  if (total <= width)
181
266
  return row;
267
+ // DC-18: a width too narrow to HOLD the marker gets a hard cut. The
268
+ // fixpoint below floors `cut` at 0 and then appends a 6-cell marker
269
+ // regardless, so every width ≤ 6 returned a row WIDER than the
270
+ // terminal — and invariant ① throws rather than truncating. A marker
271
+ // wider than the row it marks is not a marker.
272
+ if (width < 7)
273
+ return plainCut(row, Math.max(0, width));
182
274
  // iterate the marker to a fixpoint: the marker's width changes the
183
275
  // cut, the cut changes the hidden count the marker reports
184
276
  let marker = " (+0)";
@@ -209,17 +301,80 @@ export function truncateRow(row, width) {
209
301
  * text row does not repeat the name — then extensions — then the W5
210
302
  * resume list (BIG only, W5). Every row truncates at the terminal width
211
303
  * 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));
304
+ export function bannerLines(W, H, version, extensionsText, resume = [], now = Date.now(), meta) {
305
+ const p = palette();
306
+ // R2: the banner styles itself per span. It used to be wrapped in one
307
+ // blanket dim by its component, which made the answers as faint as the
308
+ // labels asking the questions — the labels are the quiet half, the
309
+ // values are what a human came to read.
310
+ //
311
+ // Every width decision below is taken on PLAIN text and the styling is
312
+ // applied after, because truncateRow measures with displayWidth, which
313
+ // counts SGR bytes as columns. Style then measure is a bug waiting.
314
+ // DC-18: the name row is CUT like every other row here. It was the one
315
+ // row in this function pushed unguarded, so at W ≤ 10 `kiso 0.16.4`
316
+ // measured 11 cells and invariant ① threw AT STARTUP — the function
317
+ // whose own comment preaches "invariant ① holds at every width".
318
+ // The cut is taken on the plain text, per the note above.
319
+ const namePlain = plainCut(`kiso ${version}`, Math.max(1, W));
320
+ const nameCut = namePlain.slice(0, 4); // "kiso", or its surviving prefix
321
+ const verCut = namePlain.slice(5); // the version, if the width left room for it
322
+ const rows = [`${p.bold}${nameCut}${p.reset}${verCut === "" ? "" : `${p.dim} ${verCut}${p.reset}`}`];
323
+ const facts = [];
324
+ if (meta !== undefined) {
325
+ facts.push([BANNER_LABELS[0], `${meta.model}${meta.mode === "" ? "" : ` · ${meta.mode}`}`], [BANNER_LABELS[1], meta.cwd]);
217
326
  }
218
- if (rows.length > 0)
219
- rows.push("");
220
- rows.push(truncateRow(`v${version} — ${TAGLINE}`, W));
221
327
  if (extensionsText !== "")
222
- rows.push(truncateRow(extensionsText, W));
328
+ facts.push([BANNER_LABELS[2], extensionsText]);
329
+ if (facts.length > 0) {
330
+ rows.push("");
331
+ // The value column HANGS rather than truncating. The label costs
332
+ // columns the value used to have, and an extension list cut at the
333
+ // width would hide which extensions loaded — on the one screen whose
334
+ // job is to say what is loaded.
335
+ const indent = 2 + LABEL_STOP;
336
+ // a terminal too narrow to hold the label column at all: the room is
337
+ // what is left, floored at one column, and the assembled row is
338
+ // truncated as a unit so invariant ① holds at every width.
339
+ const room = Math.max(1, W - indent);
340
+ for (const [label, value] of facts) {
341
+ const lead = ` ${p.dim}${label}${p.reset}${" ".repeat(LABEL_STOP - label.length)}`;
342
+ const hang = " ".repeat(indent);
343
+ const lines = [];
344
+ let line = "";
345
+ for (const word of value.split(" ")) {
346
+ if (line === "")
347
+ line = word;
348
+ else if (displayWidth(`${line} ${word}`) <= room)
349
+ line += ` ${word}`;
350
+ else {
351
+ lines.push(line);
352
+ line = word;
353
+ }
354
+ }
355
+ if (line !== "")
356
+ lines.push(line);
357
+ for (const [i, l] of lines.entries()) {
358
+ const styled = `${i === 0 ? lead : hang}${truncateRow(l, room)}`;
359
+ rows.push(displayWidth(styled) - (i === 0 ? p.dim.length + p.reset.length : 0) <= W ? styled : truncateRow(`${hang}${l}`, W));
360
+ }
361
+ }
362
+ }
363
+ if (meta !== undefined && W >= 40) {
364
+ // R2/DC-2's device: the keys row is a list of independent clauses,
365
+ // so a narrow terminal drops whole clauses from the end rather than
366
+ // cutting one in half. `ctrl+r ex (+8)` teaches nothing.
367
+ const clauses = BANNER_KEYS.split(" \u00b7 ");
368
+ let keys = clauses[0];
369
+ for (let n = clauses.length; n > 1; n -= 1) {
370
+ const row = clauses.slice(0, n).join(" \u00b7 ");
371
+ if (displayWidth(row) <= W - 2) {
372
+ keys = row;
373
+ break;
374
+ }
375
+ }
376
+ rows.push("", ` ${p.dim}${truncateRow(keys, W - 2)}${p.reset}`);
377
+ }
223
378
  if (W >= 40 && H >= 20 && resume.length > 0) {
224
379
  rows.push("", ...renderResumeList(resume, W, now));
225
380
  }
@@ -259,7 +414,7 @@ function titleCut(text, max) {
259
414
  export function renderResumeList(metas, W, now) {
260
415
  if (metas.length === 0)
261
416
  return [];
262
- const rows = [" resume"];
417
+ const rows = [" resume"]; // R2: the ONE fold/segment mark (§4.2)
263
418
  const whens = metas.map((m) => relativeTime(m.updatedAt, now));
264
419
  const metaTexts = metas.map((m) => `${m.events} events · ${m.runs} runs`);
265
420
  const metaW = Math.max(...metaTexts.map((t) => t.length));