@vincemakes/kiso-tui-cells 0.8.0 → 0.10.0

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.
@@ -18,6 +18,10 @@
18
18
  * tint, fold wording).
19
19
  */
20
20
  import { displayWidth } from "./width.js";
21
+ // TUI2-R2pre ④: the ONE display-verb table (strings.ts, beside
22
+ // KEY_BINDINGS). strings.js imports only render/width here, so this edge
23
+ // adds no cycle.
24
+ import { displayVerb } from "./strings.js";
21
25
  import { bannerLines, escapeTerminal, foldThinking, foldResult, colorInlineCode, renderTerminalGap, renderToolSummary, toolTarget, kUnit, palette, } from "./render.js";
22
26
  /** The spinner glyphs, cycled by the compositor's on-demand tick. */
23
27
  export const SPINNER = ["▖", "▘", "▝", "▗"];
@@ -251,6 +255,66 @@ class ThinkingFold {
251
255
  * distinguishes the states at --plain; the UserMessage rail precedent,
252
256
  * v5 #16f). The gutter carries its own SGR (e.g. the bold ✓). W21:
253
257
  * exported for the approval panel's text args (the same │ gutter). */
258
+ /**
259
+ * TUI2-R1.5 ⑨ (VD-10) — the WORD-aware fold, for text a human reads.
260
+ *
261
+ * foldLine is a hard character fold at the width. That is exactly right
262
+ * for verbatim tool output, where a byte is a byte and a break is a
263
+ * display artefact the reader knows to ignore; it is exactly wrong for
264
+ * prose, where the reader's eye has to reassemble "ex" + "pected" into a
265
+ * word it already knew. The walkthrough read three of those off one
266
+ * screen.
267
+ *
268
+ * The implementation is a wrapper, not a second engine: the text is cut
269
+ * at the last space that fits and each resulting segment is handed to
270
+ * foldLine, which keeps the SGR close/reopen discipline, the display-
271
+ * width arithmetic and the newline handling in ONE place. A word longer
272
+ * than the width falls through to foldLine's hard break — an
273
+ * overflowing row would violate invariant ①, and a word that cannot fit
274
+ * has to be broken somewhere.
275
+ */
276
+ /** The SGR spans still open at the end of `text`, given those open at
277
+ * its start. A reset closes everything; anything else stacks. */
278
+ function spansOpenAfter(text, before) {
279
+ let open = [...before];
280
+ for (const m of text.matchAll(/\x1b\[[0-9;]*m/g)) {
281
+ if (m[0] === "\x1b[0m")
282
+ open = [];
283
+ else
284
+ open.push(m[0]);
285
+ }
286
+ return open;
287
+ }
288
+ export function foldWords(line, W) {
289
+ if (W < 1)
290
+ return [line];
291
+ const out = [];
292
+ for (const para of line.split("\n")) {
293
+ if (visibleWidth(para) <= W) {
294
+ out.push(para);
295
+ continue;
296
+ }
297
+ let rest = para;
298
+ // the spans open at the cut point, so each emitted row closes them
299
+ // and the next row reopens them — foldLine's own discipline, applied
300
+ // across the segments this function creates.
301
+ let open = [];
302
+ while (visibleWidth(rest) > W) {
303
+ // the widest prefix that fits, then back up to the last space in
304
+ // it — the SGR-aware cut keeps the spans intact
305
+ const head = widthCut(rest, W);
306
+ const at = head.lastIndexOf(" ");
307
+ if (at <= 0)
308
+ break; // one long word (or no space at all) — hard-break it
309
+ const cut = head.slice(0, at);
310
+ out.push(`${cut}${open.length > 0 || /\x1b\[[0-9;]*m/.test(cut) ? "\x1b[0m" : ""}`);
311
+ open = spansOpenAfter(cut, open);
312
+ rest = `${open.join("")}${rest.slice(cut.length + 1)}`;
313
+ }
314
+ out.push(...foldLine(rest, W));
315
+ }
316
+ return out.length > 0 ? out : [""];
317
+ }
254
318
  export function gutterFold(gutter, line, W) {
255
319
  const textW = Math.max(1, W - 2);
256
320
  return foldLine(line, textW).map((r) => `${gutter}${r}`);
@@ -340,6 +404,21 @@ function settledMeta(c) {
340
404
  * read/write/edit → the path, shell → the command, list_dir → path ??
341
405
  * "(root)". Parsed from the FULL input — the folded summary is a
342
406
  * truncated slice. */
407
+ /** TUI2-R1.5 ④(a) (VD-4) — the header text for a cell that has NOT
408
+ * settled yet (queued, awaiting approval, running).
409
+ *
410
+ * These three states printed `c.input`: a 60-char slice of the call's
411
+ * JSON, escapes and all. The done card printed the plain command
412
+ * through toolTarget, so the SAME call read as
413
+ * `shell {"command":"for i in 1 2 3 4 5 6; do echo \"step $i · compil`
414
+ * while it ran and as `shell for i in 1 2 3 4 5 6; …` a second later.
415
+ * One formatter now, for every state. A cell whose full input somehow
416
+ * will not parse keeps the old slice — the header always says
417
+ * something. */
418
+ function liveTarget(c) {
419
+ const target = toolTargetOf(c);
420
+ return escapeTerminal(target === "?" ? c.input : target);
421
+ }
343
422
  function toolTargetOf(c) {
344
423
  let input = {};
345
424
  try {
@@ -379,9 +458,24 @@ class ToolExecution {
379
458
  render(W, ctx) {
380
459
  const p = palette();
381
460
  const c = this.cell;
382
- const verb = escapeTerminal(c.name.replace("_file", ""));
461
+ const verb = escapeTerminal(displayVerb(c.name));
383
462
  const verbCol = verb.length < 5 ? `${verb}${" ".repeat(5 - verb.length)}` : verb;
384
- const summary = escapeTerminal(c.input);
463
+ const parts = c.rolled?.parts;
464
+ if (c.rolled !== null && parts !== undefined) {
465
+ // TUI2-R1 (B) — the exploration row: a run that spans more than
466
+ // one read-only tool. The counts are BOLD (what the reader is
467
+ // being told), the timing and the affordance dim — the
468
+ // prototype's placement. The affordance names what the key
469
+ // SHOWS here ("lists them"), because a group row's expand is a
470
+ // list of calls, not a body of output.
471
+ const r = c.rolled;
472
+ const counts = exploreCounts(parts);
473
+ const head = `${p.bold}✓${p.reset} explored ${p.bold}${counts}${p.reset}`;
474
+ const tail = ` (${r.elapsed}s)`;
475
+ const room = W - visibleWidth(head) - tail.length;
476
+ const affordance = " · ctrl+r lists them";
477
+ return [cutLine(`${head}${p.dim}${tail}${affordance.length <= room ? affordance : ""}${p.reset}`, W)];
478
+ }
385
479
  if (c.rolled !== null) {
386
480
  // W13 — the rolled-up group's ONE row + the target children:
387
481
  // the work order's claimed shape, verbatim — the verbCol's
@@ -408,45 +502,263 @@ class ToolExecution {
408
502
  // denial appends `· by <decidedBy>` — the aggregated head row
409
503
  // names the decider; a human denial (no decidedBy) has no tail.
410
504
  if (c.reason !== null) {
411
- const by = c.verdict !== null && c.verdict.decidedBy !== undefined ? ` · by ${escapeTerminal(c.verdict.decidedBy)}` : "";
505
+ const by = attribution(c);
412
506
  const out = gutterCut(`${p.red}✗${p.reset} `, `${p.red}${escapeTerminal(`${c.name} ${toolTargetOf(c)}`)} (${escapeTerminal(c.reason)}${by})${p.reset}`, W);
413
507
  out.push(...toolBlockBody(c, W));
414
508
  return out;
415
509
  }
416
510
  const elapsed = c.startedAt !== null && c.doneAt !== null ? ((c.doneAt - c.startedAt) / 1000).toFixed(1) : "?";
417
- const meta = escapeTerminal(settledMeta(c));
511
+ // TUI2-R1.5 ⑤ (VD-6): the line count is stated EXACTLY ONCE. Every
512
+ // read card carried it twice — `(2 lines, 0.0s) · 2 lines · ctrl+r
513
+ // expands` — because the parens and the suffix were written by
514
+ // different rounds, each unaware the other was counting. The
515
+ // SUFFIX keeps it (it is the one that also names the key), so a
516
+ // meta that says only "<n> lines" drops out when a suffix will
517
+ // carry it. A meta that says something else — read_file's
518
+ // "200 of 250 lines", a diff's "+1 -1", a shell's "exit 0" — is a
519
+ // different fact and stays.
520
+ const rawMeta = settledMeta(c);
521
+ const dup = hiddenLines(c, W) !== null && new RegExp(`^${hiddenLines(c, W)} lines?$`).test(rawMeta);
522
+ const meta = dup ? "" : escapeTerminal(rawMeta);
418
523
  // A4: the target rides the settled head row — the verb's
419
524
  // summary column (W3's 5-char pad keeps the paths lined up).
420
525
  // A5: an extension's auto-approval appends `· approved by
421
526
  // <decidedBy>` — the "why wasn't I asked" answer; the human
422
527
  // approval (no decidedBy) leaves the row unchanged.
423
- const approvedBy = c.verdict !== null && c.verdict.decidedBy !== undefined ? ` · approved by ${escapeTerminal(c.verdict.decidedBy)}` : "";
424
- const out = c.isError
425
- ? gutterCut(`${p.red}✗${p.reset} `, `${p.red}${verbCol} ${escapeTerminal(toolTargetOf(c))} (${meta}${approvedBy}, ${elapsed}s)${p.reset}`, W)
426
- : gutterCut(`${p.bold}✓${p.reset} `, `${verbCol} ${escapeTerminal(toolTargetOf(c))} (${meta}${approvedBy}, ${elapsed}s)`, W);
528
+ const approvedBy = attribution(c);
529
+ // TUI2-R1 (A): the card names its own key — the suffix rides the
530
+ // settled head row.
531
+ // TUI2-R1.5 ④(c): the suffix is now RESERVED rather than given
532
+ // the leftovers. It used to take the width that happened to be
533
+ // left, so a long command spent it all and the row said nothing
534
+ // about the seven lines behind the key — tolerable while the
535
+ // body was on screen, a silence now that the body is not. The
536
+ // command is the cuttable span (the approval panel's option-2
537
+ // rule name is the same idea); the affordance is the semantics.
538
+ const hidden = hiddenLines(c, W);
539
+ // TUI2-R1.5 ⑤: the shortest tier is RESERVED — the affordance is
540
+ // the semantics. TUI2-R1.5 pin 4: and the parts give way in a
541
+ // PINNED ORDER, rather than whichever happened to be last.
542
+ const text = settledHeadText(verbCol, escapeTerminal(toolTargetOf(c)), meta, approvedBy, elapsed, W - 2 - (hidden === null ? 0 : SUFFIX_MIN));
543
+ const out = c.isError ? [`${p.red}✗${p.reset} ${p.red}${text}${p.reset}`] : [`${p.bold}✓${p.reset} ${text}`];
544
+ out[0] = appendSuffix(out[0], expandSuffix(hidden, W - visibleWidth(out[0])));
427
545
  out.push(...toolBlockBody(c, W));
428
546
  return out;
429
547
  }
430
548
  if (c.state === "approval") {
431
549
  // W2: the ⏸ is the GUTTER (the left edge), never the line's tail
432
- const out = gutterCut(`${p.bold}⏸${p.reset} `, `${verbCol} ${summary}`, W);
550
+ const out = gutterCut(`${p.bold}⏸${p.reset} `, `${verbCol} ${liveTarget(c)}`, W);
433
551
  out.push(...toolBlockBody(c, W));
434
552
  return out;
435
553
  }
436
554
  if (c.state === "running") {
437
555
  // W2: the spinner IS the gutter (the left edge); the elapsed
438
- // rides the summary's tail
556
+ // rides the summary's tail.
557
+ // TUI2-R1.5 ④(a) (VD-4): the duration is its OWN trailing segment.
558
+ // It used to be concatenated into the text BEFORE the cut, so a
559
+ // header wider than the row lost it entirely or, worse, kept it
560
+ // welded to the last surviving characters of a cut word
561
+ // ("compil 1s"). The head is cut against the room the duration
562
+ // leaves; the duration then rides the row, always legible.
439
563
  const elapsed = c.startedAt !== null ? Math.max(1, Math.round((ctx.now - c.startedAt) / 1000)) : 1;
440
- const out = gutterCut(`${p.bold}${SPINNER[ctx.spinnerI % SPINNER.length]}${p.reset} `, `${verbCol} ${summary} ${elapsed}s`, W);
564
+ const dur = ` · ${elapsed}s`;
565
+ const out = gutterCut(`${p.bold}${SPINNER[ctx.spinnerI % SPINNER.length]}${p.reset} `, `${verbCol} ${liveTarget(c)}`, Math.max(4, W - dur.length));
566
+ out[0] = `${out[0]}${p.dim}${dur}${p.reset}`;
441
567
  out.push(...toolBlockBody(c, W));
442
568
  return out;
443
569
  }
444
570
  // W2: ◦ replaces → for QUEUED — · is the separator inside every
445
571
  // metadata group; a queued marker that is also the separator
446
572
  // glyph reads as noise
447
- return gutterCut(`${p.dim}◦${p.reset} `, `${verbCol} ${summary}`, W);
573
+ return gutterCut(`${p.dim}◦${p.reset} `, `${verbCol} ${liveTarget(c)}`, W);
448
574
  }
449
575
  }
576
+ // ---- TUI2-R1 (A): the self-naming expand affordance ----
577
+ /**
578
+ * TUI2-R1 (A) — how many lines a COLLAPSED settled cell is hiding, or
579
+ * null when it hides nothing.
580
+ *
581
+ * The affordance is a statement about hidden content: a cell whose body
582
+ * is already whole on screen must not advertise a key that would show it
583
+ * the same thing, and a cell that already carries its own renderer cut
584
+ * (`└ +N earlier rows · ctrl+r`, `└ +N more · ctrl+r`) already teaches
585
+ * the key at the place the content stops. What is LEFT — and it is the
586
+ * common case — is every settled non-shell call, whose collapsed body is
587
+ * empty: the whole result sits behind the key with nothing on screen
588
+ * saying so.
589
+ *
590
+ * The count is the RESULT's own line count (the tool's truncation note
591
+ * included — it is a line the expand will show), never a row count and
592
+ * never a cap.
593
+ */
594
+ function hiddenLines(c, W) {
595
+ if (c.expanded || c.state !== "done" || c.rolled !== null || c.reason !== null)
596
+ return null;
597
+ if (c.name === "delegate")
598
+ return null; // its body is the one-line summary, always whole
599
+ const n = countLines(c.resultText);
600
+ if (n === 0)
601
+ return null;
602
+ if (c.isError)
603
+ return null; // errorBody's own cut row is the affordance there
604
+ // TUI2-R1.5 ④(c) (VD-5): a settled shell renders NO body, so its whole
605
+ // output is behind the key exactly like every other settled call. The
606
+ // retired branch only claimed a suffix once the output passed the
607
+ // five-row cap, because below that the tail was on screen; there is no
608
+ // tail now, and a card hiding four lines while saying nothing is the
609
+ // silence TUI2-R1 (A) set out to remove.
610
+ return n; // every other settled call renders NO body — all of it is behind the key
611
+ }
612
+ /**
613
+ * TUI2-R1 (A) — the suffix, in the width that is LEFT.
614
+ *
615
+ * Three tiers, degrading: the full form teaches the key AND what it
616
+ * does, the terse form keeps the count and the key, the bare form keeps
617
+ * the key alone. Below that the row is left exactly as it is today — the
618
+ * affordance is worth a suffix, never worth cutting the path the row
619
+ * exists to name (invariant ① holds by construction: the tier is chosen
620
+ * against the room the row actually has).
621
+ */
622
+ /** TUI2-R1.5 ⑤ — the cells a settled head row reserves for its
623
+ * affordance: exactly the shortest tier, " · ctrl+r". The suffix used
624
+ * to take whatever width happened to be left, so a long target spent it
625
+ * all and the card said nothing about the lines behind the key. Every
626
+ * row that fitted its head before still fits it; only a head that would
627
+ * have eaten the whole row gives up its last nine cells. */
628
+ const SUFFIX_MIN = " · ctrl+r".length;
629
+ /**
630
+ * TUI2-R1.5 pin 4 — the settled head row's text, with a PINNED cut
631
+ * order.
632
+ *
633
+ * The row carries four things of very different value, and until now a
634
+ * single trailing widthCut decided between them by position: the parens
635
+ * came last, so the parens were what got cut. The walkthrough caught
636
+ * both consequences —
637
+ *
638
+ * ✓ shell printf '…' 1 2 … 12 (exit 0 · approv… · ctrl+r
639
+ * ✓ shell for i in 1 2 3 … … · ctrl+r
640
+ *
641
+ * — an UNCLOSED parenthesis, and a row that lost the exit code and the
642
+ * duration to a command string that had no claim on them. A cut that
643
+ * leaves `(exit 0 · approv…` has not shortened a fact, it has broken
644
+ * one: the reader is left holding the beginning of a sentence.
645
+ *
646
+ * The order, tightest last:
647
+ * 1. the affordance is already reserved by the caller (⑤);
648
+ * 2. the RESULT CORE — `(exit 0, 3.0s)`, `(+1 -1, 0.2s)` — renders
649
+ * whole, closing paren included, or not at all;
650
+ * 3. the ATTRIBUTION segment drops before the core is touched: it is
651
+ * a note about who decided, and the result is what happened;
652
+ * 4. the COMMAND/target truncates with `…`. It is the most
653
+ * compressible thing on the row — a reader recognises a command
654
+ * from its head — and it is the only part with a natural ellipsis.
655
+ */
656
+ function settledHeadText(verbCol, target, meta, attr, elapsed, room) {
657
+ const core = `(${meta === "" ? "" : `${meta}, `}${elapsed}s)`;
658
+ const withAttr = `(${[meta, attr.replace(" · ", "")].filter((x) => x !== "").join(" · ")}${meta === "" && attr === "" ? "" : ", "}${elapsed}s)`;
659
+ const lead = `${verbCol} `;
660
+ const fit = (t, parens) => {
661
+ const line = `${lead}${t}${parens === "" ? "" : ` ${parens}`}`;
662
+ return visibleWidth(line) <= room ? line : null;
663
+ };
664
+ // 1. everything
665
+ const full = fit(target, withAttr);
666
+ if (full !== null)
667
+ return full;
668
+ // 2. the attribution gives way
669
+ const bare = fit(target, core);
670
+ if (bare !== null)
671
+ return bare;
672
+ // 3. the target truncates, the core stays whole
673
+ const budget = room - visibleWidth(lead) - visibleWidth(core) - 2; // the space + the ellipsis
674
+ if (budget >= 1)
675
+ return `${lead}${widthCut(target, budget)}… ${core}`;
676
+ // 4. below that even the core cannot ride: the row is the call's
677
+ // identity and its affordance, and no half-open parenthesis.
678
+ return `${lead}${widthCut(target, Math.max(1, room - visibleWidth(lead)))}`;
679
+ }
680
+ /**
681
+ * TUI2-R1.5 ⑤ (VD-11) — approval attribution, about humans.
682
+ *
683
+ * A5 put the DECIDER on the settled head row to answer "why wasn't I
684
+ * asked". The walkthrough found the answer being given nine times in a
685
+ * row as `approved by mode:default` — and `mode:default` is not an
686
+ * answer. It is the runtime's own backfill (run.ts stamps it when no
687
+ * policy expressed an opinion at all), so the row was announcing the
688
+ * ambient default as though something had decided.
689
+ *
690
+ * The signal is inverted and reduced to the fact worth a human's eye:
691
+ * `decidedBy` PRESENT means a policy handled it — ambient, unremarkable,
692
+ * silent. `decidedBy` ABSENT means the human was asked and answered, and
693
+ * that is worth recording on the row: ` · approved`, ` · denied`.
694
+ */
695
+ function attribution(c) {
696
+ if (c.verdict === null || c.verdict.decidedBy !== undefined)
697
+ return "";
698
+ return c.verdict.decision === "denied" ? " · denied" : " · approved";
699
+ }
700
+ export function expandSuffix(lines, room) {
701
+ if (lines === null)
702
+ return "";
703
+ const count = `${lines} line${lines === 1 ? "" : "s"}`;
704
+ for (const tier of [` · ${count} · ctrl+r expands`, ` · ${count} · ctrl+r`, " · ctrl+r"]) {
705
+ if (tier.length <= room)
706
+ return tier;
707
+ }
708
+ return "";
709
+ }
710
+ /** The suffix as the row's dim tail (the empty suffix leaves the row's
711
+ * bytes untouched — a caller never has to branch). */
712
+ function appendSuffix(row, suffix) {
713
+ if (suffix === "")
714
+ return row;
715
+ const p = palette();
716
+ return `${row}${p.dim}${suffix}${p.reset}`;
717
+ }
718
+ /**
719
+ * TUI2-R2 ⑤ (D, candidate 1) — the FOCUS tint.
720
+ *
721
+ * The cell the next ctrl+r will act on brightens its own `ctrl+r` token
722
+ * to the code tint; the rest of the suffix — the separator, the count —
723
+ * stays dim, because what is being marked is the KEY's target, not the
724
+ * row. Zero new rows, zero new columns: the affordance the cell already
725
+ * prints is the marker.
726
+ *
727
+ * Applied to a row rather than composed into it on purpose. The token is
728
+ * emitted from several places (the settled suffix, the renderer's own
729
+ * `└ +N … · ctrl+r` cut rows) and threading a flag through all of them
730
+ * would put the invariant "exactly one bright token" in as many hands as
731
+ * there are emitters. Here it has exactly one.
732
+ *
733
+ * NO_COLOR: p.code is empty, so the row's bytes are untouched.
734
+ */
735
+ export function focusToken(row, W) {
736
+ const p = palette();
737
+ const at = row.lastIndexOf(CTRL_R);
738
+ if (at !== -1) {
739
+ // the row already names the key — brighten the token in place, and
740
+ // leave every other span exactly as it was
741
+ if (p.code === "")
742
+ return row;
743
+ return `${row.slice(0, at)}${p.code}${CTRL_R}${p.reset}${p.dim}${row.slice(at + CTRL_R.length)}`;
744
+ }
745
+ // A LIVE row does not carry the affordance today, and the live cell is
746
+ // the one ctrl+r takes FIRST (expandNext scans the live tail before
747
+ // the committed ring) — so the row the key is aimed at was the one row
748
+ // that never said the key existed. The affordance IS the marker here:
749
+ // it appears on the focused row and nowhere else, which is why no
750
+ // unfocused row's bytes move (every existing live-row assertion
751
+ // renders a cell with no focus and is untouched).
752
+ const room = W - visibleWidth(row);
753
+ if (room < SUFFIX_MIN)
754
+ return row; // never at the cost of invariant ①
755
+ return `${row}${p.dim} · ${p.reset}${p.code}${CTRL_R}${p.reset}`;
756
+ }
757
+ const CTRL_R = "ctrl+r";
758
+ /** TUI2-R1 (A) — the expanded block's last row: the way back. The
759
+ * rollup's expanded list carries a second clause (its members' full
760
+ * outputs live in /last, which the group row cannot show). */
761
+ const COLLAPSE_ROW = "ctrl+r collapses";
450
762
  /** W13 — the rollup opt-in table: which tools collapse, and the count
451
763
  * NOUN (read_file calls → "5 files", list_dir → "5 dirs", search_text
452
764
  * → "5 matches"). Only these tools opt in — a shell burst is never
@@ -457,6 +769,64 @@ export const ROLLUP_NOUN = {
457
769
  list_dir: "dirs",
458
770
  search_text: "matches",
459
771
  };
772
+ // ---- TUI2-R1 (B): the exploration rollup ----
773
+ /** TUI2-R1 (B) — the exploration row's nouns. Deliberately NOT
774
+ * ROLLUP_NOUN: that table says what a SINGLE-tool rollup counts
775
+ * ("5 matches"), and this row counts CALLS across tools, where
776
+ * "14 searches" is what happened. Both tables stay — changing the
777
+ * older one would move an assertion this round did not declare. */
778
+ const EXPLORE_NOUN = {
779
+ read_file: ["file", "files"],
780
+ list_dir: ["dir", "dirs"],
781
+ search_text: ["search", "searches"],
782
+ };
783
+ /** TUI2-R1 (B) — the verb column of the expanded list names the ACT.
784
+ * TUI2-R2pre ④: this used to be a private three-tool table saying the
785
+ * same thing as the card head's `_file` strip, in a different way and
786
+ * for a different set of tools. Both are `displayVerb` now — the whole
787
+ * point of the ruling is that there is ONE answer to "what does the
788
+ * screen call this". The cut note, which used to be the deliberate
789
+ * exception here, moved with it (see toolCutNote). */
790
+ /** Whether a tool joins an exploration run. Exactly the read-only set —
791
+ * writes, edits, shells and extension tools never group (a burst of
792
+ * side effects is a list of things that HAPPENED, and every row of it
793
+ * carries meaning). */
794
+ export function isExploreTool(name) {
795
+ return EXPLORE_NOUN[name] !== undefined;
796
+ }
797
+ /** "8 files · 14 searches" — the per-tool counts in first-call order. */
798
+ export function exploreCounts(parts) {
799
+ return parts
800
+ .map((part) => {
801
+ const [singular, plural] = EXPLORE_NOUN[part.name] ?? ["call", "calls"];
802
+ return `${part.subjects.length} ${part.subjects.length === 1 ? singular : plural}`;
803
+ })
804
+ .join(" · ");
805
+ }
806
+ /** TUI2-R1 (B) — the expanded list: ONE row per tool, the verb column
807
+ * then the distinct subjects in first-call order, a repeated subject
808
+ * carrying its ×count, the first three shown and the rest counted.
809
+ * A search's subject is its PATTERN (quoted — the thing that was
810
+ * looked for); a read's or a list's is its path. */
811
+ export function exploreRows(parts, W) {
812
+ const p = palette();
813
+ const rows = [];
814
+ for (const part of parts) {
815
+ const counts = new Map();
816
+ for (const s of part.subjects)
817
+ counts.set(s, (counts.get(s) ?? 0) + 1);
818
+ const shown = [...counts.entries()].slice(0, 3).map(([s, n]) => (n > 1 ? `${s} ×${n}` : s));
819
+ const more = counts.size > 3 ? ` (+${counts.size - 3})` : "";
820
+ const verb = displayVerb(part.name);
821
+ rows.push(cutLine(`${p.dim}${BODY_ROW}${escapeTerminal(`${verb.padEnd(6)} ${shown.join(" · ")}${more}`)}${p.reset}`, W));
822
+ }
823
+ // TUI2-R1.5 ① (VD-15): the footer used to promise "/last shows the full
824
+ // outputs". /last shows the LAST call only — for a nine-call burst that
825
+ // is one output out of nine, and a footer that sends the human to a
826
+ // place the content is not is worse than a footer that says nothing.
827
+ rows.push(cutLine(`${p.dim}${CUT_ROW}${COLLAPSE_ROW}${p.reset}`, W));
828
+ return rows;
829
+ }
460
830
  /** The count term with the singular/plural forms — "no reads", "1 read",
461
831
  * "5 reads". The noun's singular drops the plural suffix ("dirs" → "dir",
462
832
  * "matches" → "match"). */
@@ -489,7 +859,7 @@ export function turnFold(t, W) {
489
859
  parts.push(countTerm(n, noun.endsWith("es") ? noun.slice(0, -2) : noun.slice(0, -1), noun));
490
860
  }
491
861
  else {
492
- const verb = name.replace("_file", "");
862
+ const verb = displayVerb(name);
493
863
  parts.push(countTerm(n, verb, `${verb}s`));
494
864
  }
495
865
  }
@@ -556,19 +926,33 @@ function toolBlockBody(c, W) {
556
926
  ? errorBody(c, W)
557
927
  : c.name === "delegate"
558
928
  ? delegateSettled(c, W)
559
- : c.name.startsWith("shell")
560
- ? shellTail(c.resultText, W)
561
- : []
929
+ : // TUI2-R1.5 ④(c) (VD-5): a settled shell collapses like
930
+ // every other settled call. It used to keep its last
931
+ // rows plus a "+N earlier rows · ctrl+r" cut FOREVER —
932
+ // six rows per call, so three shells owned a screen. The
933
+ // approved R1 prototype's state 2 is one line; the head
934
+ // row's own suffix already names the count and the key,
935
+ // and ctrl+r shows the whole block, not a five-row window
936
+ // of it.
937
+ []
562
938
  : c.state === "running"
563
939
  ? c.name === "delegate"
564
940
  ? delegateRunning(c, W)
565
- : liveWindow(c.resultText, W)
941
+ : c.name === "shell"
942
+ ? shellLiveTail(c.resultText, W)
943
+ : liveWindow(c.resultText, W)
566
944
  : c.state === "approval"
567
945
  ? diffBody(c.diff, W)
568
946
  : [];
569
947
  const note = c.expanded ? null : toolCutNote(c.name, c.resultText);
570
948
  if (note !== null)
571
949
  rows.push(...foldLine(`${p.dim}${CUT_ROW}${note}${p.reset}`, W));
950
+ // TUI2-R1 (A): an EXPANDED block says how to put it back. The footer
951
+ // rides a block that HAS rows — an expanded delegate whose summary
952
+ // marker is missing renders nothing, and a lone footer under a head
953
+ // row would be an affordance for an empty block.
954
+ if (c.expanded && rows.length > 0)
955
+ rows.push(...foldLine(`${p.dim}${CUT_ROW}${COLLAPSE_ROW}${p.reset}`, W));
572
956
  blockMemo.set(c, { width: W, state, content, rows });
573
957
  return rows;
574
958
  }
@@ -639,6 +1023,49 @@ function liveWindow(text, W) {
639
1023
  const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - (CAP_LIVE_WINDOW - 1)} earlier rows · ctrl+r${p.reset}`, W);
640
1024
  return [...rows.slice(rows.length - (CAP_LIVE_WINDOW - 1)), ...cut];
641
1025
  }
1026
+ /**
1027
+ * TUI2-R1 (C) — the RUNNING shell's live tail.
1028
+ *
1029
+ * The rows are the sidecar's last lines, NEWEST AT THE BOTTOM (a tail
1030
+ * grows downward, and the row nearest the footer is the newest thing the
1031
+ * command said). The window is the SAME three rows W8 fixed: two tail
1032
+ * rows and the footer, blank-padded before the output fills them, so the
1033
+ * block's height still changes exactly once — at settle.
1034
+ *
1035
+ * With nothing observed the shape is exactly today's "waiting for
1036
+ * output": a sidecar that never appeared, a command that has not
1037
+ * printed, and a temp dir that refused the write are indistinguishable
1038
+ * from here, and all three mean the same thing — nothing to show.
1039
+ *
1040
+ * The footer names the state AND the two gestures that apply while a
1041
+ * command runs, because this is precisely when a human wants them.
1042
+ */
1043
+ function shellLiveTail(text, W) {
1044
+ if (text === "")
1045
+ return liveWindow("", W);
1046
+ const p = palette();
1047
+ // TUI2-R1.5 ④(b) (VD-4): the tail's first row is never a blank gutter.
1048
+ // Two sources, both fixed here, and the W8 fixed-window height is kept
1049
+ // by both fixes:
1050
+ // - leading empty lines in the sidecar (a 4096-byte tail can begin on
1051
+ // a line boundary, and the reader's .trimEnd only trims the other
1052
+ // end) are skipped;
1053
+ // - the short-output pad moved from the TOP to the BOTTOM. It exists
1054
+ // so the block's height never changes while the command runs (W8);
1055
+ // at the top it put an empty row above the command's very first
1056
+ // line, which is the frame the walkthrough filed. At the bottom the
1057
+ // output starts under its own header and grows downward, and the
1058
+ // height is just as fixed.
1059
+ const all = blockRows(text, W);
1060
+ const from = all.findIndex((r) => visibleWidth(r) > visibleWidth(BODY_ROW));
1061
+ const rows = from < 0 ? [] : all.slice(from);
1062
+ if (rows.length === 0)
1063
+ return liveWindow("", W);
1064
+ const kept = rows.slice(Math.max(0, rows.length - (CAP_LIVE_WINDOW - 1)));
1065
+ while (kept.length < CAP_LIVE_WINDOW - 1)
1066
+ kept.push(`${p.dim}${BODY_ROW}${p.reset}`);
1067
+ return [...kept, cutLine(`${p.dim}${CUT_ROW}live tail · esc stop · alt+⏎ redirect${p.reset}`, W)];
1068
+ }
642
1069
  /** W12: the delegate's child sessions collapse to the tool row plus ONE
643
1070
  * line — the height NEVER changes (running → settled replaces the row
644
1071
  * in place). The running row derives from the INPUT: the parent has no
@@ -730,14 +1157,22 @@ export function diffBody(diff, W, expanded = false) {
730
1157
  * offset=N", the output cap, list_dir's entry cap). The note reaches
731
1158
  * the MODEL and never the human — this row surfaces it. Detected in
732
1159
  * the result's TAIL (the note is appended at the end); returns null
733
- * when the tool did not truncate. */
1160
+ * when the tool did not truncate.
1161
+ *
1162
+ * TUI2-R2pre ④: the verb here is the DISPLAY one now. This row used to
1163
+ * be the sanctioned raw-name exception, on the reasoning that it names
1164
+ * the tool the model should call again — but the row is addressed to
1165
+ * the HUMAN (the model already has the note in its own transcript, which
1166
+ * is where it read it), and the ruling names this advisory family
1167
+ * explicitly. The `offset=N` it carries is the actionable half and is
1168
+ * untouched. */
734
1169
  function toolCutNote(name, resultText) {
735
1170
  const tail = resultText.slice(-300);
736
1171
  const m = /offset=(\d+)/.exec(tail);
737
1172
  if (m !== null)
738
- return `capped by ${escapeTerminal(name)} · offset=${m[1]} for the rest`;
1173
+ return `capped by ${escapeTerminal(displayVerb(name))} · offset=${m[1]} for the rest`;
739
1174
  if (/…\[truncated\]/.test(tail) || /… \+?\d+ more (?:lines|entries)/.test(tail))
740
- return `capped by ${escapeTerminal(name)} · /last for the rest`;
1175
+ return `capped by ${escapeTerminal(displayVerb(name))} · /last for the rest`;
741
1176
  return null;
742
1177
  }
743
1178
  /** The assistant body text — wrapped at W, the inline-code tint per
@@ -748,8 +1183,10 @@ class AssistantMessage {
748
1183
  this.cell = cell;
749
1184
  }
750
1185
  render(W, _ctx) {
1186
+ // TUI2-R1.5 9 (VD-10): the model's prose is the clearest case of
1187
+ // text a human reads — it wraps at word boundaries.
751
1188
  const text = escapeTerminal(this.cell.text);
752
- const wrapped = foldLine(text, W);
1189
+ const wrapped = foldWords(text, W);
753
1190
  return wrapped.length > 0 ? wrapped.map((l) => colorInlineCode(l)) : [""];
754
1191
  }
755
1192
  }
@@ -760,7 +1197,8 @@ class ErrorLine {
760
1197
  this.cell = cell;
761
1198
  }
762
1199
  render(W, _ctx) {
763
- return foldLine(escapeTerminal(this.cell.text), W);
1200
+ // TUI2-R1.5 9 (VD-10): a notice is a sentence addressed to a human.
1201
+ return foldWords(escapeTerminal(this.cell.text), W);
764
1202
  }
765
1203
  }
766
1204
  /** The CLI's pre-rendered blocks (the banner, the recap, slash-command
@@ -773,7 +1211,12 @@ class RawBlock {
773
1211
  this.cell = cell;
774
1212
  }
775
1213
  render(W, _ctx) {
776
- return this.cell.lines.flatMap((l) => foldLine(l, W));
1214
+ // TUI2-R1.5 9 (VD-10): the raw channel carries BOTH kinds of text —
1215
+ // /help's sentences and /last's verbatim tool output — so the
1216
+ // CALLER says which it is. Verbatim is the default: a surface that
1217
+ // has not thought about it must not have its bytes reflowed.
1218
+ const fold = this.cell.wrap === "words" ? foldWords : foldLine;
1219
+ return this.cell.lines.flatMap((l) => fold(l, W));
777
1220
  }
778
1221
  }
779
1222
  /** The terminal label + the status line. W11: the rhythm gap blank is
package/dist/diff.d.ts CHANGED
@@ -20,13 +20,33 @@ export interface DiffResult {
20
20
  lines: DiffLine[];
21
21
  added: number;
22
22
  removed: number;
23
+ /** TUI2-R1.5 ② (VD-2): the search is not in the file — the tool will
24
+ * ERROR, so the panel shows the honest note carried in `lines` and
25
+ * never a diff. Absent on every real diff. */
26
+ notFound?: true;
23
27
  }
24
28
  /** The RENDERER's truncation: head + "… N lines (/last for full)" + tail. */
25
29
  export declare function truncateDiff(diff: DiffLine[]): DiffLine[];
26
- /** edit_file: the search→replace windows replace in place — the changed
27
- * region is KNOWN, so the diff is the old window vs the new window,
28
- * context from the surrounding file. */
29
- export declare function editFileDiff(oldContent: string, search: string, replace: string): DiffResult;
30
+ /** edit_file: the preview of a CHARACTER splice.
31
+ *
32
+ * TUI2-R1.5 ② (VD-2): the locator is the tool's own, verbatim — the
33
+ * workspace edit_file does `i = text.indexOf(search)` and writes
34
+ * `text.slice(0, i) + replace + text.slice(i + search.length)`. This
35
+ * function mirrors those two lines and diffs the result against the
36
+ * original; it does not model the edit, it reproduces it.
37
+ *
38
+ * The retired locator required the search to align to FULL LINES. A
39
+ * mid-line search ("// OLD" inside " // OLD") therefore missed, and
40
+ * the miss branch rendered the WHOLE FILE as the old side: a one-line
41
+ * edit was drawn as a catastrophic rewrite, on the approval panel, at
42
+ * the moment a human was deciding whether to allow it. A preview that
43
+ * can be that wrong is worse than no preview.
44
+ *
45
+ * A genuine miss is now reported as a miss: the tool will return
46
+ * `pattern not found in <path>` and change nothing, so the panel says
47
+ * exactly that instead of inventing a diff for an edit that will not
48
+ * happen. `path` names the file in that note. */
49
+ export declare function editFileDiff(oldContent: string, search: string, replace: string, path?: string): DiffResult;
30
50
  /** write_file: a new file is all +; an existing file diffs row-level
31
51
  * against its old content. */
32
52
  export declare function writeFileDiff(oldContent: string | null, newContent: string): DiffResult;