@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.
- package/dist/compositor.js +329 -287
- package/dist/editor.d.ts +8 -4
- package/dist/editor.js +63 -15
- package/package.json +2 -2
package/dist/compositor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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.#
|
|
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
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
//
|
|
1744
|
-
//
|
|
1745
|
-
//
|
|
1746
|
-
//
|
|
1747
|
-
//
|
|
1748
|
-
//
|
|
1749
|
-
//
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
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
|
-
|
|
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) —
|
|
1816
|
-
for (let rr = r; rr <= H - 4 - inputExtra; rr += 1)
|
|
1817
|
-
|
|
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
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
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
|
-
|
|
1833
|
-
for (let i = 0; i < editor.rows.length; i += 1)
|
|
1834
|
-
|
|
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
|
-
|
|
1839
|
-
|
|
1840
|
-
//
|
|
1841
|
-
//
|
|
1842
|
-
//
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
*
|
|
1849
|
-
*
|
|
1850
|
-
*
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
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-R1 — emit 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
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
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
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
// the
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
//
|
|
1931
|
-
//
|
|
1932
|
-
//
|
|
1933
|
-
//
|
|
1934
|
-
//
|
|
1935
|
-
//
|
|
1936
|
-
//
|
|
1937
|
-
//
|
|
1938
|
-
//
|
|
1939
|
-
//
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
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
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
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:
|
|
72
|
-
*
|
|
73
|
-
* moment. */
|
|
74
|
-
|
|
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 —
|
|
175
|
+
* REL-0152-D11/D15 — the 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
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
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
|
-
#
|
|
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:
|
|
303
|
-
*
|
|
304
|
-
* moment. */
|
|
305
|
-
|
|
306
|
-
this.#
|
|
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
|
|
1937
|
-
if (
|
|
1982
|
+
const file = this.#onClipboardPaste?.() ?? null;
|
|
1983
|
+
if (file === null || file === "")
|
|
1938
1984
|
return;
|
|
1939
|
-
|
|
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.
|
|
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.
|
|
38
|
+
"@vincemakes/kiso-tui-cells": "0.15.9"
|
|
39
39
|
}
|
|
40
40
|
}
|