@cplieger/web-terminal-engine 5.3.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,112 +1,32 @@
1
1
  export * from "./types.js";
2
2
  export { LineStore } from "./store.js";
3
3
  export type { WindowState, StoreChanges, StoreSnapshot } from "./store.js";
4
+ export { createTerminalEngine } from "./terminal.js";
5
+ export type { TerminalEngine, TerminalEngineOptions } from "./terminal.js";
6
+ export { createRenderer } from "./render.js";
7
+ export type { Renderer, RendererOptions, ViewMemory } from "./render.js";
8
+ export { createScrollController } from "./scroll.js";
9
+ export type { ScrollController, ScrollControllerOptions } from "./scroll.js";
10
+ export { createConnection, MAX_REPLAY_LINES, MAX_OUTBOX_BYTES } from "./connection.js";
11
+ export type { Connection, ConnectionOptions, ConnectionCallbacks } from "./connection.js";
12
+ export { createMouseController, encodeSGR } from "./mouse.js";
13
+ export type { MouseController, MouseControllerOptions, MouseInputHandler } from "./mouse.js";
14
+ export { createModeState, POWER_ON_MODES } from "./modes.js";
15
+ export type { ModeState, ModeSnapshot } from "./modes.js";
4
16
  /**
5
- * The DOM renderer: it owns a `LineStore` of absolute-index lines and reflects
6
- * it to one `div.term-row` per line inside a natively-scrolled container, so
7
- * there is no live-zone/scrollback split to reconcile. Reach for it to paint
8
- * decoded frames (`handleScreen`, `handleScroll`), to measure the grid
9
- * (`updateFontMetrics`, `computeSize`, `getCursorPx`), and to swap stores on a
10
- * tab switch (`bind`).
11
- *
12
- * `init` must run once against the two elements before any frame is applied,
13
- * and it always installs a fresh implicit store — so a consumer that manages
14
- * its own stores binds AFTER init, never before. `getHighestIndex` reports how
15
- * far the store's content reaches and is NOT a resume claim; a transport asks
16
- * `getReplayBoundary` for that, and wiring the former is the defect the latter
17
- * exists to prevent.
18
- */
19
- export * as render from "./render.js";
20
- export type { ViewMemory } from "./render.js";
21
- /**
22
- * Key encoding, and nothing else: `mapKeyboardEvent` turns a browser
23
- * `KeyboardEvent` into either the bytes a PTY expects or a local action, and
24
- * the paste helpers (`bracketTextForPaste`, `prepareTextForTerminal`) do the
25
- * same for text. Reach for it when your own input surface — a hidden textarea,
26
- * an IME composition, a paste handler — has an event and needs the wire form.
27
- *
28
- * It touches no DOM and sends nothing: the caller delivers the bytes, and the
29
- * `scroll-up` / `scroll-down` results carry no bytes at all because local
30
- * scrollback navigation is the consumer's to perform. Mode state arrives as an
31
- * explicit `KeyboardModes` argument rather than being read from a global, so a
32
- * tabbed shell encodes against the active session's modes.
17
+ * Key encoding, and nothing else: `mapKeyboardEvent` turns a `KeyboardEvent`
18
+ * into the bytes a PTY expects or a local action, and the paste helpers do the
19
+ * same for text. It touches no DOM and sends nothing; mode state arrives as an
20
+ * explicit argument (an engine's `modes` satisfies it).
33
21
  */
34
22
  export * as keyboard from "./keyboard.js";
35
23
  /**
36
- * The on-screen mobile key toolbar — the one DOM widget in the engine.
37
- * `bindMobileToolbar` wires an existing container's buttons (Ctrl, arrows, Tab,
38
- * Enter, Esc, a collapse toggle) to a send sink and returns a controller owning
39
- * sticky-Ctrl state and tear-down. Reach for it on a touch UI, where the keys a
40
- * physical keyboard supplies have to be painted.
41
- *
42
- * The consumer supplies the markup; the module only looks buttons up by id
43
- * within the container, so a missing button is skipped rather than fatal.
44
- * Sticky Ctrl only takes effect on text routed through
45
- * `controller.applyStickyCtrl` — the toolbar cannot see keystrokes it did not
46
- * emit. Its arrow and Escape bytes come from `keyboard`'s exported logical-key
47
- * encodings, so they cannot drift from the physical-key path.
24
+ * The on-screen mobile key toolbar, the one DOM widget in the engine:
25
+ * `bindMobileToolbar` wires an existing container's buttons to a send sink and
26
+ * a mode state and returns a controller owning sticky-Ctrl state and tear-down.
27
+ * A missing button is skipped rather than fatal.
48
28
  */
49
29
  export * as toolbar from "./toolbar.js";
50
- /**
51
- * The scroll controller: the single owner of the scroll container's
52
- * `scrollTop`, holding one piece of state — whether the viewport is following
53
- * the tail or holding a reading position — derived from scroll events alone.
54
- * Reach for it to `init` the container and for consumer-facing controls: a
55
- * jump-to-bottom button (`scrollToBottom`), and `isUserScrolledUp` to decide
56
- * whether new output deserves an affordance.
57
- *
58
- * Everything else here is the renderer's to call around its own DOM mutations
59
- * (`stickToBottom`, `adjustForContentShift`, `noteContentShrink`). A consumer
60
- * that writes `scrollTop` directly is competing with this module for the same
61
- * value, which is how a held reading position gets lost.
62
- */
63
- export * as scroll from "./scroll.js";
64
- /**
65
- * The DEC private-mode mirror for the ACTIVE session — bracketed paste,
66
- * application cursor and keypad, mouse tracking and its encoding, focus
67
- * reporting, reverse video, kitty keyboard flags. Reach for it to read what the
68
- * application has turned on, most often to decide whether a paste needs
69
- * bracketing or whether a mouse gesture belongs to the terminal at all.
70
- *
71
- * It is a module singleton with one writer: `connection` applies every inbound
72
- * modes frame and, in a tabbed shell, restores the target session's snapshot
73
- * inside `setSession`. A consumer that calls `setModes` is racing that writer.
74
- * Values lag the server by the one frame in flight, and an unseen session
75
- * starts at `POWER_ON_MODES`, where bracketed paste is already ON so the first
76
- * paste cannot be delivered as typed input.
77
- */
78
- export * as modes from "./modes.js";
79
- /**
80
- * Mouse input for the terminal: SGR 1006 encoding (`encodeSGR`) plus `init`,
81
- * which attaches pointer and wheel listeners to an element and returns an
82
- * idempotent disposer. Reach for it when the terminal element should forward
83
- * clicks, drags and wheel ticks to the application rather than the page.
84
- *
85
- * The listeners gate on the mode state, so they are inert until the
86
- * application enables tracking — which is also why selection and page
87
- * scrolling keep working in a normal shell. Reports are best-effort: one
88
- * describes a screen a resume may since have repainted, so a refused report is
89
- * forgotten rather than retried. Focus is not mouse input and lives in
90
- * `connection.setClientFocus`.
91
- */
92
- export * as mouse from "./mouse.js";
93
- /**
94
- * The client-to-server transport. It owns the socket, the jittered
95
- * exponential-backoff reconnect, and the resume/inputAck layer that makes input
96
- * survive a reconnect: a bounded outbox retransmitted against the server's
97
- * acked byte count, resume by absolute index, and server-restart detection via
98
- * the boot epoch. Reach for it to `init` the callbacks, `connect`, send input
99
- * (`sendBinary`), announce geometry (`sendResize`), and switch sessions
100
- * (`setSession`).
101
- *
102
- * It decodes frames and applies mode changes itself, so a consumer's
103
- * `onMessage` only has to route screen and scroll to the renderer. Supply as
104
- * few of the resume and history callbacks as possible: an omitted one gets the
105
- * renderer's own answer, read per resume rather than captured, and overriding
106
- * one means deciding a local fact on the engine's behalf — `getReplayMax`, how
107
- * much history the client keeps resident, is the one override that is expected.
108
- */
109
- export * as connection from "./connection.js";
110
30
  export { decodeWireBinary } from "./wire-binary.js";
111
31
  export {
112
32
  MIN_SUPPORTED_SERVER_WIRE_VERSION,
package/src/keyboard.ts CHANGED
@@ -1,20 +1,9 @@
1
- // Keyboard event → terminal byte sequence mapping.
2
- //
3
- // Mirrors xterm.js's evaluateKeyboardEvent (src/common/input/Keyboard.ts):
4
- // every browser KeyboardEvent maps to either a sequence to send to the
5
- // PTY or a local action (page-up/down for local scrollback nav). The
6
- // mapping is exhaustive over xterm.js's coverage so vim/readline/Ink/
7
- // the host application get the keys they expect.
8
- //
9
- // Modifier encoding follows xterm.js / VT520 convention:
10
- // 1=Shift, 2=Alt, 4=Ctrl, 8=Meta. Sum then +1 for CSI 1;{n}{letter}.
11
- //
12
- // Application cursor mode (DECCKM, CSI ?1h/l) is tracked client-side
13
- // via modes.ts (announced by server in wireMsgModes). Server-side, the
14
- // vt screen tracks both bracketed paste (?2004) and DECCKM (?1) and
15
- // emits a modes frame whenever they change.
16
-
17
- import { getKeyboardFlags, isBracketedPaste } from "./modes.js";
1
+ // Keyboard event → terminal byte sequence mapping, mirroring xterm.js's
2
+ // evaluateKeyboardEvent (src/common/input/Keyboard.ts) so vim, readline, Ink
3
+ // and the host application get the keys they expect. Modifier digit per the
4
+ // xterm/VT520 convention: 1=Shift, 2=Alt, 4=Ctrl, 8=Meta, summed then +1 for
5
+ // CSI 1;{n}{letter}. DECCKM and DECKPAM come from the injected mode state,
6
+ // which the server's modes frame keeps current.
18
7
 
19
8
  /** Result of mapping a keyboard event. */
20
9
  export type KeyboardResult =
@@ -26,10 +15,8 @@ export type KeyboardResult =
26
15
  /**
27
16
  * KeyboardModes is the mode state `mapKeyboardEvent` reads: DECCKM (application
28
17
  * cursor keys), DECKPAM (application keypad), and the kitty keyboard
29
- * progressive-enhancement flags. Passed explicitly so the shared input maps
30
- * against the active tab's modes and so the mapping is testable without mutating
31
- * global state. The `modes` module namespace satisfies this structurally; a
32
- * tabbed shell passes its active session's modes.
18
+ * progressive-enhancement flags. A `ModeState` satisfies it; a consumer passes
19
+ * the state of the terminal the key belongs to.
33
20
  */
34
21
  export interface KeyboardModes {
35
22
  isApplicationCursor: () => boolean;
@@ -59,17 +46,10 @@ type CursorKeyLetter = "A" | "B" | "C" | "D" | "H" | "F";
59
46
 
60
47
  /**
61
48
  * plainCursorKeySeq is THE encoding of an unmodified logical cursor-key press
62
- * (arrows, Home, End) — the one home for the mode decision, shared by the
63
- * physical-key path (csiLetter's modifier-less branch) and the mobile
64
- * toolbar's arrow buttons so the two encoders cannot drift:
65
- * - kitty disambiguate active: the CSI form regardless of DECCKM (the
66
- * protocol supersedes cursor-key mode);
67
- * - DECCKM (application cursor mode): the SS3 form (ESC O letter);
68
- * - otherwise: the bare CSI form.
69
- *
70
- * Both mode bits are PARAMETERS (not read from the module-global mode state):
71
- * mapKeyboardEvent honors an injected KeyboardModes, and a helper silently
72
- * consulting the global would diverge from it in a tabbed shell.
49
+ * (arrows, Home, End), shared by the physical-key path and the mobile toolbar's
50
+ * arrow buttons so the two encoders cannot drift: the CSI form under kitty
51
+ * disambiguate (the protocol supersedes cursor-key mode), the SS3 form
52
+ * (ESC O letter) under DECCKM, the bare CSI form otherwise.
73
53
  */
74
54
  export function plainCursorKeySeq(
75
55
  letter: CursorKeyLetter,
@@ -94,40 +74,22 @@ export function plainEscapeSeq(kittyActive: boolean): string {
94
74
  return kittyActive ? `${ESC}[27u` : ESC;
95
75
  }
96
76
 
97
- /** kittyActiveIn reads the disambiguate flag from an INJECTED KeyboardModes
98
- * (the seam mapKeyboardEvent honors), as opposed to kittyDisambiguateActive,
99
- * which reads the module-global active-session facade used by the toolbar.
100
- * connection.setSession restores that facade from the target session's
101
- * snapshot synchronously before switch-window input can be encoded. */
102
- function kittyActiveIn(modes: KeyboardModes): boolean {
77
+ /** True when the kitty disambiguate flag is active in `modes`. */
78
+ export function kittyDisambiguateActive(modes: KeyboardModes): boolean {
103
79
  return (modes.getKeyboardFlags() & KITTY_DISAMBIGUATE) !== 0;
104
80
  }
105
81
 
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.
115
- //
116
- // Application cursor mode (DECCKM, CSI ?1) is plumbed via modes.ts.
117
- // When the application has set DECCKM, the modifier-less form switches
118
- // from CSI to SS3 (ESC O letter) via plainCursorKeySeq; modifier-bearing
119
- // forms stay on CSI because they have no SS3 equivalent.
82
+ // The keys that honor DECCKM (arrows, Home, End): bare CSI or SS3 unmodified,
83
+ // CSI 1;{mod}{letter} with modifiers, which have no SS3 equivalent. The
84
+ // `CursorKeyLetter` parameter type makes "every letter here honors DECCKM" a
85
+ // compiler guarantee; F1-F4's SS3/CSI pair is encoded at its own call site.
120
86
  function csiLetter(letter: CursorKeyLetter, ev: KeyboardEvent, modes: KeyboardModes): string {
121
87
  const m = modifiersDigit(ev);
122
88
  if (m === 1) {
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());
89
+ // Read from `modes` rather than hard-coded false (mapKeyboardEvent returns
90
+ // through encodeKittyDisambiguate first) so a change to that early return
91
+ // cannot mis-encode silently.
92
+ return plainCursorKeySeq(letter, kittyDisambiguateActive(modes), modes.isApplicationCursor());
131
93
  }
132
94
  return `${ESC}[1;${m}${letter}`;
133
95
  }
@@ -199,18 +161,12 @@ const KEYPAD_SS3: Record<string, string | undefined> = {
199
161
  };
200
162
 
201
163
  // -- Kitty keyboard protocol (progressive enhancement) ----------------------
202
- // When the server reports an active kitty flag (modes.getKeyboardFlags), keys
203
- // that do NOT produce text are encoded per the kitty protocol instead of the
204
- // legacy encodings, so an app that enabled the protocol (e.g. Codex via
205
- // crossterm) gets unambiguous key events. We honor the disambiguate flag (0x1)
206
- // — report-event-types (0x2) / report-alternate-keys (0x4) / report-all (0x8) /
207
- // text (0x10) are masked off server-side, so the CSI ?u query reports only 0x1
208
- // and the encoder never has to emit them.
209
- //
210
- // Under disambiguate, text-producing keys still flow through the hidden
211
- // textarea as text (this encoder returns "ignore" for them), matching both the
212
- // spec (text keys stay text under 0x1) and our IME/composition model. Only
213
- // Escape, ctrl/alt/meta combinations, and functional keys are re-encoded.
164
+ // Under an active kitty flag, keys that do NOT produce text are encoded per the
165
+ // kitty protocol so an app that enabled it (Codex via crossterm) gets
166
+ // unambiguous key events. Only the disambiguate flag (0x1) is honored; the
167
+ // server masks off 0x2/0x4/0x8/0x10, so the CSI ?u query reports only 0x1.
168
+ // Text-producing keys still flow through the hidden textarea as text (this
169
+ // encoder returns "ignore" for them), per the spec and the IME model.
214
170
 
215
171
  /** Kitty progressive-enhancement flag bits (mirror vt/kitty.go). */
216
172
  const KITTY_DISAMBIGUATE = 1;
@@ -339,17 +295,13 @@ export function kittyCtrlCharSeq(ch: string): string | null {
339
295
  return `${ESC}[${cp};${shifted ? 6 : 5}u`; // 5 = ctrl, 6 = ctrl+shift
340
296
  }
341
297
 
342
- // Keypad NON-TEXT keys under disambiguate, keyed by ev.key (the key's current
343
- // function — NumLock flips a numpad key between its text digit, which stays text
344
- // under 0x1, and these navigation functions). ev.code identifies it as a keypad
345
- // key. Per the spec these get dedicated KP_* codes (57414-57427) so an app can
346
- // tell them apart from the main navigation keys. NumpadEnter is included as
347
- // KP_ENTER (57414) — it is a distinct key, NOT the legacy-byte main Enter.
348
- // KP_BEGIN (Numpad5, NumLock off -> "Clear") is emitted as 57427 in the `u`
349
- // form: both kitty and crossterm decode `CSI 57427 u`, whereas the spec's
350
- // alternate `CSI E` letter form is kitty-only (crossterm/ratatui can't parse it).
351
- // The KP_ DIGIT/operator codes (57399-57416) are deliberately absent — they are
352
- // a report-all (0x8) feature; under 0x1 a NumLock-on numpad digit is plain text.
298
+ // Keypad NON-TEXT keys under disambiguate, keyed by ev.key (NumLock flips a
299
+ // numpad key between its text digit, which stays text under 0x1, and these
300
+ // functions), with the spec's dedicated KP_* codes (57414-57427). NumpadEnter
301
+ // is KP_ENTER, a distinct key from the legacy-byte main Enter. KP_BEGIN is
302
+ // emitted in the `u` form because the spec's alternate `CSI E` letter form is
303
+ // kitty-only (crossterm/ratatui cannot parse it). The KP_ digit/operator codes
304
+ // (57399-57416) are a report-all (0x8) feature and are deliberately absent.
353
305
  const KITTY_KEYPAD: Record<string, number | undefined> = {
354
306
  ArrowLeft: 57417, // KP_LEFT
355
307
  ArrowRight: 57418, // KP_RIGHT
@@ -490,28 +442,12 @@ function encodeKittyDisambiguate(ev: KeyboardEvent): KeyboardResult {
490
442
  return { kind: "ignore" };
491
443
  }
492
444
 
493
- /** True when the kitty disambiguate flag is active in the GLOBAL
494
- * active-session mode facade. Exported for the toolbar widget (toolbar.ts);
495
- * connection.setSession synchronously restores the target session's cached
496
- * snapshot before input resumes. Encoder paths in this module use
497
- * kittyActiveIn (the injected-modes read) instead. */
498
- export function kittyDisambiguateActive(): boolean {
499
- return (getKeyboardFlags() & KITTY_DISAMBIGUATE) !== 0;
500
- }
501
-
502
445
  /**
503
- * mapKeyboardEvent converts a KeyboardEvent into the terminal action
504
- * to take. Returns "ignore" when the event is purely a modifier press
505
- * or when the browser should be allowed to handle it (e.g. browser
506
- * shortcuts like Cmd+R).
507
- *
508
- * Caller is responsible for ev.preventDefault() when the result is
509
- * "send" or "scroll-*"; we don't call it here so the function stays
510
- * pure and testable.
511
- *
512
- * `modes` supplies the active session's DECCKM/DECKPAM state; pass the `modes`
513
- * module namespace for the single-terminal case or the active tab's modes in a
514
- * tabbed shell.
446
+ * mapKeyboardEvent converts a KeyboardEvent into the terminal action to take,
447
+ * read against `modes`, the mode state of the terminal the key belongs to.
448
+ * Returns "ignore" for a bare modifier press or a key the browser should keep
449
+ * (Cmd+R). The caller calls `ev.preventDefault()` on "send" and "scroll-*";
450
+ * this function stays pure.
515
451
  */
516
452
  export function mapKeyboardEvent(ev: KeyboardEvent, modes: KeyboardModes): KeyboardResult {
517
453
  // Modifier-only presses (Shift, Ctrl, Alt, Meta) — no-op.
@@ -683,22 +619,10 @@ export function mapKeyboardEvent(ev: KeyboardEvent, modes: KeyboardModes): Keybo
683
619
  }
684
620
 
685
621
  /**
686
- * ctrlByteFor returns the C0 control byte produced by Ctrl+`ch`, or
687
- * `null` when the character has no Ctrl mapping. The full table:
688
- *
689
- * a-z (case-folded) → \x01..\x1a (Ctrl+A=SOH .. Ctrl+Z=SUB)
690
- * ' ' (space) → \x00 (NUL — same as Ctrl+@)
691
- * '@' → \x00 (NUL)
692
- * '[' → \x1b (ESC)
693
- * '\\' → \x1c (FS)
694
- * ']' → \x1d (GS)
695
- * '^' → \x1e (RS)
696
- * '_' → \x1f (US)
697
- * '?' → \x7f (DEL — also Ctrl+8 on US layouts via Shift+/)
698
- *
699
- * Anything else (multi-char strings, unmapped single chars) returns
700
- * `null`. Used by `mapKeyboardEvent` for Ctrl+printable handling and
701
- * by `bindMobileToolbar`'s sticky-Ctrl applier.
622
+ * ctrlByteFor returns the C0 control byte produced by Ctrl+`ch`, or `null`
623
+ * when the character has no Ctrl mapping: a-z (case-folded) give \x01..\x1a,
624
+ * space and '@' give NUL, '[' '\\' ']' '^' '_' give \x1b..\x1f, '?' gives DEL.
625
+ * A multi-character string or an unmapped character returns `null`.
702
626
  */
703
627
  export function ctrlByteFor(ch: string): string | null {
704
628
  if (ch.length !== 1) {
@@ -774,21 +698,20 @@ function numpadCodeToKey(code: string): string | undefined {
774
698
 
775
699
  // -- Bracketed paste --------------------------------------------------------
776
700
 
701
+ /** The mode bit `bracketTextForPaste` reads; a `ModeState` satisfies it. */
702
+ export interface PasteModes {
703
+ isBracketedPaste(): boolean;
704
+ }
705
+
777
706
  /**
778
- * bracketTextForPaste wraps text in DEC 2004 bracketed-paste sentinels
779
- * after sanitising any embedded ESC bytes to U+241B (visible escape
780
- * symbol), but only when the application has currently enabled
781
- * bracketed-paste mode (CSI ?2004h). When disabled, returns the text
782
- * unchanged. The current mode state is owned by modes.ts, kept in
783
- * sync by the server's wireMsgModes wire frame.
784
- *
785
- * The ESC sanitisation defends against an attacker-controlled paste
786
- * containing \x1b[201~ that would prematurely close the paste region
787
- * and let the rest be interpreted as a command — only relevant when
788
- * we are bracketing.
707
+ * bracketTextForPaste wraps text in DEC 2004 bracketed-paste sentinels when
708
+ * `modes` reports bracketed-paste mode (CSI ?2004h) enabled, and returns it
709
+ * unchanged otherwise. Embedded ESC bytes are replaced with U+241B first: an
710
+ * attacker-controlled paste containing \x1b[201~ would otherwise close the
711
+ * paste region early and have the rest interpreted as a command.
789
712
  */
790
- export function bracketTextForPaste(text: string): string {
791
- if (!isBracketedPaste()) {
713
+ export function bracketTextForPaste(text: string, modes: PasteModes): string {
714
+ if (!modes.isBracketedPaste()) {
792
715
  return text;
793
716
  }
794
717
  // eslint-disable-next-line no-control-regex -- intentional: sanitising ESC bytes in pasted text