@vincemakes/kiso-tui-cells 0.16.3 → 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.
@@ -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}${"\u254c".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}${"\u254c".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}${"\u254c".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}${"\u254c".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,22 @@ 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
+ * W6 turned two \u254c dotted rows into a rounded box, reasoning that
382
+ * "the box already says input lives here". That is reversed here, and
383
+ * the reason is not taste: a rule is a DELIMITER and a box is a
384
+ * CONTAINER, and the screen was carrying six edge vocabularies at once
385
+ * (this box, the panel's \u2502 gutter and \u2514\u2500\u2500 tail, the
386
+ * diff gutter, the quote's \u258f, the table's rails, the markdown
387
+ * rule). One dashed rule replaces the ones that SEPARATE; the \u2502
388
+ * gutter survives where it SCOPES.
388
389
  *
389
390
  * 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.
391
+ * keyed on H \u2212 4 is untouched, and the input row gains the two
392
+ * columns the walls were taking.
392
393
  */
393
394
  export declare function boxTop(W: number): string;
394
- /** W6 — the box: the chrome's bottom rail. */
395
+ /** R2 — the same rule below. Named for its POSITION, not its shape, so
396
+ * the compositor's two call sites did not have to move. */
395
397
  export declare function boxBottom(W: number): string;
396
398
  /** The terminal label + rhythm gap (the pipe path's v2c bytes — the
397
399
  * exact render the passthrough needs). */
@@ -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;
@@ -912,26 +969,26 @@ export function turnFold(t, W) {
912
969
  const meta = parts.join(" · ");
913
970
  const words = escapeTerminal(t.words);
914
971
  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
972
+ const row = `${p.bold}✦${p.reset} ${meta}`;
973
+ return visibleWidth(row) <= W ? [row] : [`${p.bold}✦${p.reset} ${widthCut(meta, Math.max(1, W - 3))}…`]; // a wordless turn folds to the W14 shape
917
974
  }
918
975
  // A9 (ruling R2, mock A): the user chip rides the fold — the human's
919
976
  // words LEAD the one line, the same SGR-7 bracket as the live user
920
977
  // row (#16f, side pads included). The words take the fold's width
921
- // budget: W − the gutter (" " = 2) − the join (" · " = 3) − the
978
+ // budget: W − the gutter (" " = 2) − the join (" · " = 3) − the
922
979
  // chip's side pads (2) − the cut-tail reserve (1, the "…") − the
923
980
  // metadata's own width — the metadata survives, the words width-cut
924
981
  // at the end with the honest "…" (the "…" alone is the honest floor:
925
982
  // the words were there, cut).
926
- const budget = Math.max(0, W - visibleWidth(`▞ ${meta}`) - 6);
983
+ const budget = Math.max(0, W - visibleWidth(`✦ ${meta}`) - 6);
927
984
  const cut = visibleWidth(words) > budget ? `${widthCut(words, budget)}…` : words;
928
- const row = `${p.bold}▞${p.reset} ${p.rv} ${cut} ${p.rvEnd} · ${meta}`;
985
+ const row = `${p.bold}✦${p.reset} ${p.rv} ${cut} ${p.rvEnd} · ${meta}`;
929
986
  if (visibleWidth(row) <= W)
930
987
  return [row];
931
988
  // the last resort: the METADATA gives way — the words hold their
932
989
  // budget, the meta cuts with the honest "…"; invariant ① never trips
933
990
  // 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)))}…`];
991
+ return [`${p.bold}✦${p.reset} ${p.rv} ${cut} ${p.rvEnd} · ${widthCut(meta, Math.max(1, W - 8 - visibleWidth(cut)))}…`];
935
992
  }
936
993
  // ---- the bounded-block flow contract (W7, W8, W10) ----
937
994
  /** The caps — screen rows counted AFTER the fold, at the current width
@@ -1288,8 +1345,11 @@ class Banner {
1288
1345
  }
1289
1346
  render(W, ctx) {
1290
1347
  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}`);
1348
+ // R2: NO blanket dim. bannerLines styles itself the labels are
1349
+ // dim, the values are ink — and wrapping the whole thing in dim
1350
+ // made the answers as faint as the questions.
1351
+ void p;
1352
+ return bannerLines(W, ctx.height, this.cell.version, this.cell.extensionsText, this.cell.resume, ctx.now, this.cell.meta);
1293
1353
  }
1294
1354
  }
1295
1355
  /** W20 — the task block's fixed-window height: the whole live block
@@ -1373,7 +1433,7 @@ class Checklist {
1373
1433
  const fixed = done
1374
1434
  ? `task done · ${plural(items.length, "item")} · ${formatDuration(durationSeconds)}`
1375
1435
  : `task · ${plural(items.length, "item")} · ${active.length} active · ${doneCount} done`;
1376
- const header = `${p.bold}▞${p.reset} ${escapeTerminal(fixed + tail)}`;
1436
+ const header = `${p.bold}✦${p.reset} ${escapeTerminal(fixed + tail)}`;
1377
1437
  // the FULL-list forms: SETTLED — the durable record (the fold is
1378
1438
  // fine — committed content wraps naturally) — and the LIVE ctrl+r
1379
1439
  // toggle (the header CUTS — the block stays one window high; the
@@ -1475,30 +1535,38 @@ export function widthCut(text, max) {
1475
1535
  */
1476
1536
  export function selectionBar(styled, visible, W) {
1477
1537
  const p = palette();
1478
- const inner = styled.replaceAll(p.reset, `${p.reset}${p.rv}`);
1538
+ // R2 (design §2.1 — nothing dim ever sits on the wash): the bar IS a
1539
+ // wash. A dim span inside it renders grey-on-grey — 3.91:1 on the
1540
+ // light ground, under the 4.5 floor — and the dim spans are exactly
1541
+ // the descriptions and the metadata, i.e. the half of the row the
1542
+ // selection was supposed to help you read. Dim is dropped INSIDE the
1543
+ // bar and nowhere else; the same row unselected keeps it.
1544
+ const inner = (p.dim === "" ? styled : styled.replaceAll(p.dim, "")).replaceAll(p.reset, `${p.reset}${p.rv}`);
1479
1545
  return `${p.rv} ${inner}${" ".repeat(Math.max(0, W - visible - 2))} ${p.rvEnd}`;
1480
1546
  }
1481
1547
  /**
1482
1548
  * R2 — the composer's rails, and the ONE edge vocabulary.
1483
1549
  *
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.
1550
+ * W6 turned two \u254c dotted rows into a rounded box, reasoning that
1551
+ * "the box already says input lives here". That is reversed here, and
1552
+ * the reason is not taste: a rule is a DELIMITER and a box is a
1553
+ * CONTAINER, and the screen was carrying six edge vocabularies at once
1554
+ * (this box, the panel's \u2502 gutter and \u2514\u2500\u2500 tail, the
1555
+ * diff gutter, the quote's \u258f, the table's rails, the markdown
1556
+ * rule). One dashed rule replaces the ones that SEPARATE; the \u2502
1557
+ * gutter survives where it SCOPES.
1491
1558
  *
1492
1559
  * 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.
1560
+ * keyed on H \u2212 4 is untouched, and the input row gains the two
1561
+ * columns the walls were taking.
1495
1562
  */
1496
1563
  export function boxTop(W) {
1497
- return `\x1b[2m\u256d${"\u2500".repeat(Math.max(0, W - 2))}\u256e\x1b[0m`;
1564
+ return `\x1b[2m${"\u254c".repeat(Math.max(0, W))}\x1b[0m`;
1498
1565
  }
1499
- /** W6 — the box: the chrome's bottom rail. */
1566
+ /** R2 — the same rule below. Named for its POSITION, not its shape, so
1567
+ * the compositor's two call sites did not have to move. */
1500
1568
  export function boxBottom(W) {
1501
- return `\x1b[2m\u2570${"\u2500".repeat(Math.max(0, W - 2))}\u256f\x1b[0m`;
1569
+ return `\x1b[2m${"\u254c".repeat(Math.max(0, W))}\x1b[0m`;
1502
1570
  }
1503
1571
  /** The terminal label + rhythm gap (the pipe path's v2c bytes — the
1504
1572
  * exact render the passthrough needs). */
package/dist/md.js CHANGED
@@ -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
@@ -141,16 +141,6 @@ export declare function kUnit(value: number | null): string;
141
141
  * this verbatim; the render tests pin the sequence.
142
142
  */
143
143
  export declare function renderTerminalGap(statusLine: string | null): string;
144
- /**
145
- * v3 §01 (V6-2) — the banner, block-split. The logo is THREE BRICK rows
146
- * (the logo.svg pixel form — K I S O), then a BLANK, then the info rows:
147
- * "kiso vX — tagline" + extensions. The tagline rides the version line
148
- * (the old logo MIDDLE row was the tagline — a text row masquerading as
149
- * the logo's centre). Every row truncates at the terminal width with a
150
- * " (+N)" marker (N = the hidden display width); a window narrower than
151
- * 40 columns skips the logo + the blank entirely — only the info rows.
152
- * Pure.
153
- */
154
144
  export declare const TAGLINE = "the coding agent that survives kill -9";
155
145
  /** R2 — what the opening knows about the session. Optional because the
156
146
  * off-TTY caller prints a banner before a model is bound. */
package/dist/render.js CHANGED
@@ -7,7 +7,28 @@
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) => ({ ...BASE, red, wash, washEnd, code: wash });
11
32
  /**
12
33
  * DC-3 — one table per ground.
13
34
  *
@@ -18,8 +39,8 @@ const withWash = (wash, washEnd) => ({ ...BASE, wash, washEnd, code: wash });
18
39
  * varies.
19
40
  */
20
41
  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");
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");
23
44
  /** The historical name — the palette for a colour TTY whose ground has
24
45
  * not been established. Unchanged in every byte except `code`, which
25
46
  * was the defect. */
@@ -190,6 +211,20 @@ export function renderTerminalGap(statusLine) {
190
211
  * 40 columns skips the logo + the blank entirely — only the info rows.
191
212
  * Pure.
192
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
+ }
193
228
  export const TAGLINE = "the coding agent that survives kill -9";
194
229
  /**
195
230
  * R2 — the wordmark is retired (2026-08-27, the nineteen-screen review).
@@ -210,7 +245,11 @@ export const TAGLINE = "the coding agent that survives kill -9";
210
245
  /** R2 — the keys a first screen teaches. One dim row, and deliberately
211
246
  * NOT derived from KEY_BINDINGS: the sheet is the complete list and
212
247
  * this is the opening's five, chosen rather than generated. */
213
- const BANNER_KEYS = "esc interrupt · ctrl+c exit · / commands · ! bash · ctrl+r expand";
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";
214
253
  /** R2 — the labels. Uppercase mono, dim, letter-spaced by the column
215
254
  * rather than by SGR: they mark sections and are never content. */
216
255
  const BANNER_LABELS = ["MODEL", "WORKSPACE", "EXTENSIONS"];
@@ -225,6 +264,13 @@ export function truncateRow(row, width) {
225
264
  const total = displayWidth(row);
226
265
  if (total <= width)
227
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));
228
274
  // iterate the marker to a fixpoint: the marker's width changes the
229
275
  // cut, the cut changes the hidden count the marker reports
230
276
  let marker = " (+0)";
@@ -256,7 +302,24 @@ export function truncateRow(row, width) {
256
302
  * resume list (BIG only, W5). Every row truncates at the terminal width
257
303
  * with a " (+N)" marker. Pure. */
258
304
  export function bannerLines(W, H, version, extensionsText, resume = [], now = Date.now(), meta) {
259
- const rows = [truncateRow(`kiso ${version}`, W)];
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}`}`];
260
323
  const facts = [];
261
324
  if (meta !== undefined) {
262
325
  facts.push([BANNER_LABELS[0], `${meta.model}${meta.mode === "" ? "" : ` · ${meta.mode}`}`], [BANNER_LABELS[1], meta.cwd]);
@@ -268,32 +331,50 @@ export function bannerLines(W, H, version, extensionsText, resume = [], now = Da
268
331
  // The value column HANGS rather than truncating. The label costs
269
332
  // columns the value used to have, and an extension list cut at the
270
333
  // 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);
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);
274
340
  for (const [label, value] of facts) {
275
- const lead = ` ${label}${" ".repeat(LABEL_STOP - label.length)}`;
276
- const hang = " ".repeat(displayWidth(lead));
341
+ const lead = ` ${p.dim}${label}${p.reset}${" ".repeat(LABEL_STOP - label.length)}`;
342
+ const hang = " ".repeat(indent);
343
+ const lines = [];
277
344
  let line = "";
278
- const out = [];
279
345
  for (const word of value.split(" ")) {
280
346
  if (line === "")
281
347
  line = word;
282
348
  else if (displayWidth(`${line} ${word}`) <= room)
283
349
  line += ` ${word}`;
284
350
  else {
285
- out.push(line);
351
+ lines.push(line);
286
352
  line = word;
287
353
  }
288
354
  }
289
355
  if (line !== "")
290
- out.push(line);
291
- for (const [i, l] of out.entries())
292
- rows.push(truncateRow(`${i === 0 ? lead : hang}${l}`, W));
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
+ }
293
375
  }
376
+ rows.push("", ` ${p.dim}${truncateRow(keys, W - 2)}${p.reset}`);
294
377
  }
295
- if (meta !== undefined && W >= 40)
296
- rows.push("", truncateRow(` ${BANNER_KEYS}`, W));
297
378
  if (W >= 40 && H >= 20 && resume.length > 0) {
298
379
  rows.push("", ...renderResumeList(resume, W, now));
299
380
  }
@@ -333,7 +414,7 @@ function titleCut(text, max) {
333
414
  export function renderResumeList(metas, W, now) {
334
415
  if (metas.length === 0)
335
416
  return [];
336
- const rows = [" resume"];
417
+ const rows = [" resume"]; // R2: the ONE fold/segment mark (§4.2)
337
418
  const whens = metas.map((m) => relativeTime(m.updatedAt, now));
338
419
  const metaTexts = metas.map((m) => `${m.events} events · ${m.runs} runs`);
339
420
  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.4",
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",