@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.
- package/dist/approval-panel.d.ts +75 -0
- package/dist/approval-panel.js +125 -12
- package/dist/components.d.ts +55 -6
- package/dist/components.js +466 -23
- package/dist/diff.d.ts +24 -4
- package/dist/diff.js +30 -16
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/render.d.ts +8 -0
- package/dist/render.js +2 -2
- package/dist/strings.d.ts +57 -0
- package/dist/strings.js +171 -0
- package/dist/width.d.ts +16 -6
- package/dist/width.js +78 -6
- package/package.json +1 -1
package/dist/components.js
CHANGED
|
@@ -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
|
|
461
|
+
const verb = escapeTerminal(displayVerb(c.name));
|
|
383
462
|
const verbCol = verb.length < 5 ? `${verb}${" ".repeat(5 - verb.length)}` : verb;
|
|
384
|
-
const
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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} ${
|
|
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
|
|
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} ${
|
|
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
|
|
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
|
-
:
|
|
560
|
-
|
|
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
|
-
:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
|
|
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;
|