@vincemakes/kiso-tui-cells 0.16.3 → 0.16.5

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.
@@ -262,7 +262,7 @@ export interface PanelView {
262
262
  /** The fix hint per speaker (the v8 design §3.5 table). */
263
263
  readonly hint?: string;
264
264
  /** The options-phase status-left text — the CLI knows the context
265
- * (" run paused", the trust gate's line). */
265
+ * (" run paused", the trust gate's line). */
266
266
  readonly statusText: string;
267
267
  /** The ALWAYS-verbose args — the full command/content/diff. */
268
268
  readonly args: PanelArgs;
@@ -385,12 +385,13 @@ export declare function panelBlockLayout(view: PanelView, phase: PanelPhase, cur
385
385
  */
386
386
  export declare function panelLead(view: PanelView, phase: PanelPhase, cursor: number): string;
387
387
  /** The lead's plain text — the editor's reflow width (the line must
388
- * fit the lead + the box's walls). */
388
+ * fit the lead + the drawn cursor's own cell — R2 retired the box and
389
+ * its walls with it). */
389
390
  export declare function panelLeadPlain(view: PanelView, phase: PanelPhase, cursor: number): string;
390
391
  export declare function panelLeadWidth(view: PanelView, phase: PanelPhase, cursor: number): number;
391
392
  /** The status row's left text while the panel is up — the phase, not
392
393
  * the CLI's painting status (the compositor derives it from the panel
393
- * state; the " run paused" etc. ride the options phase). */
394
+ * state; the " run paused" etc. ride the options phase). */
394
395
  export declare function panelStatus(view: PanelView, phase: PanelPhase, cursor: number): string;
395
396
  /**
396
397
  * The status row's right-aligned hint — the v4 frame's line, verbatim.
@@ -205,21 +205,52 @@ function panelRuleText(view) {
205
205
  * trade to make: each option owns a row, a narrow window cuts LABELS,
206
206
  * and every choice stays reachable at every width the product survives.
207
207
  *
208
- * The unselected row carries the block's gutter and a two-space indent;
209
- * the selected row is the shared selectionBar, which spends its own two
210
- * cells of frame. Both build their span against W−2, so the digit column
211
- * does not shift as the bar walks — a column that moves per row reads as
212
- * damage, which is the R2 picker's finding, inherited.
208
+ * The unselected row is a two-space indent; the selected row is the
209
+ * shared selectionBar, which spends its own two cells of frame. Both
210
+ * build their span against W−2, so the digit column does not shift as
211
+ * the bar walks — a column that moves per row reads as damage, which is
212
+ * the R2 picker's finding, inherited.
213
+ *
214
+ * R2 — two changes. The unselected row carried the block's │ gutter: a
215
+ * gutter SCOPES a verbatim block (the args keep theirs), and an option
216
+ * list is not verbatim, so it draws a boundary the block already has a
217
+ * rule for. And the cursor now carries `→` as well as the bar (design
218
+ * §7.5) — the bar is the loud signal, the arrow is the one that
219
+ * survives a strip, which is law 1.3's test applied to a selection.
213
220
  */
214
- function panelOptionRow(option, n, selected, W) {
221
+ function panelOptionRow(option, n, selected, W, note, stop = 0) {
215
222
  const p = palette();
216
223
  const room = Math.max(1, W - 2);
217
- const plain = ` ${n} ${option.label}`;
218
- const text = cutLine(`${selected ? p.bold : ""}${escapeTerminal(plain)}${p.reset}`, room);
224
+ const plain = optionLead(option, n, selected);
225
+ const tail = note === undefined || note === ""
226
+ ? ""
227
+ : stop > 0
228
+ ? `${" ".repeat(Math.max(1, stop - visibleWidth(plain)))}${p.dim}${widthCut(escapeTerminal(note), Math.max(0, room - stop))}${p.reset}`
229
+ : `${p.dim} — ${escapeTerminal(note)}${p.reset}`;
230
+ const text = cutLine(`${selected ? p.bold : ""}${escapeTerminal(plain)}${p.reset}${tail}`, room);
219
231
  if (!selected)
220
- return `${p.dim}│${p.reset} ${text}`;
232
+ return ` ${text}`;
221
233
  return selectionBar(text, visibleWidth(text), W);
222
234
  }
235
+ /** The row's left span, PLAIN — written once so the column arithmetic
236
+ * and the row cannot disagree about how wide it is. */
237
+ function optionLead(option, n, selected) {
238
+ return `${selected ? "→" : " "} ${n} ${option.label}`;
239
+ }
240
+ /** R2 — the safer list's `why` column. Same rule as the ask panel's
241
+ * descriptions: computed over the WHOLE list so the column belongs to
242
+ * the list, and 0 (the em-dash fallback) when there is no room for it. */
243
+ function saferStop(options, W) {
244
+ // an empty list has no column to compute — `Math.max()` of nothing is
245
+ // -Infinity, which would sail through both guards below and return a
246
+ // negative stop
247
+ if (options.length === 0)
248
+ return 0;
249
+ const room = Math.max(1, W - 2);
250
+ const widest = Math.max(...options.map((o, i) => visibleWidth(optionLead({ kind: "allow", label: o.command }, i + 1, false))));
251
+ const stop = widest + 2;
252
+ return stop > Math.floor(room / 2) || room - stop < 18 ? 0 : stop;
253
+ }
223
254
  /** The block's rows — EXACTLY the preview's frame shape, the gutter at
224
255
  * the left edge (the preview's two-space mock indent is its own
225
256
  * styling; the real rows sit at column 1, like every tool cell).
@@ -229,15 +260,27 @@ export function panelBlockRows(view, phase, cursor, W, maxRows, note, safer) {
229
260
  }
230
261
  export function panelBlockLayout(view, phase, cursor, W, maxRows, note, safer) {
231
262
  const p = palette();
232
- const gutter = `${p.dim}│${p.reset} `;
263
+ // R2: the block's own PROSE rows (the risk line, the safer-options
264
+ // note, the affordance) take the two-space indent every other row in
265
+ // the block takes. The │ gutter stays where it means something — on
266
+ // the args, which are verbatim, and which is the whole distinction:
267
+ // a gutter SCOPES a quotation, it is not a left edge for a panel.
268
+ const gutter = " ";
233
269
  const rows = [];
234
- rows.push(`${gutter}${cutLine(panelRuleText(view), Math.max(1, W - 2))}`);
235
- rows.push(`${gutter}${cutLine(`${p.bold}${escapeTerminal(view.title)}${p.reset}`, Math.max(1, W - 2))}`);
270
+ // R2 the block opens and closes with the SAME dashed rule the
271
+ // composer uses. It used to open with the │ gutter, divide with a
272
+ // ─ run and close with a └ rule: three edge vocabularies inside one
273
+ // block, and none of them the composer's. A rule SEPARATES, a gutter
274
+ // SCOPES — the args keep their gutter because they are a verbatim
275
+ // block; everything that was drawing a boundary is one rule now.
276
+ rows.push(`${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`);
277
+ rows.push(` ${cutLine(panelRuleText(view), Math.max(1, W - 2))}`);
278
+ rows.push(` ${cutLine(`${p.bold}${escapeTerminal(view.title)}${p.reset}`, Math.max(1, W - 2))}`);
236
279
  // TUI2-R1.5 ⑤ (VD-11): the divider is a LABEL, not a design note. "the
237
280
  // full args — never truncated" is a sentence about the implementation,
238
281
  // addressed to whoever was building the panel; the human reading it
239
282
  // during an approval wants to know what the block below is.
240
- rows.push(`${cutLine(`${p.dim}─ args (full) ─${p.reset}`, Math.max(1, W - 2))}`);
283
+ rows.push("");
241
284
  // the args — the bounded block's body: fold, then cap. The └ cut is
242
285
  // ONE row (the W20 discipline): when the args exceed the budget, one
243
286
  // notice row carries the count and where the rest is (the event log).
@@ -252,7 +295,12 @@ export function panelBlockLayout(view, phase, cursor, W, maxRows, note, safer) {
252
295
  // they can also read in the event log is worth less than the row that
253
296
  // carries the choice. The args keep a floor of one row so the block
254
297
  // never claims to show what it is asking about and then shows nothing.
255
- const chrome = 5 +
298
+ const chrome =
299
+ // R2: SIX rows of frame, not five — the block opens with a rule now
300
+ // as well as closing with one, and the divider row became a blank.
301
+ // The count is the same shape it always was: every row the block
302
+ // spends on itself before the args and the list share what is left.
303
+ 6 +
256
304
  (phase === "options" && note !== undefined ? 1 : 0) +
257
305
  (view.riskHint !== undefined && view.riskHint !== "" ? 1 : 0) +
258
306
  (phase === "asking" ? 1 : 0) +
@@ -297,9 +345,13 @@ export function panelBlockLayout(view, phase, cursor, W, maxRows, note, safer) {
297
345
  // out of would be a trap.
298
346
  if (phase === "safer" && safer !== undefined) {
299
347
  offset = rows.length;
348
+ // R2: the `why` takes a COLUMN rather than running on after an em
349
+ // dash — the commands are what is being chosen between, and they
350
+ // only scan when they all start and end at the same columns.
351
+ const stop = saferStop(safer.options, W);
300
352
  for (let i = 0; i < safer.options.length; i += 1) {
301
353
  const o = safer.options[i];
302
- rows.push(panelOptionRow({ kind: "allow", label: `${o.command} — ${o.why}` }, i + 1, i === safer.cursor, W));
354
+ rows.push(panelOptionRow({ kind: "allow", label: o.command }, i + 1, i === safer.cursor, W, o.why, stop));
303
355
  }
304
356
  rows.push(panelOptionRow({ kind: "deny", label: SAFER_BACK }, safer.options.length + 1, safer.cursor === safer.options.length, W));
305
357
  }
@@ -336,7 +388,7 @@ export function panelBlockLayout(view, phase, cursor, W, maxRows, note, safer) {
336
388
  // else in the product, so a CAPPED panel emitted two elbow rows in a
337
389
  // row meaning entirely different things. The rule reads as an edge,
338
390
  // and the cut notice above it reads as a notice.
339
- rows.push(`${p.dim}\u2514${"\u2500".repeat(Math.max(0, W - 1))}${p.reset}`);
391
+ rows.push(`${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`);
340
392
  return { rows, ...layout };
341
393
  }
342
394
  /**
@@ -354,13 +406,24 @@ export function panelLead(view, phase, cursor) {
354
406
  const p = palette();
355
407
  if (phase === "amend")
356
408
  return `${p.dim}amend› ${p.reset}`;
357
- return `${p.dim}${PANEL_IDLE_LEAD}${p.reset}`;
409
+ // R2: an EMPTY lead emits no bytes at all — `dim + reset` around
410
+ // nothing is eight bytes on the composer row of every frame a panel
411
+ // is up, and the row it wraps has no content to style.
412
+ return PANEL_IDLE_LEAD === "" ? "" : `${p.dim}${PANEL_IDLE_LEAD}${p.reset}`;
358
413
  }
359
- /** The composer's lead while a selection list owns the keys — the quiet
360
- * chevron, not a prompt for input that is not being asked for. */
361
- const PANEL_IDLE_LEAD = " ";
414
+ /** The composer's lead while a selection list owns the keys.
415
+ *
416
+ * R2: EMPTY. It was a quiet chevron, on the argument that it is "not a
417
+ * prompt for input that is not being asked for" — but the composer
418
+ * dropped its own chevron this round (the cursor sits at column one),
419
+ * so the panel would have been the one surface reintroducing the glyph
420
+ * the rest of the product just removed. The NAMED leads stay: `amend›`
421
+ * and the pick panel's `1-4>` say where the keystrokes go, which is
422
+ * information rather than decoration. */
423
+ const PANEL_IDLE_LEAD = "";
362
424
  /** The lead's plain text — the editor's reflow width (the line must
363
- * fit the lead + the box's walls). */
425
+ * fit the lead + the drawn cursor's own cell — R2 retired the box and
426
+ * its walls with it). */
364
427
  export function panelLeadPlain(view, phase, cursor) {
365
428
  return phase === "amend" ? "amend› " : PANEL_IDLE_LEAD;
366
429
  }
@@ -369,20 +432,25 @@ export function panelLeadWidth(view, phase, cursor) {
369
432
  }
370
433
  /** The status row's left text while the panel is up — the phase, not
371
434
  * the CLI's painting status (the compositor derives it from the panel
372
- * state; the " run paused" etc. ride the options phase). */
435
+ * state; the " run paused" etc. ride the options phase). */
436
+ // R2 (design §4, the ⏸ ruling): a panel that is WAITING ON A HUMAN says
437
+ // so with the one mark that means it. `▸` is the checklist's "the
438
+ // current one" — a mark meaning two things is worse than two marks
439
+ // (law 4.2), and the thing this row has to convey is not "here" but
440
+ // "nothing moves until you answer".
373
441
  export function panelStatus(view, phase, cursor) {
374
442
  // TUI2-R3v2 ③: the frames' own words — what the panel is doing, and
375
443
  // (in the safer list) what it did.
376
444
  if (phase === "asking")
377
- return "\u25b8 asked the model for safer options";
445
+ return "\u23f8 asked the model for safer options";
378
446
  if (phase === "safer")
379
- return "\u25b8 asked the model for safer options";
447
+ return "\u23f8 asked the model for safer options";
380
448
  // TUI2-R3v2 ①: the typed phase says where the words GO. "the words ride
381
449
  // the verdict" described the plumbing to whoever wrote it; the human
382
450
  // typing needs to know the model will read this and answer with a new
383
451
  // call — which is what the v4 frame says, in those words.
384
452
  if (phase === "amend")
385
- return " your note goes to the model — it will propose a new call";
453
+ return " your note goes to the model — it will propose a new call";
386
454
  return view.statusText;
387
455
  }
388
456
  /**
@@ -425,32 +493,60 @@ export function panelAffordance(view, phase, cursor, safer) {
425
493
  export function pickBlockRows(view, state, W, maxRows) {
426
494
  const p = palette();
427
495
  const spec = view.pick;
428
- const gutter = `${p.dim}\u2502${p.reset} `;
429
- const rows = [];
496
+ const rows = [`${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`]; // R2: the same rule the composer and the other panels use
430
497
  const room = Math.max(1, W - 2);
431
- rows.push(`${gutter}${cutLine(`${p.bold}${escapeTerminal(spec.header.split(" \u2014 ")[0] ?? spec.header)}${p.reset}${p.dim}${escapeTerminal(spec.header.slice((spec.header.split(" \u2014 ")[0] ?? "").length))}${p.reset}`, room)}`);
498
+ rows.push(` ${cutLine(`${p.bold}${escapeTerminal(spec.header.split(" \u2014 ")[0] ?? spec.header)}${p.reset}${p.dim}${escapeTerminal(spec.header.slice((spec.header.split(" \u2014 ")[0] ?? "").length))}${p.reset}`, room)}`);
432
499
  if (spec.options.length === 0) {
433
500
  // the honest empty state \u2014 the caller's own copy, verbatim
434
- rows.push(`${gutter}${cutLine(`${p.dim} ${escapeTerminal(spec.emptyNote ?? "no options")}${p.reset}`, room)}`);
501
+ rows.push(` ${cutLine(`${p.dim} ${escapeTerminal(spec.emptyNote ?? "no options")}${p.reset}`, room)}`);
435
502
  }
436
503
  else {
437
- // the budget: the header, the t row, the affordance and the rule
438
- const budget = Math.max(1, maxRows - 4);
504
+ // the budget: the OPENING rule, the header, the t row, the
505
+ // affordance and the CLOSING rule — five, not four. R2 added the
506
+ // opening rule to this block and bumped the ask panel (5→6) and the
507
+ // approval panel (5→6) to pay for it, and missed this one: the
508
+ // block ran two rows over its budget, and two rows of committed
509
+ // content were scrolled irreversibly into the scrollback every time
510
+ // `/model` opened on a tight screen. The `+N more` row is a sixth
511
+ // when it appears, so it is paid for too.
512
+ const chrome = 5 + (spec.options.length > Math.min(Math.max(1, maxRows - 5), PICK_MAX) ? 1 : 0);
513
+ const budget = Math.max(1, maxRows - chrome);
439
514
  const shown = spec.options.slice(0, Math.min(budget, PICK_MAX));
515
+ // R2: the note takes a COLUMN, not three spaces after a label of
516
+ // whatever length this row happened to have, and the cursor row
517
+ // wears the bar and the arrow like every other list in the
518
+ // product. This panel was the last one still saying "selected"
519
+ // with bold alone.
520
+ const lead = (o, i, cursor) => `${cursor ? "\u2192" : " "} ${i + 1} ${escapeTerminal(o.label)}`;
521
+ const widest = Math.max(...shown.map((o, i) => visibleWidth(lead(o, i, false))));
522
+ const stop = shown.some((o) => o.note !== undefined) && widest + 2 <= Math.floor(room / 2) && room - widest - 2 >= 18 ? widest + 2 : 0;
440
523
  for (let i = 0; i < shown.length; i += 1) {
441
524
  const o = shown[i];
442
525
  const mark = i === state.cursor && state.phase === "options";
443
- const head = `${mark ? p.bold : ""} ${i + 1} ${escapeTerminal(o.label)}${mark ? p.reset : ""}`;
444
- const note = o.note === undefined ? "" : `${p.dim} ${escapeTerminal(o.note)}${p.reset}`;
445
- rows.push(`${gutter}${cutLine(`${head}${note}`, room)}`);
526
+ const plain = lead(o, i, mark);
527
+ const head = `${mark ? p.bold : ""}${plain}${mark ? p.reset : ""}`;
528
+ const note = o.note === undefined
529
+ ? ""
530
+ : stop > 0
531
+ ? `${" ".repeat(Math.max(1, stop - visibleWidth(plain)))}${p.dim}${widthCut(escapeTerminal(o.note), Math.max(0, room - stop))}${p.reset}`
532
+ : `${p.dim} ${escapeTerminal(o.note)}${p.reset}`;
533
+ const text = cutLine(`${head}${note}`, room);
534
+ // ONE space, like the approval and ask panels: the bar spends a
535
+ // leading cell of its own, so a two-space unselected prefix
536
+ // moves the digit column by one as the cursor walks — the exact
537
+ // "a column that moves per row reads as damage" this file
538
+ // quotes twice as its standard.
539
+ rows.push(mark ? selectionBar(text, visibleWidth(text), W) : ` ${text}`);
446
540
  }
447
541
  if (spec.options.length > shown.length) {
448
- rows.push(`${gutter}${cutLine(`${p.dim} \u2514 +${spec.options.length - shown.length} more \u2014 /model <name> takes any of them${p.reset}`, room)}`);
542
+ rows.push(` ${cutLine(`${p.dim} \u2514 +${spec.options.length - shown.length} more \u2014 /model <name> takes any of them${p.reset}`, room)}`);
449
543
  }
450
544
  }
451
- rows.push(`${gutter}${cutLine(`${state.phase === "custom" ? p.bold : ""} t ${p.reset}${p.dim}${escapeTerminal(spec.typeHint)}${p.reset}`, room)}`);
452
- rows.push(`${gutter}${p.dim}${cutLine(pickAffordance(state), room)}${p.reset}`);
453
- rows.push(`${p.dim}\u2514${"\u2500".repeat(Math.max(0, W - 1))}${p.reset}`);
545
+ const typing = state.phase === "custom";
546
+ const tText = cutLine(`${typing ? p.bold : ""}${typing ? "\u2192" : " "} t ${p.reset}${p.dim}${escapeTerminal(spec.typeHint)}${p.reset}`, room);
547
+ rows.push(typing ? selectionBar(tText, visibleWidth(tText), W) : ` ${tText}`);
548
+ rows.push(` ${p.dim}${cutLine(pickAffordance(state), room)}${p.reset}`);
549
+ rows.push(`${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`);
454
550
  return rows;
455
551
  }
456
552
  /** The digits are the keys, so the list the panel offers is bounded by
@@ -378,20 +378,27 @@ export declare function selectionBar(styled: string, visible: number, W: number)
378
378
  /**
379
379
  * R2 — the composer's rails, and the ONE edge vocabulary.
380
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.
381
+ * R3 (owner, 2026-08-27): the rule is a SOLID hairline (`\u2500`), not
382
+ * the dashed `\u254c` R2 shipped, and it is solid EVERYWHERE the
383
+ * composer, every panel's open and close, the band headers and the
384
+ * markdown rule. One line, one weight, no exceptions to remember.
385
+ *
386
+ * W6 turned two \u254c dotted rows into a rounded box, reasoning that
387
+ * "the box already says input lives here". That is reversed here, and
388
+ * the reason is not taste: a rule is a DELIMITER and a box is a
389
+ * CONTAINER, and the screen was carrying six edge vocabularies at once
390
+ * (this box, the panel's \u2502 gutter and \u2514\u2500\u2500 tail, the
391
+ * diff gutter, the quote's \u258f, the table's rails, the markdown
392
+ * rule). ONE rule replaces the ones that SEPARATE; the \u2502 gutter
393
+ * survives where it SCOPES.
388
394
  *
389
395
  * 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.
396
+ * keyed on H \u2212 4 is untouched, and the input row gains the two
397
+ * columns the walls were taking.
392
398
  */
393
399
  export declare function boxTop(W: number): string;
394
- /** W6 — the box: the chrome's bottom rail. */
400
+ /** R2 — the same rule below. Named for its POSITION, not its shape, so
401
+ * the compositor's two call sites did not have to move. */
395
402
  export declare function boxBottom(W: number): string;
396
403
  /** The terminal label + rhythm gap (the pipe path's v2c bytes — the
397
404
  * exact render the passthrough needs). */
@@ -22,7 +22,7 @@ import { displayWidth, visibleWidth } from "./width.js";
22
22
  // KEY_BINDINGS). strings.js imports only render/width here, so this edge
23
23
  // adds no cycle.
24
24
  import { displayVerb } from "./strings.js";
25
- import { bannerLines, escapeTerminal, foldThinking, foldResult, renderTerminalGap, renderToolSummary, toolTarget, kUnit, palette, } from "./render.js";
25
+ import { bannerLines, breathFrame, escapeTerminal, foldThinking, foldResult, renderTerminalGap, renderToolSummary, toolTarget, kUnit, palette, } from "./render.js";
26
26
  // TUI2-MD: the markdown renderer's surface reaches the tui through this
27
27
  // module (the tui's components shim re-exports it) — one import edge,
28
28
  // and it points one way: md.ts measures with the width authority, never
@@ -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
@@ -226,11 +231,18 @@ class UserMessage {
226
231
  content.push(row);
227
232
  }
228
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
+ //
229
241
  // displayWidth stays the padding authority (never `length`): a CJK
230
242
  // row is two cells per character and pads by cells.
231
- const inner = content.length === 0 ? 0 : Math.max(...content.map((r) => displayWidth(r)));
243
+ const inner = chipW;
232
244
  for (const row of content) {
233
- rows.push(`${p.rv} ${row}${" ".repeat(inner - displayWidth(row))} ${p.rvEnd}`);
245
+ rows.push(`${p.rv} ${row}${" ".repeat(Math.max(0, inner - displayWidth(row)))} ${p.rvEnd}`);
234
246
  }
235
247
  if (!truncated)
236
248
  return rows;
@@ -275,19 +287,57 @@ class ThinkingFold {
275
287
  this.cell = cell;
276
288
  }
277
289
  render(W, _ctx) {
290
+ const p = palette();
278
291
  const block = this.cell.text;
279
292
  const trimmed = escapeTerminal(block.trim());
280
- // the SHORT branch is width-aware TOO: the ≤100 short-circuit was
281
- // W-blind — a short block at a narrow width returned the line
282
- // UNFOLDED and tripped invariant (the crash class still live on
283
- // npm for short /think blocks after a resize).
284
- if (trimmed.length <= 100)
285
- return [`${palette().dim}⋯${widthCut(trimmed, Math.max(1, W - 1))}${palette().reset}`];
286
- const suffix = ` (${block.length} chars · /think)`;
287
- const slice = Math.max(1, W - 1 - suffix.length);
288
- 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)];
289
327
  }
290
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
+ }
291
341
  /** Fold a line's CONTENT at W−2 and prefix EVERY row with the gutter
292
342
  * (W2: a wrapped tool row keeps its state mark — the left edge alone
293
343
  * distinguishes the states at --plain; the UserMessage rail precedent,
@@ -508,7 +558,7 @@ class ToolExecution {
508
558
  // list of calls, not a body of output.
509
559
  const r = c.rolled;
510
560
  const counts = exploreCounts(parts);
511
- const head = `${p.bold}✓${p.reset} explored ${p.bold}${counts}${p.reset}`;
561
+ const head = ` explored ${p.bold}${counts}${p.reset}`; // R2: no tick
512
562
  const tail = ` (${r.elapsed}s)`;
513
563
  const room = W - visibleWidth(head) - tail.length;
514
564
  const affordance = " · ctrl+r lists them";
@@ -523,7 +573,7 @@ class ToolExecution {
523
573
  // W15 expand history — the head's commit captures it).
524
574
  const r = c.rolled;
525
575
  const noun = ROLLUP_NOUN[c.name] ?? "calls";
526
- 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
527
577
  const shown = r.targets.slice(0, 3);
528
578
  if (shown.length > 0)
529
579
  out.push(` ${p.dim}${CUT_ROW}${escapeTerminal(shown.join(" · "))}${p.reset}`);
@@ -541,7 +591,7 @@ class ToolExecution {
541
591
  // names the decider; a human denial (no decidedBy) has no tail.
542
592
  if (c.reason !== null) {
543
593
  const by = attribution(c);
544
- 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);
545
595
  out.push(...toolBlockBody(c, W));
546
596
  return out;
547
597
  }
@@ -578,7 +628,14 @@ class ToolExecution {
578
628
  // the semantics. TUI2-R1.5 pin 4: and the parts give way in a
579
629
  // PINNED ORDER, rather than whichever happened to be last.
580
630
  const text = settledHeadText(verbCol, escapeTerminal(toolTargetOf(c)), meta, approvedBy, elapsed, W - 2 - (hidden === null ? 0 : SUFFIX_MIN));
581
- 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}`];
582
639
  out[0] = appendSuffix(out[0], expandSuffix(hidden, W - visibleWidth(out[0])));
583
640
  out.push(...toolBlockBody(c, W));
584
641
  return out;
@@ -600,7 +657,14 @@ class ToolExecution {
600
657
  // leaves; the duration then rides the row, always legible.
601
658
  const elapsed = c.startedAt !== null ? Math.max(1, Math.round((ctx.now - c.startedAt) / 1000)) : 1;
602
659
  const dur = ` · ${elapsed}s`;
603
- const out = gutterCut(`${p.bold}${SPINNER[ctx.spinnerI % SPINNER.length]}${p.reset} `, `${verbCol} ${liveTarget(c)}`, Math.max(4, W - dur.length));
660
+ // R3 (design §5.2): a running command BREATHES one glyph, seven
661
+ // greys, bottoming out on the ground's dim token (§2.2 applies
662
+ // mid-animation, not just at rest). The quadrant spinner it
663
+ // replaces ROTATED, which §5.3 forbids for a call whose duration
664
+ // cannot be predicted: a turning mark implies progress the
665
+ // product does not have. With no ground the breath freezes to a
666
+ // static `●` and says the same thing more quietly.
667
+ const out = gutterCut(`${breathFrame(ctx.spinnerI)} `, `${verbCol} ${liveTarget(c)}`, Math.max(4, W - dur.length));
604
668
  out[0] = `${out[0]}${p.dim}${dur}${p.reset}`;
605
669
  out.push(...toolBlockBody(c, W));
606
670
  return out;
@@ -912,26 +976,26 @@ export function turnFold(t, W) {
912
976
  const meta = parts.join(" · ");
913
977
  const words = escapeTerminal(t.words);
914
978
  if (words === "") {
915
- const row = `${p.bold}▞${p.reset} ${meta}`;
916
- return visibleWidth(row) <= W ? [row] : [`${p.bold}▞${p.reset} ${widthCut(meta, Math.max(1, W - 3))}…`]; // a wordless turn folds to the W14 shape
979
+ const row = `${p.bold}✦${p.reset} ${meta}`;
980
+ return visibleWidth(row) <= W ? [row] : [`${p.bold}✦${p.reset} ${widthCut(meta, Math.max(1, W - 3))}…`]; // a wordless turn folds to the W14 shape
917
981
  }
918
982
  // A9 (ruling R2, mock A): the user chip rides the fold — the human's
919
983
  // words LEAD the one line, the same SGR-7 bracket as the live user
920
984
  // row (#16f, side pads included). The words take the fold's width
921
- // budget: W − the gutter (" " = 2) − the join (" · " = 3) − the
985
+ // budget: W − the gutter (" " = 2) − the join (" · " = 3) − the
922
986
  // chip's side pads (2) − the cut-tail reserve (1, the "…") − the
923
987
  // metadata's own width — the metadata survives, the words width-cut
924
988
  // at the end with the honest "…" (the "…" alone is the honest floor:
925
989
  // the words were there, cut).
926
- const budget = Math.max(0, W - visibleWidth(`▞ ${meta}`) - 6);
990
+ const budget = Math.max(0, W - visibleWidth(`✦ ${meta}`) - 6);
927
991
  const cut = visibleWidth(words) > budget ? `${widthCut(words, budget)}…` : words;
928
- const row = `${p.bold}▞${p.reset} ${p.rv} ${cut} ${p.rvEnd} · ${meta}`;
992
+ const row = `${p.bold}✦${p.reset} ${p.rv} ${cut} ${p.rvEnd} · ${meta}`;
929
993
  if (visibleWidth(row) <= W)
930
994
  return [row];
931
995
  // the last resort: the METADATA gives way — the words hold their
932
996
  // budget, the meta cuts with the honest "…"; invariant ① never trips
933
997
  // at ANY width (a degenerate W's fold is a cut, never a crash).
934
- return [`${p.bold}▞${p.reset} ${p.rv} ${cut} ${p.rvEnd} · ${widthCut(meta, Math.max(1, W - 8 - visibleWidth(cut)))}…`];
998
+ return [`${p.bold}✦${p.reset} ${p.rv} ${cut} ${p.rvEnd} · ${widthCut(meta, Math.max(1, W - 8 - visibleWidth(cut)))}…`];
935
999
  }
936
1000
  // ---- the bounded-block flow contract (W7, W8, W10) ----
937
1001
  /** The caps — screen rows counted AFTER the fold, at the current width
@@ -1288,8 +1352,11 @@ class Banner {
1288
1352
  }
1289
1353
  render(W, ctx) {
1290
1354
  const p = palette();
1291
- const rows = bannerLines(W, ctx.height, this.cell.version, this.cell.extensionsText, this.cell.resume, ctx.now, this.cell.meta);
1292
- return rows.map((r) => `${p.dim}${r}${p.reset}`);
1355
+ // R2: NO blanket dim. bannerLines styles itself the labels are
1356
+ // dim, the values are ink — and wrapping the whole thing in dim
1357
+ // made the answers as faint as the questions.
1358
+ void p;
1359
+ return bannerLines(W, ctx.height, this.cell.version, this.cell.extensionsText, this.cell.resume, ctx.now, this.cell.meta);
1293
1360
  }
1294
1361
  }
1295
1362
  /** W20 — the task block's fixed-window height: the whole live block
@@ -1373,7 +1440,7 @@ class Checklist {
1373
1440
  const fixed = done
1374
1441
  ? `task done · ${plural(items.length, "item")} · ${formatDuration(durationSeconds)}`
1375
1442
  : `task · ${plural(items.length, "item")} · ${active.length} active · ${doneCount} done`;
1376
- const header = `${p.bold}▞${p.reset} ${escapeTerminal(fixed + tail)}`;
1443
+ const header = `${p.bold}✦${p.reset} ${escapeTerminal(fixed + tail)}`;
1377
1444
  // the FULL-list forms: SETTLED — the durable record (the fold is
1378
1445
  // fine — committed content wraps naturally) — and the LIVE ctrl+r
1379
1446
  // toggle (the header CUTS — the block stays one window high; the
@@ -1475,30 +1542,48 @@ export function widthCut(text, max) {
1475
1542
  */
1476
1543
  export function selectionBar(styled, visible, W) {
1477
1544
  const p = palette();
1478
- const inner = styled.replaceAll(p.reset, `${p.reset}${p.rv}`);
1545
+ // R2 (design §2.1 — nothing dim ever sits on the wash): the bar IS a
1546
+ // wash. A dim span inside it renders grey-on-grey — 3.91:1 on the
1547
+ // light ground, under the 4.5 floor — and the dim spans are exactly
1548
+ // the descriptions and the metadata, i.e. the half of the row the
1549
+ // selection was supposed to help you read. Dim is dropped INSIDE the
1550
+ // bar and nowhere else; the same row unselected keeps it.
1551
+ const inner = (p.dim === "" ? styled : styled.replaceAll(p.dim, "")).replaceAll(p.reset, `${p.reset}${p.rv}`);
1479
1552
  return `${p.rv} ${inner}${" ".repeat(Math.max(0, W - visible - 2))} ${p.rvEnd}`;
1480
1553
  }
1481
1554
  /**
1482
1555
  * R2 — the composer's rails, and the ONE edge vocabulary.
1483
1556
  *
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.
1557
+ * R3 (owner, 2026-08-27): the rule is a SOLID hairline (`\u2500`), not
1558
+ * the dashed `\u254c` R2 shipped, and it is solid EVERYWHERE the
1559
+ * composer, every panel's open and close, the band headers and the
1560
+ * markdown rule. One line, one weight, no exceptions to remember.
1561
+ *
1562
+ * W6 turned two \u254c dotted rows into a rounded box, reasoning that
1563
+ * "the box already says input lives here". That is reversed here, and
1564
+ * the reason is not taste: a rule is a DELIMITER and a box is a
1565
+ * CONTAINER, and the screen was carrying six edge vocabularies at once
1566
+ * (this box, the panel's \u2502 gutter and \u2514\u2500\u2500 tail, the
1567
+ * diff gutter, the quote's \u258f, the table's rails, the markdown
1568
+ * rule). ONE rule replaces the ones that SEPARATE; the \u2502 gutter
1569
+ * survives where it SCOPES.
1491
1570
  *
1492
1571
  * 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.
1572
+ * keyed on H \u2212 4 is untouched, and the input row gains the two
1573
+ * columns the walls were taking.
1495
1574
  */
1496
1575
  export function boxTop(W) {
1497
- return `\x1b[2m\u256d${"\u2500".repeat(Math.max(0, W - 2))}\u256e\x1b[0m`;
1576
+ // R3: the palette's dim, not a hardcoded SGR 2 — `dim` is an absolute
1577
+ // grey once the ground is known, and a rail that hardcodes the
1578
+ // attribute would be the one chrome row not obeying the table.
1579
+ const p = palette();
1580
+ return `${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`;
1498
1581
  }
1499
- /** W6 — the box: the chrome's bottom rail. */
1582
+ /** R2 — the same rule below. Named for its POSITION, not its shape, so
1583
+ * the compositor's two call sites did not have to move. */
1500
1584
  export function boxBottom(W) {
1501
- return `\x1b[2m\u2570${"\u2500".repeat(Math.max(0, W - 2))}\u256f\x1b[0m`;
1585
+ const p = palette();
1586
+ return `${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`;
1502
1587
  }
1503
1588
  /** The terminal label + rhythm gap (the pipe path's v2c bytes — the
1504
1589
  * exact render the passthrough needs). */
package/dist/index.d.ts CHANGED
@@ -14,7 +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_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";
17
+ export { bannerLines, COLOR_OFF, COLOR_DARK, COLOR_LIGHT, COLOR_NEUTRAL, COLOR_ON, currentGround, setGround, escapeTerminal, foldResult, foldThinking, kUnit, palette, relativeTime, renderResumeList, MOTION_FRAMES, TWINKLE, breathFrame, twinkleFrame, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, type Palette, type ResumeMeta, } from "./render.js";
18
18
  /** DC-3 — the ground: is the terminal light or dark. Pure; see the
19
19
  * module comment for why `unknown` is a result and not a failure. */
20
20
  export { groundFrom, parseOscColor, relativeLuminance, resolveGround, type Ground, type GroundInputs, type Rgb } from "./ground.js";
package/dist/index.js CHANGED
@@ -28,7 +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_DARK, COLOR_LIGHT, COLOR_NEUTRAL, COLOR_ON, currentGround, setGround, 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, MOTION_FRAMES, TWINKLE, breathFrame, twinkleFrame, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, } from "./render.js";
32
32
  /** DC-3 — the ground: is the terminal light or dark. Pure; see the
33
33
  * module comment for why `unknown` is a result and not a failure. */
34
34
  export { groundFrom, parseOscColor, relativeLuminance, resolveGround } from "./ground.js";
package/dist/md.js CHANGED
@@ -258,7 +258,7 @@ function blockBody(b, W) {
258
258
  // R2: the dashed rule, at the block's own width. The 28 was a
259
259
  // guess that read as a short line rather than a divider, and ─
260
260
  // belonged to the box vocabulary this round is collapsing.
261
- return [`${p.dim}${"\u254c".repeat(Math.max(1, W))}${p.reset}`];
261
+ return [`${p.dim}${"\u2500".repeat(Math.max(1, W))}${p.reset}`];
262
262
  case "fence-open":
263
263
  // E2: the RAIL, not a gutter. A block drawn with ``` is still a
264
264
  // fenced block when a human selects it and pastes it somewhere
@@ -282,8 +282,10 @@ function blockBody(b, W) {
282
282
  // DC-3: a fenced BODY carries no colour token. It used to take
283
283
  // `code` — 1.54:1 on a white terminal, applied to whole blocks,
284
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.
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.
287
289
  return foldLineWidth(src.slice(indent.length), W - visibleWidth(gutter), indent).map((r) => `${gutter}${r}`);
288
290
  }
289
291
  case "quote": {
package/dist/render.d.ts CHANGED
@@ -82,11 +82,22 @@ export interface Palette {
82
82
  /**
83
83
  * DC-3 — one table per ground.
84
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.
85
+ * R3 (owner, 2026-08-27) — `dim` is ABSOLUTE once the ground is known,
86
+ * and design.md §2's table always said so: light `243` `#767676` at
87
+ * 4.54:1, dark `246` `#949494` at 5.50:1 (both re-measured here).
88
+ *
89
+ * DC-3 shipped SGR 2 instead, on the argument that an attribute adapts
90
+ * to the ground while an absolute grey asserts one. That argument is
91
+ * right about what SGR 2 IS and wrong about what it MEASURES: a
92
+ * terminal renders it as a fraction of its own foreground, and on Apple
93
+ * Terminal's light profile that lands well under the 4.5:1 floor — the
94
+ * labels, the keys row and the status row were all reported unreadable
95
+ * in real use. An attribute that adapts to an unknown ratio is not a
96
+ * contrast guarantee; the table's measured value is.
97
+ *
98
+ * The UNKNOWN ground keeps SGR 2, because §3.1 forbids an absolute
99
+ * foreground in a palette that has not established a background — the
100
+ * attribute is exactly the "correct on any ground" degradation there.
90
101
  */
91
102
  export declare const COLOR_NEUTRAL: Palette;
92
103
  export declare const COLOR_LIGHT: Palette;
@@ -151,6 +162,32 @@ export declare function renderTerminalGap(statusLine: string | null): string;
151
162
  * 40 columns skips the logo + the blank entirely — only the info rows.
152
163
  * Pure.
153
164
  */
165
+ /**
166
+ * design.md §5.2 — THE TWO CYCLES, built. Seven frames each, walked at
167
+ * the existing 200ms spinner cadence, so a waiting screen's byte volume
168
+ * and frame rate are exactly what they were.
169
+ *
170
+ * §5.3 is why neither rotates: "a breath says alive; a turn says
171
+ * counting". A call whose duration cannot be predicted must not wear a
172
+ * mark that implies progress it does not have.
173
+ */
174
+ /** The THINKING twinkle — glyphs only, no colour at all, so it survives
175
+ * NO_COLOR and any ground intact. §4.1: it settles onto `✦`, which is
176
+ * the same mark the collapsed segment keeps, so nothing new appears at
177
+ * the transition. Every glyph is in Menlo and absent from Apple Color
178
+ * Emoji (§6.1's test, run). */
179
+ export declare const TWINKLE: readonly ["✧", "✦", "✶", "✸", "✺", "✸", "✦"];
180
+ /** The breath's frame: `●` at the step's grey, for the CURRENT ground.
181
+ * With no ground — or under NO_COLOR — it freezes to a static `●`,
182
+ * because a brightness ramp needs a background to be a ramp against and
183
+ * §3.1 forbids guessing one. The glyph never changes, so the freeze
184
+ * degrades the motion and never the meaning. */
185
+ export declare function breathFrame(step: number): string;
186
+ /** The twinkle's frame — pure glyph, no palette involved. */
187
+ export declare function twinkleFrame(step: number): string;
188
+ /** Both cycles are seven frames, so ONE counter walks them and the two
189
+ * marks stay in step on a screen showing both. */
190
+ export declare const MOTION_FRAMES = 7;
154
191
  export declare const TAGLINE = "the coding agent that survives kill -9";
155
192
  /** R2 — what the opening knows about the session. Optional because the
156
193
  * off-TTY caller prints a banner before a model is bound. */
package/dist/render.js CHANGED
@@ -7,19 +7,51 @@
7
7
  */
8
8
  import { charWidth, displayWidth } from "./width.js";
9
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 });
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, dim = BASE.dim) => ({ ...BASE, red, dim, wash, washEnd, code: wash });
11
32
  /**
12
33
  * DC-3 — one table per ground.
13
34
  *
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.
35
+ * R3 (owner, 2026-08-27) — `dim` is ABSOLUTE once the ground is known,
36
+ * and design.md §2's table always said so: light `243` `#767676` at
37
+ * 4.54:1, dark `246` `#949494` at 5.50:1 (both re-measured here).
38
+ *
39
+ * DC-3 shipped SGR 2 instead, on the argument that an attribute adapts
40
+ * to the ground while an absolute grey asserts one. That argument is
41
+ * right about what SGR 2 IS and wrong about what it MEASURES: a
42
+ * terminal renders it as a fraction of its own foreground, and on Apple
43
+ * Terminal's light profile that lands well under the 4.5:1 floor — the
44
+ * labels, the keys row and the status row were all reported unreadable
45
+ * in real use. An attribute that adapts to an unknown ratio is not a
46
+ * contrast guarantee; the table's measured value is.
47
+ *
48
+ * The UNKNOWN ground keeps SGR 2, because §3.1 forbids an absolute
49
+ * foreground in a palette that has not established a background — the
50
+ * attribute is exactly the "correct on any ground" degradation there.
19
51
  */
20
52
  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");
53
+ export const COLOR_LIGHT = withWash("\x1b[48;5;255m", "\x1b[49m", "\x1b[38;5;124m", "\x1b[38;5;243m");
54
+ export const COLOR_DARK = withWash("\x1b[48;5;236m", "\x1b[49m", "\x1b[38;5;173m", "\x1b[38;5;246m");
23
55
  /** The historical name — the palette for a colour TTY whose ground has
24
56
  * not been established. Unchanged in every byte except `code`, which
25
57
  * was the defect. */
@@ -190,6 +222,60 @@ export function renderTerminalGap(statusLine) {
190
222
  * 40 columns skips the logo + the blank entirely — only the info rows.
191
223
  * Pure.
192
224
  */
225
+ /**
226
+ * design.md §5.2 — THE TWO CYCLES, built. Seven frames each, walked at
227
+ * the existing 200ms spinner cadence, so a waiting screen's byte volume
228
+ * and frame rate are exactly what they were.
229
+ *
230
+ * §5.3 is why neither rotates: "a breath says alive; a turn says
231
+ * counting". A call whose duration cannot be predicted must not wear a
232
+ * mark that implies progress it does not have.
233
+ */
234
+ /** The THINKING twinkle — glyphs only, no colour at all, so it survives
235
+ * NO_COLOR and any ground intact. §4.1: it settles onto `✦`, which is
236
+ * the same mark the collapsed segment keeps, so nothing new appears at
237
+ * the transition. Every glyph is in Menlo and absent from Apple Color
238
+ * Emoji (§6.1's test, run). */
239
+ export const TWINKLE = ["\u2727", "\u2726", "\u2736", "\u2738", "\u273a", "\u2738", "\u2726"];
240
+ /** The COMMAND breath — brightness only, one glyph. The ramps bottom out
241
+ * EXACTLY on the ground's dim token (§2.2: "the floor is a floor,
242
+ * including mid-animation"): light ends at 243 (4.54:1 on white), dark
243
+ * at 246 (5.50:1 on #1e1e1e). Measured, not assumed. */
244
+ const BREATH_LIGHT = [232, 236, 240, 243, 240, 236, 232];
245
+ const BREATH_DARK = [255, 251, 248, 246, 248, 251, 255];
246
+ /** The breath's frame: `●` at the step's grey, for the CURRENT ground.
247
+ * With no ground — or under NO_COLOR — it freezes to a static `●`,
248
+ * because a brightness ramp needs a background to be a ramp against and
249
+ * §3.1 forbids guessing one. The glyph never changes, so the freeze
250
+ * degrades the motion and never the meaning. */
251
+ export function breathFrame(step) {
252
+ const p = palette();
253
+ const ramp = currentGround() === "light" ? BREATH_LIGHT : currentGround() === "dark" ? BREATH_DARK : null;
254
+ if (ramp === null || p.bold === "")
255
+ return "\u25cf";
256
+ return `\x1b[38;5;${ramp[step % ramp.length]}m\u25cf${p.reset}`;
257
+ }
258
+ /** The twinkle's frame — pure glyph, no palette involved. */
259
+ export function twinkleFrame(step) {
260
+ return TWINKLE[step % TWINKLE.length];
261
+ }
262
+ /** Both cycles are seven frames, so ONE counter walks them and the two
263
+ * marks stay in step on a screen showing both. */
264
+ export const MOTION_FRAMES = 7;
265
+ /** DC-18: the display-width prefix of PLAIN text. `widthCut` lives in
266
+ * components.ts, which imports this module — the dependency runs one
267
+ * way, so the four lines live here rather than inverting it. */
268
+ function plainCut(text, max) {
269
+ let w = 0;
270
+ let i = 0;
271
+ for (; i < text.length; i += 1) {
272
+ const cw = charWidth(text.codePointAt(i));
273
+ if (w + cw > max)
274
+ break;
275
+ w += cw;
276
+ }
277
+ return text.slice(0, i);
278
+ }
193
279
  export const TAGLINE = "the coding agent that survives kill -9";
194
280
  /**
195
281
  * R2 — the wordmark is retired (2026-08-27, the nineteen-screen review).
@@ -210,7 +296,11 @@ export const TAGLINE = "the coding agent that survives kill -9";
210
296
  /** R2 — the keys a first screen teaches. One dim row, and deliberately
211
297
  * NOT derived from KEY_BINDINGS: the sheet is the complete list and
212
298
  * this is the opening's five, chosen rather than generated. */
213
- const BANNER_KEYS = "esc interrupt · ctrl+c exit · / commands · ! bash · ctrl+r expand";
299
+ // R2: the keys row names bindings the product ACTUALLY has. The first
300
+ // draft advertised `! bash` — there is no bang passthrough in kiso and
301
+ // KEY_BINDINGS never had one, so the opening screen was teaching a key
302
+ // that does nothing. A first screen that lies is worse than a short one.
303
+ const BANNER_KEYS = "esc interrupt · ctrl+c exit · / commands · @ files · ? keys";
214
304
  /** R2 — the labels. Uppercase mono, dim, letter-spaced by the column
215
305
  * rather than by SGR: they mark sections and are never content. */
216
306
  const BANNER_LABELS = ["MODEL", "WORKSPACE", "EXTENSIONS"];
@@ -225,6 +315,13 @@ export function truncateRow(row, width) {
225
315
  const total = displayWidth(row);
226
316
  if (total <= width)
227
317
  return row;
318
+ // DC-18: a width too narrow to HOLD the marker gets a hard cut. The
319
+ // fixpoint below floors `cut` at 0 and then appends a 6-cell marker
320
+ // regardless, so every width ≤ 6 returned a row WIDER than the
321
+ // terminal — and invariant ① throws rather than truncating. A marker
322
+ // wider than the row it marks is not a marker.
323
+ if (width < 7)
324
+ return plainCut(row, Math.max(0, width));
228
325
  // iterate the marker to a fixpoint: the marker's width changes the
229
326
  // cut, the cut changes the hidden count the marker reports
230
327
  let marker = " (+0)";
@@ -256,7 +353,24 @@ export function truncateRow(row, width) {
256
353
  * resume list (BIG only, W5). Every row truncates at the terminal width
257
354
  * with a " (+N)" marker. Pure. */
258
355
  export function bannerLines(W, H, version, extensionsText, resume = [], now = Date.now(), meta) {
259
- const rows = [truncateRow(`kiso ${version}`, W)];
356
+ const p = palette();
357
+ // R2: the banner styles itself per span. It used to be wrapped in one
358
+ // blanket dim by its component, which made the answers as faint as the
359
+ // labels asking the questions — the labels are the quiet half, the
360
+ // values are what a human came to read.
361
+ //
362
+ // Every width decision below is taken on PLAIN text and the styling is
363
+ // applied after, because truncateRow measures with displayWidth, which
364
+ // counts SGR bytes as columns. Style then measure is a bug waiting.
365
+ // DC-18: the name row is CUT like every other row here. It was the one
366
+ // row in this function pushed unguarded, so at W ≤ 10 `kiso 0.16.4`
367
+ // measured 11 cells and invariant ① threw AT STARTUP — the function
368
+ // whose own comment preaches "invariant ① holds at every width".
369
+ // The cut is taken on the plain text, per the note above.
370
+ const namePlain = plainCut(`kiso ${version}`, Math.max(1, W));
371
+ const nameCut = namePlain.slice(0, 4); // "kiso", or its surviving prefix
372
+ const verCut = namePlain.slice(5); // the version, if the width left room for it
373
+ const rows = [`${p.bold}${nameCut}${p.reset}${verCut === "" ? "" : `${p.dim} ${verCut}${p.reset}`}`];
260
374
  const facts = [];
261
375
  if (meta !== undefined) {
262
376
  facts.push([BANNER_LABELS[0], `${meta.model}${meta.mode === "" ? "" : ` · ${meta.mode}`}`], [BANNER_LABELS[1], meta.cwd]);
@@ -268,32 +382,50 @@ export function bannerLines(W, H, version, extensionsText, resume = [], now = Da
268
382
  // The value column HANGS rather than truncating. The label costs
269
383
  // columns the value used to have, and an extension list cut at the
270
384
  // 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);
385
+ // job is to say what is loaded.
386
+ const indent = 2 + LABEL_STOP;
387
+ // a terminal too narrow to hold the label column at all: the room is
388
+ // what is left, floored at one column, and the assembled row is
389
+ // truncated as a unit so invariant ① holds at every width.
390
+ const room = Math.max(1, W - indent);
274
391
  for (const [label, value] of facts) {
275
- const lead = ` ${label}${" ".repeat(LABEL_STOP - label.length)}`;
276
- const hang = " ".repeat(displayWidth(lead));
392
+ const lead = ` ${p.dim}${label}${p.reset}${" ".repeat(LABEL_STOP - label.length)}`;
393
+ const hang = " ".repeat(indent);
394
+ const lines = [];
277
395
  let line = "";
278
- const out = [];
279
396
  for (const word of value.split(" ")) {
280
397
  if (line === "")
281
398
  line = word;
282
399
  else if (displayWidth(`${line} ${word}`) <= room)
283
400
  line += ` ${word}`;
284
401
  else {
285
- out.push(line);
402
+ lines.push(line);
286
403
  line = word;
287
404
  }
288
405
  }
289
406
  if (line !== "")
290
- out.push(line);
291
- for (const [i, l] of out.entries())
292
- rows.push(truncateRow(`${i === 0 ? lead : hang}${l}`, W));
407
+ lines.push(line);
408
+ for (const [i, l] of lines.entries()) {
409
+ const styled = `${i === 0 ? lead : hang}${truncateRow(l, room)}`;
410
+ rows.push(displayWidth(styled) - (i === 0 ? p.dim.length + p.reset.length : 0) <= W ? styled : truncateRow(`${hang}${l}`, W));
411
+ }
412
+ }
413
+ }
414
+ if (meta !== undefined && W >= 40) {
415
+ // R2/DC-2's device: the keys row is a list of independent clauses,
416
+ // so a narrow terminal drops whole clauses from the end rather than
417
+ // cutting one in half. `ctrl+r ex (+8)` teaches nothing.
418
+ const clauses = BANNER_KEYS.split(" \u00b7 ");
419
+ let keys = clauses[0];
420
+ for (let n = clauses.length; n > 1; n -= 1) {
421
+ const row = clauses.slice(0, n).join(" \u00b7 ");
422
+ if (displayWidth(row) <= W - 2) {
423
+ keys = row;
424
+ break;
425
+ }
293
426
  }
427
+ rows.push("", ` ${p.dim}${truncateRow(keys, W - 2)}${p.reset}`);
294
428
  }
295
- if (meta !== undefined && W >= 40)
296
- rows.push("", truncateRow(` ${BANNER_KEYS}`, W));
297
429
  if (W >= 40 && H >= 20 && resume.length > 0) {
298
430
  rows.push("", ...renderResumeList(resume, W, now));
299
431
  }
@@ -333,7 +465,7 @@ function titleCut(text, max) {
333
465
  export function renderResumeList(metas, W, now) {
334
466
  if (metas.length === 0)
335
467
  return [];
336
- const rows = [" resume"];
468
+ const rows = [" resume"]; // R2: the ONE fold/segment mark (§4.2)
337
469
  const whens = metas.map((m) => relativeTime(m.updatedAt, now));
338
470
  const metaTexts = metas.map((m) => `${m.events} events · ${m.runs} runs`);
339
471
  const metaW = Math.max(...metaTexts.map((t) => t.length));
package/dist/strings.js CHANGED
@@ -51,7 +51,7 @@ export function projectTrustView(root, files) {
51
51
  name: "project trust",
52
52
  title: root,
53
53
  speaker: "kiso",
54
- statusText: " project trust",
54
+ statusText: " project trust",
55
55
  args: { kind: "text", lines: projectTrustRows(files) },
56
56
  ruleOverride: "trust this project's .kiso?",
57
57
  fallbackQuestion: `trust this project's .kiso? (y/n) `,
@@ -96,7 +96,7 @@ export function verifyOfferView() {
96
96
  name: "verification",
97
97
  title: "finish the checklist?",
98
98
  speaker: "kiso",
99
- statusText: "\u25b8 run paused",
99
+ statusText: "\u23f8 run paused",
100
100
  args: { kind: "text", lines: [] },
101
101
  ruleOverride: "every item is marked done \u2014 run a check?",
102
102
  simpleOptions: ["run a verification pass", "not now"],
@@ -109,7 +109,7 @@ export function uncertainView(name, executionId) {
109
109
  name: "uncertain execution",
110
110
  title: `${name} (${executionId})`,
111
111
  speaker: "kiso",
112
- statusText: " uncertain execution",
112
+ statusText: " uncertain execution",
113
113
  args: { kind: "text", lines: [executionId] },
114
114
  ruleOverride: "an interrupted execution may have applied — rerun it?",
115
115
  simpleOptions: ["rerun it", "abandon it"],
@@ -136,7 +136,7 @@ export function unansweredAskView(executionId) {
136
136
  name: "unanswered question",
137
137
  title: `ask_user (${executionId})`,
138
138
  speaker: "kiso",
139
- statusText: " unanswered question",
139
+ statusText: " unanswered question",
140
140
  args: { kind: "text", lines: [executionId] },
141
141
  ruleOverride: "an unanswered question was interrupted — ask it again?",
142
142
  simpleOptions: ["ask it again", "drop it"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui-cells",
3
- "version": "0.16.3",
3
+ "version": "0.16.5",
4
4
  "description": "kiso tui-cells — the components cell renderer (components, diff, width, the render slice). Zero runtime dependencies: input is data, output is bytes.",
5
5
  "type": "module",
6
6
  "license": "MIT",