@vincemakes/kiso-tui 0.20.2 → 0.20.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.
Files changed (2) hide show
  1. package/dist/compositor.js +177 -25
  2. package/package.json +2 -2
@@ -47,7 +47,7 @@ import { MENU_ITEMS, displayWidth } from "./editor.js";
47
47
  import { leadWidth } from "./width.js"; // W23: the ONE width authority (the editor, #inputRow, and editCol share it)
48
48
  // KC3.5: the panel-slot reads come from the DISPATCHERS — one source
49
49
  // for four reads, so an ask can never render half as an approval.
50
- import { panelAffordanceOf, panelFrameOf, panelLeadOf, panelRowsOf, panelStatusOf } from "./ask-panel.js";
50
+ import { panelFrameOf, panelLeadOf, panelRowsOf, panelStatusOf } from "./ask-panel.js";
51
51
  import { MOUSE_OFF } from "./editor.js";
52
52
  import { atPanelRows, bandHeader } from "./at-picker.js";
53
53
  // TUI2-R2 ②: the session picker's rows — the band's third occupant.
@@ -292,6 +292,20 @@ export class Body {
292
292
  #needsReset = false;
293
293
  #resizeTimer = null;
294
294
  #lastH = 0;
295
+ /** DC-34 — did THIS FRAME refold the committed cells?
296
+ *
297
+ * It must be reset where the question is asked, not only where it
298
+ * is answered. Armed once and consumed later, it latched: the
299
+ * session's FIRST frame ran a vacuous refold over zero committed
300
+ * cells and set it, and nothing cleared it until the first resize —
301
+ * so every session's first widen still ran the adopt it was
302
+ * supposed to skip, and swallowed the live band's worth of
303
+ * committed rows. Three paragraphs, in the measurement that found
304
+ * it. */
305
+ #refolded = false;
306
+ /** DC-34 — the previous frame's width; the reach-back guard is for a
307
+ * WIDTH change, which re-indexes the model, not a height change. */
308
+ #lastW = 0;
295
309
  // KC1 §6: the composer's recorded extent — the row count the last
296
310
  // frame drew (exit's clear walks it) and the row its CHA parked the
297
311
  // cursor on (the steady frame's relative anchor). N = 1 reproduces
@@ -1806,8 +1820,25 @@ export class Body {
1806
1820
  * now). */
1807
1821
  #statusSource() {
1808
1822
  const panel = this.#panelState?.() ?? null;
1823
+ // DC-38: the panel's STATUS replaces the CLI's painting status —
1824
+ // that half of W21 stands. The HINT does not come with it, because
1825
+ // the panel block already ends in its own affordance row and has
1826
+ // since TUI2-R1.5 ("the affordance — the phase's key hint, ONE
1827
+ // row"). Both sites read `panelAffordance`, neither knew about the
1828
+ // other, and an approval at W≥100 printed the same 48-cell sentence
1829
+ // twice, six rows apart.
1830
+ //
1831
+ // The block's copy is the one to keep, on two grounds. It sits next
1832
+ // to the options it names. And it never disappears: the status row
1833
+ // drops its hint when the left text plus the hint will not fit, so
1834
+ // the duplicate was width-dependent — one copy on an ask (long left
1835
+ // text), two on an approval (`⏸ run paused`, twelve cells) — which
1836
+ // is why every fixed-width gate was blind to it. Each flavour's
1837
+ // block carries its own row (approval-panel.ts pushes it for the
1838
+ // approval and the pick, ask-panel.ts for the ask), so nothing is
1839
+ // lost anywhere.
1809
1840
  if (panel !== null)
1810
- return { status: panelStatusOf(panel), hint: panelAffordanceOf(panel) };
1841
+ return { status: panelStatusOf(panel), hint: undefined };
1811
1842
  // W22: while turns wait in the queue, the right hint shows the
1812
1843
  // count — the chips below carry the lines themselves.
1813
1844
  const queued = this.#queueState?.().length ?? 0;
@@ -2443,15 +2474,86 @@ export class Body {
2443
2474
  // the NEW width, so the cached folds are stale. Re-fold the
2444
2475
  // committed cells so the every-row draw below re-paints them at the
2445
2476
  // current geometry — the frame's model and the screen agree.
2477
+ // DC-34 — A WIDEN DOES NOT REFOLD WHAT IS ALREADY COMMITTED.
2478
+ //
2479
+ // Every count here is physical ROWS at the fold width in force
2480
+ // when it was computed. Refolding the committed cells at a new W
2481
+ // changes what every index MEANS while `#scrolledOff` is carried
2482
+ // across untranslated — and no translation exists, because the
2483
+ // row the scroll stopped at does not occur in the new fold. On a
2484
+ // widen the stale count then points at text the terminal already
2485
+ // holds, and the frame paints it a second time.
2486
+ //
2487
+ // A committed row is ink (ADR-0046): the rows still on screen are
2488
+ // the same thing as the rows in the scrollback minus a scroll
2489
+ // that has not happened, and no terminal reflows either. Leaving
2490
+ // them folded as they were printed keeps every index valid.
2491
+ //
2492
+ // NARROWING still refolds — an old wide row does not FIT, and
2493
+ // `#checked` would throw invariant ①. The comparison is against
2494
+ // the CACHE's fold width, not the last render's: after 60 → 100
2495
+ // (no refold, the cache is still 60) a narrowing to 80 must NOT
2496
+ // refold, because 80 columns hold a 60-column row.
2497
+ this.#refolded = false;
2446
2498
  if (this.#fullRedraw) {
2447
- this.#lineCache = this.#lineCache.map(() => null);
2499
+ // DC-34 THE REFOLD IS SCOPED BY THE FRONTIER.
2500
+ //
2501
+ // `#scrolledOff` is the record of what reached the terminal:
2502
+ // rows [0, #scrolledOff) are in its scrollback, immutable, and
2503
+ // no path of ours may contradict them. A cell with any row
2504
+ // down there keeps the fold it was COMMITTED at, forever — in
2505
+ // either direction. A cell entirely above the frontier has
2506
+ // never left the screen, so re-folding it is free.
2507
+ //
2508
+ // Two scalar predicates were tried before this and both
2509
+ // failed, in different ways: the last-refold width crashed on
2510
+ // 60 → 100 → 80 (a cell committed at 100 emitted into an
2511
+ // 80-column screen), and the cache's widest fold fires a FULL
2512
+ // refold at the first narrowing, which re-wraps rows the
2513
+ // scrollback already holds — the original defect, alive in
2514
+ // the other direction. The frontier is not an approximation
2515
+ // of them; it is the question they were both approximating.
2516
+ // A cell is refolded when EITHER is true:
2517
+ // - it is entirely above the frontier (never left the
2518
+ // screen, so re-wrapping it contradicts nothing), or
2519
+ // - it does not FIT: some cached row is wider than W.
2520
+ //
2521
+ // The second is not a compromise of the first, it is the
2522
+ // answer to a question the first cannot reach. A cell can
2523
+ // STRADDLE the frontier — its head in the scrollback, its
2524
+ // tail still on screen — and the tail must be painted at the
2525
+ // current width. Holding its commit fold there emitted a
2526
+ // 100-column row into an 80-column screen and invariant ①
2527
+ // threw (60 → 100 → 80, measured). Fitting wins: a crash is
2528
+ // worse than a seam, and the seam a narrowing leaves is
2529
+ // rider 2's, stated rather than hidden.
2530
+ let row = 0;
2531
+ const refold = new Array(this.#committed).fill(false);
2532
+ for (let i = 0; i < this.#committed; i += 1) {
2533
+ const lines = this.#lineCache[i];
2534
+ if (lines === null || lines === undefined) {
2535
+ refold[i] = true;
2536
+ continue;
2537
+ }
2538
+ const above = row >= this.#scrolledOff;
2539
+ const fits = lines.every((l) => visibleWidth(l) <= W);
2540
+ refold[i] = above || !fits;
2541
+ const prev = i > 0 ? this.#lineCache[i - 1] : null;
2542
+ row += this.#space(i, prev ?? null, lines).length;
2543
+ }
2544
+ for (let i = 0; i < this.#committed; i += 1) {
2545
+ if (refold[i])
2546
+ this.#lineCache[i] = cellComponent(this.#cells[i]).render(W, ctx);
2547
+ }
2548
+ // #committedLines is re-derived over the WHOLE cache, because
2549
+ // the frozen prefix still occupies its own rows.
2448
2550
  this.#committedLines = 0;
2449
2551
  for (let i = 0; i < this.#committed; i += 1) {
2450
- const cell = this.#cells[i];
2451
- const lines = cellComponent(cell).render(W, ctx);
2452
- this.#lineCache[i] = lines; // the cell's OWN rows the cache stays raw
2453
- this.#committedLines += this.#space(i, i > 0 ? this.#lineCache[i - 1] : null, lines).length;
2552
+ const lines = this.#lineCache[i] ?? cellComponent(this.#cells[i]).render(W, ctx);
2553
+ this.#lineCache[i] = lines;
2554
+ this.#committedLines += this.#space(i, i > 0 ? (this.#lineCache[i - 1] ?? []) : null, lines).length;
2454
2555
  }
2556
+ this.#refolded = refold.some(Boolean);
2455
2557
  }
2456
2558
  // 1. the natural commits — the leading DONE cells freeze: their
2457
2559
  // lines leave the live region, the scrolls + the committed
@@ -2617,24 +2719,37 @@ export class Body {
2617
2719
  // the steady state for the session's whole life. It repairs a
2618
2720
  // killed predecessor straight into the same state. The visible
2619
2721
  // cursor comes back exactly once, in editor.exit().
2620
- out.push("\x1b[r\x1b[?69l\x1b[?7h\x1b[?25l");
2621
- // REL-0152-D20 the mechanism is understood and the obvious fix
2622
- // is NOT taken here. See the finding.
2722
+ // DC-40 — H line feeds BEFORE the reset, from wherever the shell
2723
+ // left the cursor. The first frame is the full-redraw path and
2724
+ // addresses rows 1..H absolutely; at launch those rows are the
2725
+ // shell's (its prompt, the launch command, the tail of whatever
2726
+ // ran before), and the frame painted over them — gone, not in
2727
+ // the scrollback (REL-0152-D20 established the mechanism; 37/60
2728
+ // shell lines survived on Apple Terminal, every line ON SCREEN
2729
+ // lost). From cursor row r, H feeds move H−r rows and then
2730
+ // scroll exactly r: the shell's rows 1..r enter the scrollback
2731
+ // as CONTENT (at most one blank row — the cursor's own line),
2732
+ // the screen is blank, and the model's assumption "row 0 is the
2733
+ // terminal's row 1" is true by construction. The count does not
2734
+ // depend on r, so nothing is asked of the terminal.
2623
2735
  //
2624
- // The first frame addresses rows 1..H absolutely and draws over
2625
- // whatever the terminal was showing. Scrolling a screenful away
2626
- // first would fix that, and it was built and measured: it pushes
2627
- // up to H BLANK rows into the scrollback, and TUI2-R2pre's
2628
- // blank-share gate went from 14/43 to 29/43 past the "a
2629
- // healthy session's scrollback is mostly content" invariant that
2630
- // gate exists to hold. Trading a symptom with a five-second
2631
- // user-side setting for a broken invariant is a worse deal.
2736
+ // THE ORDER IS THE FIX. `ESC[r` (DECSTBM) HOMES THE CURSOR to
2737
+ // row 1 VT100 semantics, honoured by Apple Terminal, xterm,
2738
+ // xterm.js and tmux alike. Feeds emitted AFTER the reset start
2739
+ // from row 1, move H−1 rows and scroll ONE: measured on Apple
2740
+ // Terminal, 1/20 shell lines survived with an otherwise
2741
+ // byte-identical frame. Feeds BEFORE the reset: 20/20. The house
2742
+ // emulators did not model the homing and passed the wrong order;
2743
+ // VtScrollback does now, and the DC-40 gate is red on it.
2632
2744
  //
2633
- // The correct version scrolls only as far as the terminal's
2634
- // content actually reaches, which needs a cursor-position query
2635
- // at boot its own round, with its own risk (this file already
2636
- // declined a boot-time round-trip once, for racing the editor
2637
- // for stdin).
2745
+ // The reset still precedes the FRAME (REL-0152-D19: a frame
2746
+ // drawn into an inherited sub-region is the defect); only the
2747
+ // feeds run under whatever region the shell left. A region a
2748
+ // killed foreign program left set makes the feeds scroll that
2749
+ // region alone and the frame paint over the rows outside it —
2750
+ // which is what every frame did before this fix, never worse.
2751
+ out.push("\n".repeat(H));
2752
+ out.push("\x1b[r\x1b[?69l\x1b[?7h\x1b[?25l");
2638
2753
  }
2639
2754
  out.push("\x1b[?7l");
2640
2755
  // REL-0161: ?25l is the STEADY state now, not a frame bracket —
@@ -3577,7 +3692,25 @@ export class Body {
3577
3692
  // discarded because a reflow invalidates every row of it: the
3578
3693
  // next diff repaints the whole screen, which is exactly what a
3579
3694
  // resize needs.
3580
- this.#scrolledOff = Math.max(this.#scrolledOff, Math.max(0, Math.min(skip, all.length)));
3695
+ // DC-34 NO HIGH-WATER MARK ON A RESIZE.
3696
+ //
3697
+ // This was `max(#scrolledOff, …)`, which held a stale count
3698
+ // whenever a widen made the fresh one smaller; `leaving` then
3699
+ // stayed <= 0 and the text that marched past in the meantime
3700
+ // never entered the scrollback at all — the hole, the other
3701
+ // half of the same off-by-a-refold.
3702
+ //
3703
+ // The other implementation in this space reached the same
3704
+ // conclusion independently and says so in its own source: a
3705
+ // historical high-water mark "caused self-reinforcing
3706
+ // inflation that pushed content into scrollback on terminal
3707
+ // widen". Dropping it alone brings the DUPLICATE back — it is
3708
+ // the pair with the no-refold rule above, not a substitute
3709
+ // for it.
3710
+ // PROBE 3: a widen leaves it ALONE; a narrow keeps REL-0152-R1.
3711
+ if (this.#refolded)
3712
+ this.#scrolledOff = Math.max(this.#scrolledOff, Math.max(0, Math.min(skip, all.length)));
3713
+ this.#refolded = false;
3581
3714
  this.#screen = new Array(H).fill(NOT_PAINTED);
3582
3715
  this.#resizeFrame = false;
3583
3716
  }
@@ -3597,7 +3730,26 @@ export class Body {
3597
3730
  // OLDEST on screen — they are still in the model and come back on
3598
3731
  // the close.
3599
3732
  const contentRows = Math.max(0, H - CHROME_ROWS - inputExtra - queueRows.length - menuRows.length);
3600
- const march = all.slice(skip);
3733
+ // DC-34 THE MARCH NEVER REACHES BELOW THE FRONTIER.
3734
+ //
3735
+ // Rows [0, #scrolledOff) are in the terminal's scrollback and are
3736
+ // immutable; painting one puts the same prose on screen twice,
3737
+ // which is the owner's report. `skip` can drop below it whenever
3738
+ // the model shrinks under a fixed screen — a widen refolding the
3739
+ // cells above the frontier, or the live band collapsing — and
3740
+ // nothing stopped it (rider 3's ungated reach-back).
3741
+ //
3742
+ // Clamping costs a gap under short content for one frame, which
3743
+ // the next commit fills. Reaching back costs a duplicate that
3744
+ // stands in the transcript forever.
3745
+ // ...but only when the WIDTH moved. A height change re-indexes
3746
+ // nothing — the folds are untouched, every row means what it
3747
+ // meant — so reaching back there is the pre-existing behaviour a
3748
+ // gate already covers (the A8 windowing case: grow the screen and
3749
+ // the banner returns). The duplication measured in this round is
3750
+ // width-driven, and so is the guard.
3751
+ const march = all.slice(this.#lastW !== 0 && this.#lastW !== W ? Math.max(skip, this.#scrolledOff) : skip);
3752
+ this.#lastW = W;
3601
3753
  for (const line of march.length > contentRows ? march.slice(march.length - contentRows) : march) {
3602
3754
  desired[r - 1] = this.#checked(line, W);
3603
3755
  r += 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.20.2",
3
+ "version": "0.20.4",
4
4
  "description": "kiso tui — the pure terminal layer (cell renderer, dock, raw editor, diff, palette). Zero runtime dependencies: input is data, output is bytes.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,6 +35,6 @@
35
35
  },
36
36
  "homepage": "https://github.com/vincemakes/kiso/tree/main/packages/tui#readme",
37
37
  "dependencies": {
38
- "@vincemakes/kiso-tui-cells": "0.20.2"
38
+ "@vincemakes/kiso-tui-cells": "0.20.4"
39
39
  }
40
40
  }