@vincemakes/kiso-tui 0.15.7 → 0.15.9

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.
@@ -61,6 +61,9 @@ import { displayVerb, keysSheetRows } from "./strings.js";
61
61
  export const CURSOR_MARKER = "\x1b_[kiso-cur]\x1b\\";
62
62
  const FRAME_MS = 16; // state changes coalesce to ≥16ms frames
63
63
  const SPINNER_MS = 200; // the spinner cadence — a ONE-SHOT re-armed on demand
64
+ /** REL-0152-R1: a held row that has never been painted, so the first
65
+ * frame writes every row rather than trusting an empty string. */
66
+ const NOT_PAINTED = "\u0000never";
64
67
  const CHROME_ROWS = 4; // box top + input + box bottom + status — the design §03 chrome (V6-3; the box is W6)
65
68
  /** W20 — the whole-table-replace comparison: the live task block only
66
69
  * redraws when the items actually changed (the task extension's
@@ -83,6 +86,61 @@ export class Body {
83
86
  #lastLiveTop = 0; // the recorded live region top — the resize clear starts here
84
87
  #lastLiveRows = 0; // the recorded live row count (incl. the chrome)
85
88
  #lastSkip = 0; // the last frame's window top in model rows — the A8b scroll's leaving-count base
89
+ /**
90
+ * REL-0152-R1 — the SCREEN, held.
91
+ *
92
+ * H strings: what the terminal is showing right now, as this
93
+ * compositor believes it. Every frame computes the screen it WANTS
94
+ * and writes only the rows where the two differ.
95
+ *
96
+ * This is the whole round. The old renderer's correctness came from
97
+ * remembering what it had written — which rows it had painted, which
98
+ * it had scrolled, what the window's top had been last time — and
99
+ * every defect in the D series that survived a patch came from that
100
+ * memory disagreeing with the terminal. A renderer that HOLDS the
101
+ * screen cannot disagree with itself: it computes a difference and
102
+ * emits it, and a row that is wrong for any reason is repaired on the
103
+ * next frame because the difference includes it.
104
+ *
105
+ * Six fixes were built and reverted before this — one for the tear,
106
+ * five for the chip loss — and each traded a loss for a duplicate
107
+ * somewhere else. They were all attempts to make the memory agree.
108
+ *
109
+ * The one thing a diff cannot repair is the SCROLLBACK, which is
110
+ * irreversible: see #scrolledOff.
111
+ */
112
+ #screen = [];
113
+ /**
114
+ * REL-0152-R1 — the row the cursor is on, as far as this compositor
115
+ * knows.
116
+ *
117
+ * The park at the end of a frame is a RELATIVE move, and the old
118
+ * renderer could hard-code its base: the bottom-up march always ended
119
+ * on the status row at H, so `from` was H by construction. A diff
120
+ * ends wherever the last CHANGED row happens to be — which on a
121
+ * streaming frame is the live band, not the bottom of the screen — so
122
+ * the base has to be tracked rather than assumed.
123
+ *
124
+ * Getting this wrong does not damage the frame; it parks the cursor
125
+ * in the wrong place, which is what the PTY gates that assert "every
126
+ * frame ends with the cursor on an input row" are for. They caught it.
127
+ */
128
+ #cursorRow = 0;
129
+ /**
130
+ * REL-0152-R1 — how many model rows have gone into the terminal's
131
+ * scrollback. Monotonic, because scrolling is.
132
+ *
133
+ * A row may only be scrolled off when it can never be shown again.
134
+ * The window is bottom-anchored, so its top moves BOTH ways as the
135
+ * live band grows and shrinks; scrolling on that movement pushed rows
136
+ * away that the next shrink brought back, which is the A7 replay's
137
+ * frame-106 duplicate. The floor below is where the window's top
138
+ * would sit with the live band empty and the chrome at its minimum —
139
+ * the only part of the movement that is one-way.
140
+ */
141
+ #scrolledOff = 0;
142
+ /** REL-0152-R1: this frame is the repaint after a SIGWINCH. */
143
+ #resizeFrame = false;
86
144
  #lastH = 0;
87
145
  // KC1 §6: the composer's recorded extent — the row count the last
88
146
  // frame drew (exit's clear walks it) and the row its CHA parked the
@@ -166,9 +224,35 @@ export class Body {
166
224
  * a DECRQM round-trip would race the editor for stdin at boot. */
167
225
  #conservative;
168
226
  #frameMs;
227
+ /** REL-0152-R1: the frame's own writer — the ONE path that does not
228
+ * invalidate the held screen, because it is what produced it. */
229
+ #writeFrame;
230
+ /** REL-0152-R1: true only while the frame's own bytes are going out. */
231
+ #inFrame = false;
232
+ #unguard = null;
169
233
  constructor(opts) {
170
234
  this.#opts = opts;
171
- this.#write = opts.write ?? ((s) => process.stdout.write(s));
235
+ // REL-0152-R1: every write that is NOT this frame's own goes
236
+ // through here and INVALIDATES the held screen.
237
+ //
238
+ // A banner, a bodyLog line, the terminal gap, the pipe path's
239
+ // prose — all of them paint the terminal directly, and the old
240
+ // renderer survived that because every frame repainted every row.
241
+ // A diff does not: after such a write the held screen describes a
242
+ // terminal that no longer exists, and the next frame skips exactly
243
+ // the rows it should have repaired. That is a stale composer row
244
+ // left standing while the real one moves — which is what the PTY
245
+ // cursor gates saw.
246
+ //
247
+ // Forgetting is cheap and cannot be forgotten to do: one frame
248
+ // repaints, and correctness stops depending on a list of writers
249
+ // staying complete as the file grows.
250
+ const raw = opts.write ?? ((str) => process.stdout.write(str));
251
+ this.#writeFrame = raw;
252
+ this.#write = (str) => {
253
+ this.#screen = [];
254
+ raw(str);
255
+ };
172
256
  this.#conservative = (opts.termProgram ?? process.env.TERM_PROGRAM) === "Apple_Terminal";
173
257
  this.#frameMs = this.#conservative ? 40 : FRAME_MS;
174
258
  this.#active = opts.active();
@@ -783,6 +867,7 @@ export class Body {
783
867
  if (process.stdout.isTTY !== true || palette().bold === "" || rows < 4)
784
868
  return;
785
869
  this.#docked = true;
870
+ this.#guardOutput();
786
871
  this.#attachResize();
787
872
  this.#fullRedraw = true;
788
873
  this.#dirty = true;
@@ -791,6 +876,7 @@ export class Body {
791
876
  /** Teardown — CSI r (the "no broken terminal" contract byte), the
792
877
  * chrome rows cleared, the cursor home at the input line. */
793
878
  exit() {
879
+ this.#unguard?.();
794
880
  // TUI2-R3v2 ②: the mouse disable rides the SAME teardown as CSI r,
795
881
  // and rides it BEFORE the docked guard — an un-docked compositor is
796
882
  // exactly the state a superseded or half-torn-down one is in, and
@@ -832,6 +918,77 @@ export class Body {
832
918
  out.push(`\x1b[${Math.max(1, H - 1)};1H`);
833
919
  this.#write(out.join(""));
834
920
  }
921
+ /**
922
+ * REL-0152-R1 — the compositor is not the only writer, and a diff has
923
+ * to know.
924
+ *
925
+ * The old renderer repainted every row of every frame, so anything
926
+ * else that printed to the terminal was corrected within one frame
927
+ * and nobody had to think about it. A diff writes only what changed,
928
+ * so an outside write leaves the held screen describing a terminal
929
+ * that no longer exists — and the next frame skips exactly the rows
930
+ * it should have repaired.
931
+ *
932
+ * That is not hypothetical: a bare `console.log` in the CLI's boot
933
+ * path prints the faux-mode notice with a trailing newline, which
934
+ * SCROLLS the terminal two rows while the dock is up. The chrome then
935
+ * sits two rows above where the model puts it, the diff sees no
936
+ * change, and the cursor parks on the wrong row. Three PTY gates
937
+ * caught it.
938
+ *
939
+ * REL-0152-D17: BOTH descriptors, and the second one is the one that
940
+ * mattered. The first version of this guard wrapped stdout alone,
941
+ * while kiso's degradation notices go to stderr through
942
+ * `console.error` — and several of them BEGIN with `[` and contain
943
+ * `]`:
944
+ *
945
+ * [extensions] … [project .kiso] …
946
+ * [KISO_FAUX_SCRIPT] … [run failed] …
947
+ *
948
+ * Those land on the tty wherever the cursor is, and a renderer that
949
+ * does not know they were printed skips exactly the rows that would
950
+ * repair them. The residue survives on the rows whose desired content
951
+ * NEVER changes — the composer box's own edges — which is a stray `[`
952
+ * at the left and `]` at the right for the rest of the session,
953
+ * clearing only on a resize and returning on the next launch.
954
+ *
955
+ * The reasoning that missed it was mine, and it is worth keeping: the
956
+ * renderer emits no OSC and no bare `]`, so a `]` on screen CANNOT
957
+ * have come from its stream. I had that fact and concluded "therefore
958
+ * the terminal invents it" when the only sound conclusion is
959
+ * "therefore something else wrote it".
960
+ *
961
+ * The fix is not a list of writers to remember, and not silencing the
962
+ * loggers. A compositor cannot hold a belief about a terminal it does
963
+ * not own: any write it did not make forgets the screen, whichever
964
+ * descriptor carried it. New callers cannot get it wrong because they
965
+ * are not asked to get it right.
966
+ */
967
+ #guardOutput() {
968
+ if (this.#unguard !== null || this.#opts.write !== undefined)
969
+ return;
970
+ const restores = [];
971
+ for (const stream of [process.stdout, process.stderr]) {
972
+ const real = stream.write.bind(stream);
973
+ const patched = ((chunk, ...rest) => {
974
+ if (!this.#inFrame)
975
+ this.#screen = [];
976
+ return real(chunk, ...rest);
977
+ });
978
+ stream.write = patched;
979
+ restores.push(() => {
980
+ // only restore what we installed — another wrapper may have
981
+ // been layered on top since (the byte trace does exactly that)
982
+ if (stream.write === patched)
983
+ stream.write = real;
984
+ });
985
+ }
986
+ this.#unguard = () => {
987
+ for (const r of restores)
988
+ r();
989
+ this.#unguard = null;
990
+ };
991
+ }
835
992
  /** TUI2-R3v2 ②: the panel's option rows as this frame placed them —
836
993
  * absolute, 1-based. The click hit-test's single source. */
837
994
  panelOptionRows() {
@@ -852,6 +1009,7 @@ export class Body {
852
1009
  const from = Math.max(1, (this.#lastH > 0 ? this.#lastH : H) - liveRows + 1);
853
1010
  this.#write(`\x1b[${Math.min(from, Math.max(1, H))};1H\x1b[0J`);
854
1011
  this.#fullRedraw = true;
1012
+ this.#resizeFrame = true;
855
1013
  this.#dirty = true;
856
1014
  this.render(); // the immediate redraw at the NEW geometry
857
1015
  }
@@ -1230,16 +1388,25 @@ export class Body {
1230
1388
  // erased rows are all re-painted); a shrink takes the full-redraw
1231
1389
  // path: the window re-paints at the model's positions, every row
1232
1390
  // covered (the V6-1 every-row rule).
1233
- if (this.#fullRedraw || liveTop > this.#lastLiveTop + this.#committedLinesThisFrame.length || liveRowsTotal < this.#lastLiveRows) {
1234
- this.#drawFull(out, W, H, liveTop, liveLines, queueRows, menuRows, editor);
1235
- this.#fullRedraw = false;
1236
- }
1237
- else {
1238
- this.#drawSteady(out, W, H, liveTop, liveLines, queueRows, menuRows, editor);
1239
- }
1391
+ // REL-0152-R1: ONE renderer. The steady/full split existed because
1392
+ // the steady path moved rows by scrolling and could not handle a
1393
+ // window that moved the wrong way, so the frames it could not draw
1394
+ // were handed to a full repaint. A diff has no such frames: it
1395
+ // emits the rows that differ, and on a frame where everything
1396
+ // differs that IS a full repaint. The dispatch, the two geometries
1397
+ // and the invariant about which moves each path may use all go
1398
+ // with it.
1399
+ this.#drawFull(out, W, H, liveTop, liveLines, queueRows, menuRows, editor);
1400
+ this.#fullRedraw = false;
1240
1401
  out.push(this.#conservative ? "\x1b[?25h" : "\x1b[?2026l");
1241
1402
  out.push("\x1b[?7h"); // REL-0152-D14: autowrap back on for everything outside the frame
1242
- this.#write(out.join(""));
1403
+ this.#inFrame = true;
1404
+ try {
1405
+ this.#writeFrame(out.join("")); // the frame IS the new screen — it does not invalidate it
1406
+ }
1407
+ finally {
1408
+ this.#inFrame = false;
1409
+ }
1243
1410
  this.#lastLiveTop = liveTop;
1244
1411
  this.#lastLiveRows = liveRowsTotal;
1245
1412
  this.#lastInputRows = editor.rows.length;
@@ -1735,72 +1902,34 @@ export class Body {
1735
1902
  // shrink EVERY frame) loses the scrolled-away turns from the
1736
1903
  // terminal's scrollback entirely (finding #A8b — the queued-flood
1737
1904
  // content loss).
1738
- if (skip > 0 && !overlay) {
1739
- const leaving = Math.max(0, skip - this.#lastSkip);
1740
- // TT-1B (finding TUI2-MD-1): a burst taller than the screen can
1741
- // NOT be staged in one pass rows placed past H are clamped by
1742
- // the terminal itself (CUP pins to the last row), the pile keeps
1743
- // only its last member, and the scroll pushes the wreckage: the
1744
- // short-terminal row loss the finding measured. The transit is
1745
- // chunked instead: each pass paints <= H leaving rows at rows
1746
- // 1..chunk and scrolls exactly that many LFs, so every row is ON
1747
- // the screen when its scroll slot comes up. The <= H path below
1748
- // keeps its exact pre-round bytes (its staging never exceeds H —
1749
- // position <= skip - lastSkip = leaving).
1750
- if (leaving > H) {
1751
- let p = this.#lastSkip;
1752
- while (p < skip) {
1753
- const chunk = Math.min(skip - p, H);
1754
- for (let k = 0; k < chunk; k += 1) {
1755
- out.push(`\x1b[${k + 1};1H\x1b[0K${this.#checked(all[p + k], W)}`);
1756
- }
1757
- if (chunk < H)
1758
- out.push(`\x1b[${chunk + 1};1H\x1b[0J`);
1759
- out.push(`\x1b[${H};1H`);
1760
- for (let k = 0; k < chunk; k += 1)
1761
- out.push("\n");
1762
- p += chunk;
1763
- }
1764
- }
1765
- else {
1766
- // A8b (the fresh leaving share): a leaving row whose old-screen
1767
- // copy is stale — the committed-this-frame lines (their old rows
1768
- // held the previous live/chrome) — is pre-painted at its OLD row
1769
- // so the LF scroll carries it into the scrollback; the frozen
1770
- // leaving rows are already on screen and scroll as-is. The first
1771
- // overflow frame of a batch: the window's top row is the freshly
1772
- // committed line, NEVER on the old screen — without the
1773
- // pre-paint the scroll pushes a blank and the line's only paint
1774
- // (the clamped march at row 1) is overwritten by its neighbor.
1775
- if (skip > frozen.length) {
1776
- const fromIdx = Math.max(0, this.#lastSkip - frozen.length);
1777
- const top = Math.min(skip - frozen.length, all.length - frozen.length);
1778
- for (let i = fromIdx; i < top; i += 1) {
1779
- out.push(`\x1b[${Math.max(1, frozen.length + i - this.#lastSkip + 1)};1H\x1b[0K${this.#checked(all[frozen.length + i], W)}`);
1780
- }
1781
- }
1782
- if (leaving < skip)
1783
- out.push(`\x1b[${leaving + 1};1H\x1b[0J`);
1784
- out.push(`\x1b[${H};1H`);
1785
- // TUI2-R2pre ② — scroll the rows that LEFT THE WINDOW SINCE THE
1786
- // LAST FRAME (`leaving`), never `skip`, which is the window's
1787
- // ABSOLUTE top. Scrolling the absolute top re-pushed the whole
1788
- // history's worth of rows on EVERY full redraw — and a live-region
1789
- // shrink takes this path, so that was most frames of a real
1790
- // session. The ED above had just blanked everything below row
1791
- // `leaving`, so what those surplus LFs carried into the terminal's
1792
- // scrollback was blank rows: the large blank bands mid-history of
1793
- // the owner's field report. The SCREEN never showed it because the
1794
- // repaint below covers every row 1..H (the V6-1 rule), and the
1795
- // house emulator drops scrolled rows on the floor — so no gate
1796
- // could see it either. Measured on the 5-turn 80x24 repro: the
1797
- // scrollback went from 162 blank rows of 168 to 14 of 52.
1798
- for (let i = 0; i < leaving; i += 1)
1799
- out.push("\n");
1800
- }
1905
+ // REL-0152-R1: the scroll is the frame's only irreversible act and
1906
+ // lives in one place now. The FLOOR is where the window's top would
1907
+ // sit with the live band empty and the chrome at its minimum — the
1908
+ // one-way part of a movement that otherwise goes both ways.
1909
+ if (this.#resizeFrame) {
1910
+ // REL-0152-R1: a resize scrolls NOTHING of ours. Shrinking the
1911
+ // window is the terminal's own scroll it reflows the old
1912
+ // content and pushes the overflow into its scrollback before we
1913
+ // are called so emitting our own LFs on top put the same rows
1914
+ // in twice (TT-1B: twelve rows of a forty-line burst). The
1915
+ // counter adopts the terminal's work, and the held screen is
1916
+ // discarded because a reflow invalidates every row of it: the
1917
+ // next diff repaints the whole screen, which is exactly what a
1918
+ // resize needs.
1919
+ this.#scrolledOff = Math.max(this.#scrolledOff, Math.max(0, Math.min(skip, all.length)));
1920
+ this.#screen = new Array(H).fill(NOT_PAINTED);
1921
+ this.#resizeFrame = false;
1922
+ }
1923
+ else if (!overlay) {
1924
+ const floor = Math.max(0, this.#committedLines + CHROME_ROWS - H);
1925
+ this.#emitScroll(out, W, H, all, floor);
1801
1926
  }
1802
1927
  if (!overlay)
1803
1928
  this.#lastSkip = skip;
1929
+ // REL-0152-R1: the rows this frame WANTS on the screen — the same
1930
+ // placement the full redraw has always computed, lifted out of the
1931
+ // emission so it can be compared against what is there.
1932
+ const desired = new Array(H).fill("");
1804
1933
  let r = 1;
1805
1934
  // the window's content rows: everything above the chrome. With the
1806
1935
  // overlay up `all` can exceed it, and the rows that give way are the
@@ -1809,227 +1938,127 @@ export class Body {
1809
1938
  const contentRows = Math.max(0, H - CHROME_ROWS - inputExtra - queueRows.length - menuRows.length);
1810
1939
  const march = all.slice(skip);
1811
1940
  for (const line of march.length > contentRows ? march.slice(march.length - contentRows) : march) {
1812
- out.push(`\x1b[${r};1H\x1b[0K${this.#checked(line, W)}`);
1941
+ desired[r - 1] = this.#checked(line, W);
1813
1942
  r += 1;
1814
1943
  }
1815
- // 3. the GAP rows (between the live content and the chrome) — EL.
1816
- for (let rr = r; rr <= H - 4 - inputExtra; rr += 1) {
1817
- out.push(`\x1b[${rr};1H\x1b[0K`);
1818
- }
1944
+ // 3. the GAP rows (between the live content and the chrome) — blank.
1945
+ for (let rr = r; rr <= H - 4 - inputExtra; rr += 1)
1946
+ desired[rr - 1] = "";
1819
1947
  // W22: the queue chips sit directly above the box top (the
1820
1948
  // "pre-render ABOVE the input row"), the menu above the queue.
1821
1949
  const queueTop = H - 3 - inputExtra - queueRows.length;
1822
1950
  const menuTop = queueTop - menuRows.length;
1823
- for (let i = 0; i < queueRows.length; i += 1) {
1824
- out.push(`\x1b[${queueTop + i};1H\x1b[0K${this.#checked(queueRows[i], W)}`);
1825
- }
1826
- for (let i = 0; i < menuRows.length; i += 1) {
1827
- out.push(`\x1b[${menuTop + i};1H\x1b[0K${this.#checked(menuRows[i], W)}`);
1828
- }
1951
+ for (let i = 0; i < queueRows.length; i += 1)
1952
+ desired[queueTop + i - 1] = this.#checked(queueRows[i], W);
1953
+ for (let i = 0; i < menuRows.length; i += 1)
1954
+ desired[menuTop + i - 1] = this.#checked(menuRows[i], W);
1829
1955
  // V6-3 + W6 + KC1 §6: the design §03 chrome — box top (H−2−N),
1830
1956
  // the composer's N input rows (H−1−N .. H−2), box bottom (H−1),
1831
1957
  // status (H). N = 1 is the retired four-row chrome exactly.
1832
- out.push(`\x1b[${H - 3 - inputExtra};1H\x1b[0K${boxTop(W)}`);
1833
- for (let i = 0; i < editor.rows.length; i += 1) {
1834
- out.push(`\x1b[${H - 2 - inputExtra + i};1H\x1b[0K${this.#checked(editor.rows[i], W)}`);
1835
- }
1836
- out.push(`\x1b[${H - 1};1H\x1b[0K${boxBottom(W)}`);
1958
+ desired[H - 3 - inputExtra - 1] = boxTop(W);
1959
+ for (let i = 0; i < editor.rows.length; i += 1)
1960
+ desired[H - 2 - inputExtra + i - 1] = this.#checked(editor.rows[i], W);
1961
+ desired[H - 1 - 1] = boxBottom(W);
1837
1962
  const statusRow = this.#statusSource();
1838
- out.push(`\x1b[${H};1H\x1b[0K${this.#checked(statusLine(statusRow.status, this.#tail, W, statusRow.hint), W)}`);
1839
- // the cursor: up from the status row to the MARKER'S row inside
1840
- // the composer (N = 1, markerRow 0 the retired \x1b[2A) + the
1841
- // CHA to the marker's frame-derived column W23: the afterW CUB
1842
- // retired (the CHA is absolute the base is irrelevant; the
1843
- // CUB's base was the LAST write's end column, which the steady
1844
- // frame's ELs leave at col 1 the A3 finding)
1845
- this.#parkCursor(out, H, H - 2 - inputExtra + editor.markerRow, editor.markerCol);
1846
- }
1847
- /** The steady-state frame RELATIVE moves only (invariant ②); the
1848
- * commits scroll via the CUP-free real LF at the last row, and the
1849
- * committed lines write in the march's top section (rows
1850
- * [liveTop−N .. liveTop−1] the frozen area's bottom). */
1851
- #drawSteady(out, W, H, liveTop, liveLines, queueRows, menuRows, editor) {
1852
- const inputExtra = editor.rows.length - 1; // KC1: the composer's rows above the retired single input row
1853
- const committed = this.#committedLinesThisFrame;
1854
- // A8b: the steady path's window geometry — the same skip as the full
1855
- // path's (#lastSkip's formula): the model rows above the window
1856
- // belong in the scrollback. frozenCount = the committed lines BEFORE
1857
- // this frame the lines this frame commits start at model row
1858
- // frozenCount, so the lines at [frozenCount..skip−1] are the fresh
1859
- // leaving share (their old-screen copies are stale).
1860
- const frozenCount = this.#committedLines - committed.length;
1861
- // TUI2-R1.5 7(a): an overlay frame never moves the window (see
1862
- // #drawFull) the sheet's rows displace content on screen instead
1863
- // of pushing it into the scrollback.
1864
- const overlay = this.#overlayFrame;
1865
- const skip = overlay ? this.#lastSkip : Math.max(0, this.#committedLines + liveLines.length + CHROME_ROWS + inputExtra + queueRows.length + menuRows.length - H);
1866
- const leaving = overlay ? 0 : Math.max(0, skip - this.#lastSkip);
1867
- // TT-1B (finding TUI2-MD-1): the steady staging has the same
1868
- // past-H clamp as the full path had a window that moved more than
1869
- // a screenful in one frame cannot transit on this path at all.
1870
- // Delegate the oversized frame to #drawFull's chunked emission (the
1871
- // full path repaints every row, so the hand-off is self-contained).
1872
- if (leaving > H) {
1873
- this.#drawFull(out, W, H, liveTop, liveLines, queueRows, menuRows, editor);
1874
- return;
1963
+ desired[H - 1] = this.#checked(statusLine(statusRow.status, this.#tail, W, statusRow.hint), W);
1964
+ this.#emitDiff(out, W, H, desired);
1965
+ // REL-0152-R1: park from where the cursor ACTUALLY is see
1966
+ // #cursorRow. It used to be parked from H, which the bottom-up
1967
+ // march guaranteed and a diff does not.
1968
+ this.#parkCursor(out, this.#cursorRow, H - 2 - inputExtra + editor.markerRow, editor.markerCol);
1969
+ this.#cursorRow = H - 2 - inputExtra + editor.markerRow;
1970
+ }
1971
+ /**
1972
+ * REL-0152-R1emit the difference between the screen that is up and
1973
+ * the screen this frame wants, and adopt the new one.
1974
+ *
1975
+ * One CUP + erase + content per CHANGED row and nothing for the rest.
1976
+ * A streaming delta moves the live band and leaves the status row,
1977
+ * the box, the composer and the queue exactly as they were 13 rows
1978
+ * were being erased per keystroke for a change that touches one, and
1979
+ * those twelve are the tear's whole surface (REL-0152-D1).
1980
+ *
1981
+ * Correctness does not depend on the diff being clever. It depends on
1982
+ * #screen being what the terminal shows, which is why every path that
1983
+ * moves rows updates it: a wrong row is repaired by the next frame,
1984
+ * because the difference includes it.
1985
+ */
1986
+ #emitDiff(out, W, H, desired) {
1987
+ if (this.#screen.length !== H)
1988
+ this.#screen = new Array(H).fill(NOT_PAINTED);
1989
+ for (let i = 0; i < H; i += 1) {
1990
+ const want = desired[i] ?? "";
1991
+ if (this.#screen[i] === want)
1992
+ continue;
1993
+ out.push(want === "" ? `\x1b[${i + 1};1H\x1b[0K` : `\x1b[${i + 1};1H\x1b[0K${want}`);
1994
+ this.#cursorRow = i + 1; // a CUP write leaves the cursor on that row
1875
1995
  }
1876
- // the jump to the bottom row H, then N real LFs scroll the screen
1877
- // exactly N rows — ONE per committed line (the bookkeeping; the
1878
- // stale 1B anchor jumped to H−1 and the N LFs scrolled only N−1 —
1879
- // the committed section sat one row short in the scrollback). The
1880
- // bottom-up repaint below overwrites the scrolled-in rows (the
1881
- // scroll count is screen-neutral — proven by the emulator probe).
1882
- // A7: the scrolled rows carry the PRE-FRAME live copies (the
1883
- // streamed rendering of the just-committed cells) into the
1884
- // scrollback a real terminal keeps them forever, and the
1885
- // repaint's fresh copy below makes the two copies the reviewer
1886
- // saw. The old live band is EL'd BEFORE the scroll: the rows that
1887
- // scroll away are blank, the repaint is the only copy (the old
1888
- // band's rows are all re-drawn this frame live CUP, gap ELs,
1889
- // chrome march so the erase is invisible).
1890
- if (committed.length > 0) {
1891
- // A8b (the steady path's own leaving scroll): the fresh leaving
1892
- // lines the committed-this-frame/live lines at model rows
1893
- // [frozenCount..skip−1], whose old-screen copies are stale (the
1894
- // previous live/chrome) are pre-painted at their OLD rows so
1895
- // the LF scroll carries them into the scrollback. The queued
1896
- // flood's first frame: the window's top row is the user chip,
1897
- // never on the old screen without the pre-paint the scroll
1898
- // pushes a blank and the chip's ONLY paint (the clamped band at
1899
- // row 1) is overwritten by its neighbor in the same frame
1900
- // (finding #A8b the user chips 1..8 lost from the terminal
1901
- // state).
1902
- if (leaving > 0 && skip > frozenCount) {
1903
- const seq = [...committed, ...liveLines];
1904
- const top = Math.min(skip - frozenCount, seq.length);
1905
- for (let i = Math.max(0, this.#lastSkip - frozenCount); i < top; i += 1) {
1906
- out.push(`\x1b[${Math.max(1, frozenCount + i - this.#lastSkip + 1)};1H\x1b[0K${this.#checked(seq[i], W)}`);
1907
- }
1908
- }
1909
- const oldBottom = Math.min(H, this.#lastLiveTop + this.#lastLiveRows);
1910
- // A8b: the EL covers the OVERLAP only the old live band's rows
1911
- // the repaint re-draws (the A7 single-copy discipline). The
1912
- // leaving rows below the overlap scroll WITH their content —
1913
- // they are never re-painted, the scrollback is their record (the
1914
- // old code erased them and the scrolled-away rows came up blank —
1915
- // the A8b content loss).
1916
- const overlapFrom = Math.max(1, this.#lastLiveTop, leaving + 1);
1917
- for (let r = overlapFrom; r <= oldBottom; r += 1) {
1918
- out.push(`\x1b[${r};1H\x1b[0K`);
1996
+ this.#screen = [...desired];
1997
+ }
1998
+ /**
1999
+ * REL-0152-R1 the scroll, and the only irreversible thing a frame
2000
+ * does.
2001
+ *
2002
+ * `leaving` rows go into the terminal's scrollback, which cannot be
2003
+ * rewritten, so two things have to be right BEFORE the LFs: the count,
2004
+ * and what is standing in rows 1..leaving.
2005
+ *
2006
+ * The count is bounded by the FLOOR — where the window's top would sit
2007
+ * with the live band empty and the chrome at its minimum. That is the
2008
+ * one-way part of the window's movement; the rest of it follows a band
2009
+ * whose height goes up and down, and scrolling on that pushed rows
2010
+ * away that the next shrink brought back (the A7 replay's frame 106).
2011
+ *
2012
+ * What is standing there is STAGED from the model rather than assumed.
2013
+ * The old renderer assumed the leaving rows were already correct on
2014
+ * screen and was right until a growing live band painted over one of
2015
+ * them; then the LFs carried the live band's leftover into the
2016
+ * scrollback and the committed row it had displaced was in no
2017
+ * scrollback and on no screen. That is the owner's swallowed message
2018
+ * (REL-0152-D7), and staging is what makes it structurally impossible.
2019
+ */
2020
+ #emitScroll(out, W, H, all, floor) {
2021
+ const target = Math.max(0, Math.min(floor, all.length));
2022
+ const leaving = Math.max(0, target - this.#scrolledOff);
2023
+ if (leaving === 0)
2024
+ return 0;
2025
+ if (this.#screen.length !== H)
2026
+ this.#screen = new Array(H).fill(NOT_PAINTED);
2027
+ // a transit taller than the screen cannot be staged in one pass —
2028
+ // rows placed past H are clamped by the terminal itself and the
2029
+ // pile keeps only its last member (finding TUI2-MD-1). Chunked, at
2030
+ // most H rows at a time, every row on screen when its slot comes up.
2031
+ let p = this.#scrolledOff;
2032
+ while (p < target) {
2033
+ const chunk = Math.min(target - p, H);
2034
+ for (let k = 0; k < chunk; k += 1) {
2035
+ const row = all[p + k];
2036
+ out.push(row === undefined || row === "" ? `\x1b[${k + 1};1H\x1b[0K` : `\x1b[${k + 1};1H\x1b[0K${this.#checked(row, W)}`);
1919
2037
  }
1920
- out.push(`\x1b[${H};1H`); // CUP to the bottom — the absolute scroll base (the ELs moved the cursor)
1921
- }
1922
- else {
1923
- // KC1 §6: the anchor is where the LAST frame's CHA parked the
1924
- // cursor — the marker's row inside the composer (N = 1 ⇒ H−2,
1925
- // the retired \x1b[2B)
1926
- const anchorRow = this.#lastAnchorRow > 0 ? this.#lastAnchorRow : H - 2;
1927
- if (H > anchorRow)
1928
- out.push(`\x1b[${H - anchorRow}B`);
1929
- }
1930
- // TUI2-R2pre ②: this count is the COMMIT count on purpose, and it
1931
- // stays. It reads like the same mistake the full path made, but the
1932
- // two paths are doing different jobs: the full path REPAINTS every
1933
- // row, so anything it scrolls is a duplicate of what it is about to
1934
- // draw; the steady path does not repaint the frozen band, and the
1935
- // rows it scrolls carry the PRE-FRAME live copies of the cells that
1936
- // just committed the A7 single-copy discipline (the old live band
1937
- // is EL'd first, so the repaint below is the only copy left). Making
1938
- // this `leaving` was measured: the A7 gate fails at 40x24 frame 106
1939
- // with the greeting duplicated in the terminal.
1940
- for (let i = 0; i < committed.length; i += 1)
1941
- out.push("\n");
1942
- // the bottom-up repaint, from the last row up — V6-3 + W6 + KC1:
1943
- // the design §03 chrome: status (H), box bottom (H−1), the
1944
- // composer's N input rows (H−2 up to H−1−N), box top (H−2−N)
1945
- const statusRow = this.#statusSource();
1946
- out.push(`\x1b[1G\x1b[0K${this.#checked(statusLine(statusRow.status, this.#tail, W, statusRow.hint), W)}`); // H — the status
1947
- out.push(`\x1b[1A\x1b[1G\x1b[0K${boxBottom(W)}`); // H−1 — the box bottom
1948
- for (let i = editor.rows.length - 1; i >= 0; i -= 1) {
1949
- out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(editor.rows[i], W)}`); // the input rows, bottom-up
1950
- }
1951
- out.push(`\x1b[1A\x1b[1G\x1b[0K${boxTop(W)}`); // H−2−N — the box top
1952
- // W22: the queue chips sit directly above the box top (the
1953
- // "pre-render ABOVE the input row"), the menu above the queue —
1954
- // the bottom-up order mirrors the row order.
1955
- for (let i = queueRows.length - 1; i >= 0; i -= 1) {
1956
- out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(queueRows[i], W)}`);
1957
- }
1958
- for (let i = menuRows.length - 1; i >= 0; i -= 1) {
1959
- out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(menuRows[i], W)}`);
1960
- }
1961
- // the LIVE lines at their MODEL rows — CUP, never the relative
1962
- // march: the march drew them adjacent to the chrome (rows
1963
- // H−3−n..H−3) while the model placed them at [liveTop..liveTop+n−1]
1964
- // — in the unclamped geometry the gap ELs below erased the march's
1965
- // copy and the streamed text was INVISIBLE until the commit frame.
1966
- // The CUP rows land in the content area (≤ H−4−menu ≤ 21), inside
1967
- // the frozen CUP budget of invariant ②.
1968
- for (let i = 0; i < liveLines.length; i += 1) {
1969
- out.push(`\x1b[${liveTop + i};1H\x1b[0K${this.#checked(liveLines[i], W)}`);
1970
- }
1971
- // the FROZEN area — CUP (absolute rows; this is the FREEZE path —
1972
- // the old code's frozen writes were CUP too. The frozen rows are
1973
- // computed from the current geometry, so external writes (the CLI's
1974
- // console.error CRLF) cannot misplace them the way a relative march
1975
- // could).
1976
- // 1. the GAP rows (between the live content and the chrome) — EL'd
1977
- // so the old content there cannot ghost; the range stops ABOVE
1978
- // the queue + menu bands (their rows at [H−3−queue−menu..H−4]
1979
- // are marched and must survive — the unclamped geometry erased
1980
- // them).
1981
- for (let r = liveTop + liveLines.length; r <= H - 4 - inputExtra - queueRows.length - menuRows.length; r += 1) {
1982
- out.push(`\x1b[${r};1H\x1b[0K`);
1983
- }
1984
- // 2. the STALE rows above the committed section — the scrolled old
1985
- // live copies (a live-drawn cell's pre-commit position): EL.
1986
- // TUI2-R2pre ②: the old band's POST-SCROLL origin shifted up by
1987
- // the rows that actually left — `leaving`. It read the commit
1988
- // count only because the scroll above used to BE the commit
1989
- // count; with the two decoupled, the old expression erases rows
1990
- // of frozen content that never moved.
1991
- const staleFrom = Math.max(1, this.#lastLiveTop - committed.length);
1992
- for (let r = staleFrom; r < liveTop - committed.length; r += 1) {
1993
- out.push(`\x1b[${r};1H\x1b[0K`);
1994
- }
1995
- // 3. the committed lines at [liveTop−N .. liveTop−1] — the rows
1996
- // CLAMP at 1: a super-tall force-commit's early lines have no
1997
- // on-screen row (they would need a negative CUP — terminal
1998
- // undefined behavior); their content stays in the scrollback.
1999
- // A8b: the first (skip − frozenCount) lines are ABOVE the new
2000
- // window — they were pre-painted and scrolled; re-painting them
2001
- // would re-clamp them into row 1 and the band's second line
2002
- // overwrites the first in the same frame (the row-1 clamp pile —
2003
- // the queued flood lost the user chips 1..8 that way). The
2004
- // window's share starts at the line whose model row is the
2005
- // window's top (skip).
2006
- for (let i = Math.max(0, skip - frozenCount); i < committed.length; i += 1) {
2007
- out.push(`\x1b[${Math.max(1, liveTop - committed.length + i)};1H\x1b[0K${this.#checked(committed[i], W)}`);
2008
- }
2009
- // the cursor: down to the anchor (H−2, the input row) + left to the marker —
2010
- // the down-distance from the LAST written row, in byte order: the
2011
- // committed band's bottom, then the stale ELs' bottom, then the gap
2012
- // ELs' bottom, then the live lines' bottom, then the menu's top
2013
- // (its last marched row), else the chrome's box top.
2014
- const lastRow = committed.length > 0
2015
- ? Math.max(1, liveTop - 1)
2016
- : staleFrom < liveTop
2017
- ? liveTop - 1
2018
- : liveTop + liveLines.length <= H - 4 - inputExtra - queueRows.length - menuRows.length
2019
- ? H - 4 - inputExtra - queueRows.length - menuRows.length
2020
- : liveLines.length > 0
2021
- ? liveTop + liveLines.length - 1
2022
- : menuRows.length > 0
2023
- ? H - 3 - inputExtra - menuRows.length - queueRows.length
2024
- : H - 3 - inputExtra;
2025
- // the anchor: the MARKER'S row inside the composer (N = 1,
2026
- // markerRow 0 ⇒ the retired H−2)
2027
- this.#parkCursor(out, lastRow, H - 2 - inputExtra + editor.markerRow, editor.markerCol);
2028
- // A8b: the steady path moves the window too (the scroll + the
2029
- // repaint) — record its top so the next full-redraw's leaving count
2030
- // is the rows the window dropped since the last frame, whatever the
2031
- // path of the frames between (same formula as `skip` above).
2032
- this.#lastSkip = skip;
2038
+ if (chunk < H)
2039
+ out.push(`\x1b[${chunk + 1};1H\x1b[0J`);
2040
+ out.push(`\x1b[${H};1H`);
2041
+ for (let k = 0; k < chunk; k += 1)
2042
+ out.push("\n");
2043
+ this.#cursorRow = H; // the LFs at the last row leave it there
2044
+ p += chunk;
2045
+ }
2046
+ this.#scrolledOff = target;
2047
+ // The screen after a scroll is not worth modelling row by row: the
2048
+ // staging painted rows 1..chunk, the ED blanked everything below
2049
+ // them, and the LFs then shifted all of it up per chunk. An
2050
+ // earlier version modelled only the shift, forgot the ED, and the
2051
+ // diff below then skipped rows the terminal had already blanked:
2052
+ // a 19-row blank band on a 24-row screen, which is most of it.
2053
+ //
2054
+ // So a scrolled frame forgets the screen and repaints. That costs
2055
+ // nothing worth having: a scroll happens only when the COMMITTED
2056
+ // height crosses the floor, and a commit frame changes most rows
2057
+ // anyway. The frames the diff exists for a streaming delta, a
2058
+ // keystroke commit nothing, scroll nothing, and repaint only
2059
+ // what moved.
2060
+ this.#screen = new Array(H).fill(NOT_PAINTED);
2061
+ return leaving;
2033
2062
  }
2034
2063
  /**
2035
2064
  * TUI2-R2 ⑤ (the R1.5 parked ⑩) — CURSOR AUTHORITY: the ONE frame-tail
@@ -2057,12 +2086,25 @@ export class Body {
2057
2086
  * independent of wherever the last write ended (the A3 finding: the
2058
2087
  * retired CUB's base was the gap EL's column 1, left of the lead).
2059
2088
  */
2060
- #parkCursor(out, fromRow, toRow, col) {
2061
- const delta = toRow - fromRow;
2062
- if (delta > 0)
2063
- out.push(`\x1b[${delta}B`);
2064
- else if (delta < 0)
2065
- out.push(`\x1b[${-delta}A`);
2089
+ /**
2090
+ * REL-0152-R1 park ABSOLUTELY.
2091
+ *
2092
+ * This was a relative move, and it could be: the old bottom-up march
2093
+ * always ended on the status row, so the base was H by construction.
2094
+ * A diff ends on whatever row changed last, and on a streaming frame
2095
+ * that is the live band. Tracking the base is possible but it makes
2096
+ * the cursor's correctness depend on every writer in this file
2097
+ * remembering to update a counter — and the PTY gates that assert
2098
+ * "every frame ends with the cursor on an input row" caught exactly
2099
+ * that going wrong.
2100
+ *
2101
+ * A CUP to the row needs no base at all. The CHA for the column
2102
+ * stays: it was already absolute (the A3 finding retired the CUB
2103
+ * whose base was the last write's end column), and the frame-derived
2104
+ * column contract is asserted on it.
2105
+ */
2106
+ #parkCursor(out, _fromRow, toRow, col) {
2107
+ out.push(`\x1b[${toRow};1H`);
2066
2108
  out.push(`\x1b[${col}G`);
2067
2109
  }
2068
2110
  /** Invariant ①: every emitted line fits the width — a violation is a
package/dist/editor.d.ts CHANGED
@@ -68,10 +68,14 @@ export declare class Editor {
68
68
  * over while the run is told to stop. Mirrors onEscape (a list, so
69
69
  * listeners can coexist); the line arrives already gone from the
70
70
  * composer, exactly as a submit's does. */
71
- /** REL-0152-D11: what to do when a paste arrives empty. See
72
- * #onEmptyPastethe CLI owns the platform, the editor owns the
73
- * moment. */
74
- onEmptyPaste(cb: () => string | null): void;
71
+ /** REL-0152-D11/D15: where to get the clipboard's contents when the
72
+ * human asks for them ctrl+V, or an empty paste. The CLI owns the
73
+ * platform, the editor owns the moment. */
74
+ onClipboardPaste(cb: () => string | null): void;
75
+ /** REL-0152-D16: the files this line's `[Image #N]` capsules stand
76
+ * for, by their number. The CLI resolves them when it builds the
77
+ * turn; a capsule the human deleted is simply never looked up. */
78
+ attachments(): Map<number, string>;
75
79
  onRedirect(cb: (line: string) => void): void;
76
80
  /** W22: bind the pending-turn queue — the CLI's live slots. The ↑
77
81
  * pop walks them (each pop leaves the queue, cancelling the turn);
package/dist/editor.js CHANGED
@@ -131,6 +131,24 @@ export class Editor {
131
131
  * every entry is text they chose to paste and may still submit.
132
132
  */
133
133
  #pastes = new Map();
134
+ /**
135
+ * REL-0152-D16 — which file each `[Image #N]` capsule stands for.
136
+ *
137
+ * D15 made ctrl+V fetch the clipboard and it worked; then it inserted
138
+ * the PATH, the path began with `/`, and the composer handed it to
139
+ * the slash-command dispatcher. A feature that reaches the last step
140
+ * and gives the result to the wrong parser has not shipped.
141
+ *
142
+ * So the buffer carries a token and this carries the file. The token
143
+ * is what the LINE is — the dispatcher sees `[Image #1]`, which is
144
+ * not a command and never could be — and the CLI reads this map when
145
+ * it builds the turn. Unlike the text capsule, the image is NOT
146
+ * expanded into the line on the way out: a path is not something the
147
+ * model should be sent, and a transcript full of temp-file names is
148
+ * not something the human should have to read.
149
+ */
150
+ #attachments = new Map();
151
+ #attachSeq = 0;
134
152
  #pasteSeq = 0;
135
153
  /** The buffer index where the in-flight paste began; null outside one. */
136
154
  #pasteAt = null;
@@ -154,13 +172,19 @@ export class Editor {
154
172
  */
155
173
  #pasteRun = null;
156
174
  /**
157
- * REL-0152-D11 — what an EMPTY paste means.
175
+ * REL-0152-D11/D15the clipboard hook.
176
+ *
177
+ * A terminal cannot put binary into a byte stream, so pasting an image
178
+ * sends no image. D11 keyed on an EMPTY bracketed paste, reasoning
179
+ * that the paste would still arrive with nothing in it. Half right:
180
+ * with no TEXT on the clipboard many terminals send no paste at all,
181
+ * so there was no empty paste to react to and the owner's cmd+V and
182
+ * ctrl+V both did nothing.
158
183
  *
159
- * Pasting an image sends nothing: a terminal has no way to put binary
160
- * into a byte stream, so the bracketed paste arrives with no content.
161
- * That emptiness is the signal the user pressed paste and the
162
- * terminal had nothing to hand over and whatever they meant has to
163
- * be fetched from the clipboard instead.
184
+ * ctrl+V is the gesture that always arrives 0x16, a byte the editor
185
+ * has always received and thrown away as "other control". The empty
186
+ * paste stays wired too, because terminals differ and one that does
187
+ * send it should behave the same. Two doors, one room.
164
188
  *
165
189
  * The editor does not know what a clipboard is and must not: this
166
190
  * package renders and reads keys, and reaching into the operating
@@ -169,7 +193,7 @@ export class Editor {
169
193
  * moment. It returns the text to insert (a path, for the CLI's
170
194
  * attachment scan to pick up) or null when there was nothing.
171
195
  */
172
- #onEmptyPaste = null;
196
+ #onClipboardPaste = null;
173
197
  /** TUI2-R3v2 ①: one-shot — a panel that just closed swallows the
174
198
  * habitual trailing enter rather than submitting the restored draft. */
175
199
  #swallowEnter = false;
@@ -299,11 +323,17 @@ export class Editor {
299
323
  * over while the run is told to stop. Mirrors onEscape (a list, so
300
324
  * listeners can coexist); the line arrives already gone from the
301
325
  * composer, exactly as a submit's does. */
302
- /** REL-0152-D11: what to do when a paste arrives empty. See
303
- * #onEmptyPastethe CLI owns the platform, the editor owns the
304
- * moment. */
305
- onEmptyPaste(cb) {
306
- this.#onEmptyPaste = cb;
326
+ /** REL-0152-D11/D15: where to get the clipboard's contents when the
327
+ * human asks for them ctrl+V, or an empty paste. The CLI owns the
328
+ * platform, the editor owns the moment. */
329
+ onClipboardPaste(cb) {
330
+ this.#onClipboardPaste = cb;
331
+ }
332
+ /** REL-0152-D16: the files this line's `[Image #N]` capsules stand
333
+ * for, by their number. The CLI resolves them when it builds the
334
+ * turn; a capsule the human deleted is simply never looked up. */
335
+ attachments() {
336
+ return new Map(this.#attachments);
307
337
  }
308
338
  onRedirect(cb) {
309
339
  this.#redirectCbs.push(cb);
@@ -1195,6 +1225,22 @@ export class Editor {
1195
1225
  this.#atAccept();
1196
1226
  i += 1;
1197
1227
  }
1228
+ else if (c === "\x16") {
1229
+ // REL-0152-D15: ctrl+V asks for the clipboard. Inert with no
1230
+ // hook wired, and 0x16 must NEVER reach the buffer — an
1231
+ // unhandled control byte in a prompt is a corrupt prompt.
1232
+ const file = this.#onClipboardPaste?.() ?? null;
1233
+ if (file !== null && file !== "") {
1234
+ // REL-0152-D16: the capsule goes in the buffer, the file
1235
+ // goes beside it. See #attachments.
1236
+ this.#attachSeq += 1;
1237
+ this.#attachments.set(this.#attachSeq, file);
1238
+ for (const ch of `[Image #${this.#attachSeq}]`)
1239
+ this.#insert(ch.codePointAt(0));
1240
+ this.#onRender();
1241
+ }
1242
+ i += 1;
1243
+ }
1198
1244
  else if (c === "\x12") {
1199
1245
  // W15: the expand key (ctrl+r) — rides the chain like a
1200
1246
  // command, the editor just forwards it.
@@ -1933,10 +1979,12 @@ export class Editor {
1933
1979
  // REL-0152-D11: an empty paste is the image case — see
1934
1980
  // #onEmptyPaste. Anything it returns is ordinary text from here
1935
1981
  // on and takes the same route as if it had been typed.
1936
- const substitute = this.#onEmptyPaste?.() ?? null;
1937
- if (substitute === null || substitute === "")
1982
+ const file = this.#onClipboardPaste?.() ?? null;
1983
+ if (file === null || file === "")
1938
1984
  return;
1939
- run = [...substitute].map((ch) => ch.codePointAt(0));
1985
+ this.#attachSeq += 1;
1986
+ this.#attachments.set(this.#attachSeq, file);
1987
+ run = [...`[Image #${this.#attachSeq}]`].map((ch) => ch.codePointAt(0));
1940
1988
  }
1941
1989
  if (this.#historyIdx !== null)
1942
1990
  this.#historyIdx = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.15.7",
3
+ "version": "0.15.9",
4
4
  "description": "kiso tui \u2014 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.15.7"
38
+ "@vincemakes/kiso-tui-cells": "0.15.9"
39
39
  }
40
40
  }