@vincemakes/kiso-tui-cells 0.16.2 → 0.16.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.
@@ -193,7 +193,8 @@ function panelRuleText(view) {
193
193
  const base = view.amended === true
194
194
  ? `${head}${p.dim}needs approval · (amended) — asked by${p.reset}${tail}`
195
195
  : `${head}${p.dim}needs approval — asked by${p.reset}${tail}`;
196
- return hint ? `${base}${p.dim} ·${p.reset} ${p.code}${escapeTerminal(hint)}${p.reset}` : base;
196
+ // DC-3: the fix hint is metadata it borrowed the inline-code tint.
197
+ return hint ? `${base}${p.dim} · ${escapeTerminal(hint)}${p.reset}` : base;
197
198
  }
198
199
  /**
199
200
  * TUI2-R3v2 ① — ONE ROW PER OPTION, and the cursor's row is a bar.
@@ -204,21 +205,52 @@ function panelRuleText(view) {
204
205
  * trade to make: each option owns a row, a narrow window cuts LABELS,
205
206
  * and every choice stays reachable at every width the product survives.
206
207
  *
207
- * The unselected row carries the block's gutter and a two-space indent;
208
- * the selected row is the shared selectionBar, which spends its own two
209
- * cells of frame. Both build their span against W−2, so the digit column
210
- * does not shift as the bar walks — a column that moves per row reads as
211
- * 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.
212
220
  */
213
- function panelOptionRow(option, n, selected, W) {
221
+ function panelOptionRow(option, n, selected, W, note, stop = 0) {
214
222
  const p = palette();
215
223
  const room = Math.max(1, W - 2);
216
- const plain = ` ${n} ${option.label}`;
217
- 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);
218
231
  if (!selected)
219
- return `${p.dim}│${p.reset} ${text}`;
232
+ return ` ${text}`;
220
233
  return selectionBar(text, visibleWidth(text), W);
221
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
+ }
222
254
  /** The block's rows — EXACTLY the preview's frame shape, the gutter at
223
255
  * the left edge (the preview's two-space mock indent is its own
224
256
  * styling; the real rows sit at column 1, like every tool cell).
@@ -228,15 +260,27 @@ export function panelBlockRows(view, phase, cursor, W, maxRows, note, safer) {
228
260
  }
229
261
  export function panelBlockLayout(view, phase, cursor, W, maxRows, note, safer) {
230
262
  const p = palette();
231
- 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 = " ";
232
269
  const rows = [];
233
- rows.push(`${gutter}${cutLine(panelRuleText(view), Math.max(1, W - 2))}`);
234
- 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))}`);
235
279
  // TUI2-R1.5 ⑤ (VD-11): the divider is a LABEL, not a design note. "the
236
280
  // full args — never truncated" is a sentence about the implementation,
237
281
  // addressed to whoever was building the panel; the human reading it
238
282
  // during an approval wants to know what the block below is.
239
- rows.push(`${cutLine(`${p.dim}─ args (full) ─${p.reset}`, Math.max(1, W - 2))}`);
283
+ rows.push("");
240
284
  // the args — the bounded block's body: fold, then cap. The └ cut is
241
285
  // ONE row (the W20 discipline): when the args exceed the budget, one
242
286
  // notice row carries the count and where the rest is (the event log).
@@ -251,7 +295,12 @@ export function panelBlockLayout(view, phase, cursor, W, maxRows, note, safer) {
251
295
  // they can also read in the event log is worth less than the row that
252
296
  // carries the choice. The args keep a floor of one row so the block
253
297
  // never claims to show what it is asking about and then shows nothing.
254
- 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 +
255
304
  (phase === "options" && note !== undefined ? 1 : 0) +
256
305
  (view.riskHint !== undefined && view.riskHint !== "" ? 1 : 0) +
257
306
  (phase === "asking" ? 1 : 0) +
@@ -296,9 +345,13 @@ export function panelBlockLayout(view, phase, cursor, W, maxRows, note, safer) {
296
345
  // out of would be a trap.
297
346
  if (phase === "safer" && safer !== undefined) {
298
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);
299
352
  for (let i = 0; i < safer.options.length; i += 1) {
300
353
  const o = safer.options[i];
301
- 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));
302
355
  }
303
356
  rows.push(panelOptionRow({ kind: "deny", label: SAFER_BACK }, safer.options.length + 1, safer.cursor === safer.options.length, W));
304
357
  }
@@ -335,7 +388,7 @@ export function panelBlockLayout(view, phase, cursor, W, maxRows, note, safer) {
335
388
  // else in the product, so a CAPPED panel emitted two elbow rows in a
336
389
  // row meaning entirely different things. The rule reads as an edge,
337
390
  // and the cut notice above it reads as a notice.
338
- 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}`);
339
392
  return { rows, ...layout };
340
393
  }
341
394
  /**
@@ -353,13 +406,24 @@ export function panelLead(view, phase, cursor) {
353
406
  const p = palette();
354
407
  if (phase === "amend")
355
408
  return `${p.dim}amend› ${p.reset}`;
356
- 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}`;
357
413
  }
358
- /** The composer's lead while a selection list owns the keys — the quiet
359
- * chevron, not a prompt for input that is not being asked for. */
360
- 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 = "";
361
424
  /** The lead's plain text — the editor's reflow width (the line must
362
- * 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). */
363
427
  export function panelLeadPlain(view, phase, cursor) {
364
428
  return phase === "amend" ? "amend› " : PANEL_IDLE_LEAD;
365
429
  }
@@ -368,20 +432,25 @@ export function panelLeadWidth(view, phase, cursor) {
368
432
  }
369
433
  /** The status row's left text while the panel is up — the phase, not
370
434
  * the CLI's painting status (the compositor derives it from the panel
371
- * 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".
372
441
  export function panelStatus(view, phase, cursor) {
373
442
  // TUI2-R3v2 ③: the frames' own words — what the panel is doing, and
374
443
  // (in the safer list) what it did.
375
444
  if (phase === "asking")
376
- return "\u25b8 asked the model for safer options";
445
+ return "\u23f8 asked the model for safer options";
377
446
  if (phase === "safer")
378
- return "\u25b8 asked the model for safer options";
447
+ return "\u23f8 asked the model for safer options";
379
448
  // TUI2-R3v2 ①: the typed phase says where the words GO. "the words ride
380
449
  // the verdict" described the plumbing to whoever wrote it; the human
381
450
  // typing needs to know the model will read this and answer with a new
382
451
  // call — which is what the v4 frame says, in those words.
383
452
  if (phase === "amend")
384
- 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";
385
454
  return view.statusText;
386
455
  }
387
456
  /**
@@ -424,32 +493,60 @@ export function panelAffordance(view, phase, cursor, safer) {
424
493
  export function pickBlockRows(view, state, W, maxRows) {
425
494
  const p = palette();
426
495
  const spec = view.pick;
427
- const gutter = `${p.dim}\u2502${p.reset} `;
428
- 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
429
497
  const room = Math.max(1, W - 2);
430
- 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)}`);
431
499
  if (spec.options.length === 0) {
432
500
  // the honest empty state \u2014 the caller's own copy, verbatim
433
- 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)}`);
434
502
  }
435
503
  else {
436
- // the budget: the header, the t row, the affordance and the rule
437
- 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);
438
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;
439
523
  for (let i = 0; i < shown.length; i += 1) {
440
524
  const o = shown[i];
441
525
  const mark = i === state.cursor && state.phase === "options";
442
- const head = `${mark ? p.bold : ""} ${i + 1} ${escapeTerminal(o.label)}${mark ? p.reset : ""}`;
443
- const note = o.note === undefined ? "" : `${p.dim} ${escapeTerminal(o.note)}${p.reset}`;
444
- 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}`);
445
540
  }
446
541
  if (spec.options.length > shown.length) {
447
- 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)}`);
448
543
  }
449
544
  }
450
- rows.push(`${gutter}${cutLine(`${state.phase === "custom" ? p.bold : ""} t ${p.reset}${p.dim}${escapeTerminal(spec.typeHint)}${p.reset}`, room)}`);
451
- rows.push(`${gutter}${p.dim}${cutLine(pickAffordance(state), room)}${p.reset}`);
452
- 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}`);
453
550
  return rows;
454
551
  }
455
552
  /** The digits are the keys, so the list the panel offers is bounded by
@@ -17,7 +17,7 @@
17
17
  * (untouched); render.ts supplies the original text (palette, escape,
18
18
  * tint, fold wording).
19
19
  */
20
- import { foldThinking, foldResult, renderToolSummary, type ResumeMeta } from "./render.js";
20
+ import { foldThinking, foldResult, renderToolSummary, type ResumeMeta, type BannerMeta } from "./render.js";
21
21
  import { type MdBlock } from "./md.js";
22
22
  export { MdStream, renderBlock, renderMarkdown, type MdBlock, type MdKind } from "./md.js";
23
23
  /** The spinner glyphs, cycled by the compositor's on-demand tick. */
@@ -168,6 +168,7 @@ export type BodyCell = {
168
168
  version: string;
169
169
  extensionsText: string;
170
170
  resume: ResumeMeta[];
171
+ meta?: BannerMeta | undefined;
171
172
  done: true;
172
173
  } | {
173
174
  kind: "raw";
@@ -244,7 +245,7 @@ export declare function expandSuffix(lines: number | null, room: number): string
244
245
  * would put the invariant "exactly one bright token" in as many hands as
245
246
  * there are emitters. Here it has exactly one.
246
247
  *
247
- * NO_COLOR: p.code is empty, so the row's bytes are untouched.
248
+ * NO_COLOR: p.dim is empty, so the row's bytes are untouched.
248
249
  */
249
250
  export declare function focusToken(row: string, W: number): string;
250
251
  /** W13 — the rollup opt-in table: which tools collapse, and the count
@@ -374,13 +375,25 @@ export declare function widthCut(text: string, max: number): string;
374
375
  * keeps the columns from moving as the bar walks the list.
375
376
  */
376
377
  export declare function selectionBar(styled: string, visible: number, W: number): string;
377
- /** W6 — the box: the chrome's top rail. The two ╌ dotted rows become
378
- * a rounded box (the box already says "input lives here"); the rails
379
- * stay dim, the width is still the full W (the box is a rail with
380
- * corners the menu/gap rows above and the status below are
381
- * untouched). */
378
+ /**
379
+ * R2 the composer's rails, and the ONE edge vocabulary.
380
+ *
381
+ * W6 turned two \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.
389
+ *
390
+ * Row-neutral by construction: CHROME_ROWS is still 4, so every gate
391
+ * keyed on H \u2212 4 is untouched, and the input row gains the two
392
+ * columns the walls were taking.
393
+ */
382
394
  export declare function boxTop(W: number): string;
383
- /** 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. */
384
397
  export declare function boxBottom(W: number): string;
385
398
  /** The terminal label + rhythm gap (the pipe path's v2c bytes — the
386
399
  * exact render the passthrough needs). */