@cplieger/web-terminal-engine 5.0.4 → 5.0.8
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/jsr.json +1 -1
- package/package.json +2 -2
- package/src/connection.ts +15 -1
- package/src/keyboard.ts +19 -20
- package/src/mouse.ts +14 -4
- package/src/render.ts +49 -17
- package/src/store.ts +37 -9
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-engine",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"typecheck": "tsc -p tsconfig.json",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"esbuild": "0.28.2",
|
|
22
22
|
"eslint": "10.8.1",
|
|
23
23
|
"fast-check": "4.9.0",
|
|
24
|
-
"happy-dom": "20.11.
|
|
24
|
+
"happy-dom": "20.11.6",
|
|
25
25
|
"pngjs": "7.0.0",
|
|
26
26
|
"prettier": "3.9.6",
|
|
27
27
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
package/src/connection.ts
CHANGED
|
@@ -782,7 +782,21 @@ function retransmitOutbox(sock: WebSocket, upgraded: boolean, st: ResumeState):
|
|
|
782
782
|
}
|
|
783
783
|
|
|
784
784
|
function scheduleReconnect(): void {
|
|
785
|
-
|
|
785
|
+
// Nothing to schedule when the state already owns a path back to a socket: a
|
|
786
|
+
// backoff timer is pending ("reconnecting"), or a socket exists that will
|
|
787
|
+
// drive its own retry from its close handler or its connect timeout
|
|
788
|
+
// ("connecting"/"connected"). Both callers set "disconnected" before calling
|
|
789
|
+
// cb.onClose(), so a consumer that reconnects from its own close hook lands
|
|
790
|
+
// here at "connecting" — and scheduling over it would OVERWRITE connState,
|
|
791
|
+
// hiding that socket from connect()'s double-call guard, which then never
|
|
792
|
+
// aborts it: the timer's connect() adds a second live socket with the first
|
|
793
|
+
// one's listeners still bound, i.e. the duplicate server connection and
|
|
794
|
+
// double delivery documented inside connect().
|
|
795
|
+
if (
|
|
796
|
+
connState.status === "reconnecting" ||
|
|
797
|
+
connState.status === "connecting" ||
|
|
798
|
+
connState.status === "connected"
|
|
799
|
+
) {
|
|
786
800
|
return;
|
|
787
801
|
}
|
|
788
802
|
const step = nextBackoffDelay(reconnectDelay);
|
package/src/keyboard.ts
CHANGED
|
@@ -57,17 +57,6 @@ function modifiersDigit(ev: KeyboardEvent): number {
|
|
|
57
57
|
/** The cursor-key trailing letters that honor DECCKM (arrows + Home/End). */
|
|
58
58
|
type CursorKeyLetter = "A" | "B" | "C" | "D" | "H" | "F";
|
|
59
59
|
|
|
60
|
-
function isCursorKeyLetter(letter: string): letter is CursorKeyLetter {
|
|
61
|
-
return (
|
|
62
|
-
letter === "A" ||
|
|
63
|
-
letter === "B" ||
|
|
64
|
-
letter === "C" ||
|
|
65
|
-
letter === "D" ||
|
|
66
|
-
letter === "H" ||
|
|
67
|
-
letter === "F"
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
60
|
/**
|
|
72
61
|
* plainCursorKeySeq is THE encoding of an unmodified logical cursor-key press
|
|
73
62
|
* (arrows, Home, End) — the one home for the mode decision, shared by the
|
|
@@ -114,21 +103,31 @@ function kittyActiveIn(modes: KeyboardModes): boolean {
|
|
|
114
103
|
return (modes.getKeyboardFlags() & KITTY_DISAMBIGUATE) !== 0;
|
|
115
104
|
}
|
|
116
105
|
|
|
117
|
-
// Letter is the xterm trailing letter
|
|
118
|
-
//
|
|
119
|
-
// modifiers we send CSI 1;{mod}{letter}. xterm.js Keyboard.ts pattern.
|
|
106
|
+
// Letter is the xterm trailing letter for the keys that honor DECCKM: the four
|
|
107
|
+
// arrows plus Home (H) and End (F). Without modifiers we send the bare CSI form;
|
|
108
|
+
// with modifiers we send CSI 1;{mod}{letter}. xterm.js Keyboard.ts pattern.
|
|
109
|
+
// (F1-F4's SS3/CSI pair is encoded inline at its own call site, not here.)
|
|
110
|
+
//
|
|
111
|
+
// The parameter type is `CursorKeyLetter` rather than `string`, so "every
|
|
112
|
+
// letter reaching here honors DECCKM" is a compiler guarantee instead of a
|
|
113
|
+
// runtime net: all three call sites pass A/B/C/D from ARROW_LETTER, or the H/F
|
|
114
|
+
// literals.
|
|
120
115
|
//
|
|
121
116
|
// Application cursor mode (DECCKM, CSI ?1) is plumbed via modes.ts.
|
|
122
117
|
// When the application has set DECCKM, the modifier-less form switches
|
|
123
118
|
// from CSI to SS3 (ESC O letter) via plainCursorKeySeq; modifier-bearing
|
|
124
119
|
// forms stay on CSI because they have no SS3 equivalent.
|
|
125
|
-
function csiLetter(letter:
|
|
120
|
+
function csiLetter(letter: CursorKeyLetter, ev: KeyboardEvent, modes: KeyboardModes): string {
|
|
126
121
|
const m = modifiersDigit(ev);
|
|
127
122
|
if (m === 1) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
123
|
+
// `kittyActiveIn(modes)` is always FALSE as reached from today's only
|
|
124
|
+
// caller: mapKeyboardEvent returns through encodeKittyDisambiguate before
|
|
125
|
+
// any cursor key gets here. It is passed anyway rather than hard-coded,
|
|
126
|
+
// because plainCursorKeySeq is the one home for the mode decision and a
|
|
127
|
+
// literal `false` would make this function structurally unable to express
|
|
128
|
+
// the kitty form — a later change to that early return would then
|
|
129
|
+
// mis-encode silently instead of failing to compile or reading wrong.
|
|
130
|
+
return plainCursorKeySeq(letter, kittyActiveIn(modes), modes.isApplicationCursor());
|
|
132
131
|
}
|
|
133
132
|
return `${ESC}[1;${m}${letter}`;
|
|
134
133
|
}
|
|
@@ -169,7 +168,7 @@ const FN_TILDE: Record<string, number | undefined> = {
|
|
|
169
168
|
F20: 34,
|
|
170
169
|
};
|
|
171
170
|
|
|
172
|
-
const ARROW_LETTER: Record<string,
|
|
171
|
+
const ARROW_LETTER: Record<string, CursorKeyLetter | undefined> = {
|
|
173
172
|
ArrowUp: "A",
|
|
174
173
|
ArrowDown: "B",
|
|
175
174
|
ArrowRight: "C",
|
package/src/mouse.ts
CHANGED
|
@@ -264,10 +264,20 @@ function onWheel(e: WheelEvent): void {
|
|
|
264
264
|
if (!pos) {
|
|
265
265
|
return;
|
|
266
266
|
}
|
|
267
|
-
// Wheel up = button 64, wheel down = button 65
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
267
|
+
// Wheel up = button 64, wheel down = button 65. A gesture with NO vertical
|
|
268
|
+
// component — a horizontal trackpad swipe, or shift-wheel where the browser
|
|
269
|
+
// routes the delta to deltaX — has no wheel button to report: xterm.js's
|
|
270
|
+
// reference encoder returns before encoding when the vertical delta is zero,
|
|
271
|
+
// while its wheel handler still calls preventDefault unconditionally. So
|
|
272
|
+
// parity is "report nothing, still swallow the gesture": a terminal owns the
|
|
273
|
+
// wheel, and letting a sideways swipe through would scroll the page instead.
|
|
274
|
+
// Reporting 65 here (the pre-fix behavior, `deltaY < 0 ? 64 : 65` with no
|
|
275
|
+
// zero case) scrolled the TUI DOWN on a sideways gesture.
|
|
276
|
+
if (e.deltaY !== 0) {
|
|
277
|
+
const btn = e.deltaY < 0 ? 64 : 65;
|
|
278
|
+
const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
|
|
279
|
+
handler.send(encodeSGR(code, pos.col, pos.row, false));
|
|
280
|
+
}
|
|
271
281
|
e.preventDefault();
|
|
272
282
|
}
|
|
273
283
|
|
package/src/render.ts
CHANGED
|
@@ -2309,19 +2309,48 @@ function rowTopInTermWrap(el: HTMLElement): number {
|
|
|
2309
2309
|
return top;
|
|
2310
2310
|
}
|
|
2311
2311
|
|
|
2312
|
+
// rowTopFor resolves the top of the row holding absolute line `abs`, in
|
|
2313
|
+
// termWrap's coordinate space. Three positioners need that answer — the caret
|
|
2314
|
+
// overlay, the predicted-cursor overlay, and `getCursorPx` for the consumer's
|
|
2315
|
+
// IME view and hidden textarea — and they must not disagree, because they
|
|
2316
|
+
// describe the same row. They did: `getCursorPx` fell back to a bare `padT`
|
|
2317
|
+
// while the two that PAINT fell back to grid arithmetic, and `rowEls` is
|
|
2318
|
+
// populated only by the main-screen row builder (`renderAlt` clears it), so for
|
|
2319
|
+
// an ENTIRE alt-screen session the caret painted on the right row while the
|
|
2320
|
+
// consumer was told the top of the terminal every frame.
|
|
2321
|
+
//
|
|
2322
|
+
// Three cases, in order:
|
|
2323
|
+
// - the map holds a built row: its own DOM offset, walked into termWrap space.
|
|
2324
|
+
// - no row for a real index (an alt-screen grid row, or a main row this flush
|
|
2325
|
+
// has not built yet): uniform-grid arithmetic from the window base.
|
|
2326
|
+
// - `abs < 0`: not a row at all. That is the state
|
|
2327
|
+
// `collapseContentSpaceOverlays` leaves behind, where the cursor genuinely
|
|
2328
|
+
// has no row and the content origin IS the accurate answer; the arithmetic
|
|
2329
|
+
// would put the overlay a cell above it.
|
|
2330
|
+
function rowTopFor(abs: number): number {
|
|
2331
|
+
const el = rowEls.get(abs);
|
|
2332
|
+
if (el) {
|
|
2333
|
+
return rowTopInTermWrap(el);
|
|
2334
|
+
}
|
|
2335
|
+
const { padT } = termPadding();
|
|
2336
|
+
if (abs < 0) {
|
|
2337
|
+
return padT;
|
|
2338
|
+
}
|
|
2339
|
+
return padT + (abs - store.getWindow().base) * cellHeight;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2312
2342
|
/**
|
|
2313
2343
|
* Returns the cursor's pixel position relative to the scroll container
|
|
2314
2344
|
* (termWrap) — the coordinate space of the absolutely-positioned overlays
|
|
2315
2345
|
* (predicted-cursor, IME composition view) — plus the current cell height.
|
|
2316
|
-
* Uses the cursor row's actual DOM offset
|
|
2346
|
+
* Uses the cursor row's actual DOM offset, or the grid position of a row that
|
|
2347
|
+
* has none (see rowTopFor).
|
|
2317
2348
|
*/
|
|
2318
2349
|
export function getCursorPx(): { left: number; top: number; cellH: number } {
|
|
2319
|
-
const { padL
|
|
2320
|
-
const el = rowEls.get(cursorAbs);
|
|
2321
|
-
const top = el ? rowTopInTermWrap(el) : padT;
|
|
2350
|
+
const { padL } = termPadding();
|
|
2322
2351
|
return {
|
|
2323
2352
|
left: Math.round(padL + cursorCol * cellWidth),
|
|
2324
|
-
top: Math.round(
|
|
2353
|
+
top: Math.round(rowTopFor(cursorAbs)),
|
|
2325
2354
|
cellH: cellHeight,
|
|
2326
2355
|
};
|
|
2327
2356
|
}
|
|
@@ -2381,15 +2410,13 @@ function positionCursorOverlay(runs: readonly WireRun[] | undefined): void {
|
|
|
2381
2410
|
el.className = "term-cursor-overlay";
|
|
2382
2411
|
return;
|
|
2383
2412
|
}
|
|
2384
|
-
const
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
//
|
|
2388
|
-
//
|
|
2389
|
-
//
|
|
2390
|
-
|
|
2391
|
-
// regardless of which ancestor is the rows' offsetParent.
|
|
2392
|
-
const top = rowEl ? rowTopInTermWrap(rowEl) : padT + (cursorAbs - win.base) * cellHeight;
|
|
2413
|
+
const { padL } = termPadding();
|
|
2414
|
+
// Alt-screen rows are not registered in rowEls; their grid is uniform, so the
|
|
2415
|
+
// row offset derives from the window-relative row index. Main-screen rows
|
|
2416
|
+
// exist by the time this runs (the flush force-builds the cursor row).
|
|
2417
|
+
// rowTopFor is shared with getCursorPx so the consumer's IME view cannot be
|
|
2418
|
+
// told a different row than the one the caret paints on.
|
|
2419
|
+
const top = rowTopFor(cursorAbs);
|
|
2393
2420
|
const ch = glyphAt(runs, cursorCol);
|
|
2394
2421
|
// A wide glyph (CJK, emoji) owns two cells; the overlay covers both, like
|
|
2395
2422
|
// the old inline cursor span did via its continuation-spacer adjustment.
|
|
@@ -2433,9 +2460,14 @@ export function setPredictedCursor(row: number, col: number, active: boolean): v
|
|
|
2433
2460
|
el.classList.remove("visible");
|
|
2434
2461
|
return;
|
|
2435
2462
|
}
|
|
2436
|
-
const { padL
|
|
2437
|
-
|
|
2438
|
-
|
|
2463
|
+
const { padL } = termPadding();
|
|
2464
|
+
// Same resolver as the caret and getCursorPx. A row the flush has not built
|
|
2465
|
+
// yet answers from grid arithmetic, which for `predAbs >= 0` is exactly the
|
|
2466
|
+
// `padT + row * cellHeight` this used to compute inline. A prediction BELOW
|
|
2467
|
+
// the first line of the session (`predAbs < 0`) now lands on the content
|
|
2468
|
+
// origin instead of a negative top: that row does not exist, and an overlay
|
|
2469
|
+
// at a negative offset paints over the terminal's top padding.
|
|
2470
|
+
const top = rowTopFor(predAbs);
|
|
2439
2471
|
el.style.left = `${Math.round(padL + col * cellWidth)}px`;
|
|
2440
2472
|
el.style.top = `${Math.round(top)}px`;
|
|
2441
2473
|
el.style.width = `${cellWidth}px`;
|
package/src/store.ts
CHANGED
|
@@ -1262,10 +1262,12 @@ export class LineStore {
|
|
|
1262
1262
|
const tailKeys = [...this.lines.keys()]
|
|
1263
1263
|
.filter((k) => !this.browse.has(k))
|
|
1264
1264
|
.sort((a, b) => a - b);
|
|
1265
|
+
// `excess >= 1` here, so no guard: `tailCount` is `lines.size -
|
|
1266
|
+
// browse.size` while `tailKeys.length` is `lines.size` minus the browse
|
|
1267
|
+
// keys that are ACTUALLY in `lines`, so `tailKeys.length >= tailCount`
|
|
1268
|
+
// unconditionally — and reaching this line means the gate above found
|
|
1269
|
+
// `tailCount > keep`.
|
|
1265
1270
|
const excess = tailKeys.length - keep;
|
|
1266
|
-
if (excess <= 0) {
|
|
1267
|
-
return false;
|
|
1268
|
-
}
|
|
1269
1271
|
const winFloor = this.win.height > 0 ? this.win.base : Number.POSITIVE_INFINITY;
|
|
1270
1272
|
return this.classifyAsBrowse(tailKeys.slice(0, excess).filter((k) => k < winFloor)) > 0;
|
|
1271
1273
|
}
|
|
@@ -1445,7 +1447,6 @@ export class LineStore {
|
|
|
1445
1447
|
this.everEvictedThrough = -1;
|
|
1446
1448
|
this.erasedThrough = -1;
|
|
1447
1449
|
this.unconfirmedFrom = Number.POSITIVE_INFINITY;
|
|
1448
|
-
this.paging = false;
|
|
1449
1450
|
this.serverOldest = -1;
|
|
1450
1451
|
this.win = emptyWindow();
|
|
1451
1452
|
this.alt = false;
|
|
@@ -1825,6 +1826,17 @@ export class LineStore {
|
|
|
1825
1826
|
if (this.highest < 0) {
|
|
1826
1827
|
return;
|
|
1827
1828
|
}
|
|
1829
|
+
// No window established yet — the descriptor is still the initial base
|
|
1830
|
+
// 0/height 0, whose bottom row is -1, below every index a line can hold.
|
|
1831
|
+
// This whole pass rests on "the window's bottom row is the most recent line
|
|
1832
|
+
// in the terminal", which says nothing at all when there is no window: a
|
|
1833
|
+
// resume replays history BEFORE the screen frame describing the window, so
|
|
1834
|
+
// taking -1 as the tail bound strands that replay and forgets it, silently
|
|
1835
|
+
// (this path is not a trim, so no marker explains it). Same test `inWindow`
|
|
1836
|
+
// and applyScroll's alt gate use to ask whether a window exists at all.
|
|
1837
|
+
if (this.win.height <= 0) {
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1828
1840
|
const windowBottom = this.win.base + this.win.height - 1;
|
|
1829
1841
|
if (this.highest <= windowBottom) {
|
|
1830
1842
|
return;
|
|
@@ -1933,11 +1945,15 @@ export class LineStore {
|
|
|
1933
1945
|
// keeps the classification consistent if one ever does.)
|
|
1934
1946
|
this.everEvictedThrough = Math.max(this.everEvictedThrough, victim);
|
|
1935
1947
|
}
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1948
|
+
// No empty-store arm: the walk cannot evict the last line. The loop
|
|
1949
|
+
// guard's `cursor < this.highest` conjunct stops it once the cursor
|
|
1950
|
+
// reaches the newest key, and with a protected window the sibling
|
|
1951
|
+
// `cursor > winBottom` cannot hold there either. That is the ONLY thing
|
|
1952
|
+
// holding when the target is 0, which `new LineStore(0)` produces
|
|
1953
|
+
// (`evictionBatch(0)` is 1, so `target = 0 - 1 + 1`; the bare
|
|
1954
|
+
// constructor accepts 0, unlike `render.init` and `fromSnapshot`, which
|
|
1955
|
+
// both require a positive integer) — a reason to leave that conjunct
|
|
1956
|
+
// alone.
|
|
1941
1957
|
// Advance to the lowest remaining key above the victim. Contiguous
|
|
1942
1958
|
// history (the common case: live scroll-off, resume replay, a
|
|
1943
1959
|
// cat-bigfile burst) leaves victim + 1 present -- an O(1) probe. Only a
|
|
@@ -2003,6 +2019,18 @@ export class LineStore {
|
|
|
2003
2019
|
}
|
|
2004
2020
|
|
|
2005
2021
|
private updateWindow(msg: ScreenMessage): void {
|
|
2022
|
+
// A base that is not a non-negative integer is not a screen position — the
|
|
2023
|
+
// same validity test markUnconfirmedFrom applies to this very value — so it
|
|
2024
|
+
// must not redefine the window's shape, for the same reason a rows-less
|
|
2025
|
+
// frame must not: `base + height - 1` lands below every retained index, and
|
|
2026
|
+
// truncateBelowWindow then hands the WHOLE store over as stranded past the
|
|
2027
|
+
// live window. That is the silent wipe applyScreen's rows-less guard
|
|
2028
|
+
// records, reached with rows present, and just as silent (this path is not a
|
|
2029
|
+
// trim, so no marker explains the missing transcript). The frame's
|
|
2030
|
+
// addressable rows still apply through applyLine's guard 1.
|
|
2031
|
+
if (!Number.isInteger(msg.base) || msg.base < 0) {
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2006
2034
|
const next: WindowState = {
|
|
2007
2035
|
base: msg.base,
|
|
2008
2036
|
height: msg.rows.length,
|