buninu 0.5.0 → 0.6.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.
@@ -10,4 +10,10 @@ html, body, #terminal {
10
10
  transition: unset;
11
11
  transition-duration: 0.1s;
12
12
  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
13
- }
13
+ }
14
+
15
+ /* Keep touch taps available to xterm even with a widened WebView viewport.
16
+ Include the viewport (a scroll container); preserve panning and pinch zoom. */
17
+ #terminal, #terminal .xterm, #terminal .xterm-viewport, #terminal .xterm-screen {
18
+ touch-action: manipulation;
19
+ }
@@ -12168,7 +12168,8 @@
12168
12168
  (t.msgSetWindowTitle = "3"),
12169
12169
  (t.msgSetPreferences = "4"),
12170
12170
  (t.msgSetReconnect = "5"),
12171
- (t.msgSetBufferSize = "6"));
12171
+ (t.msgSetBufferSize = "6"),
12172
+ (t.msgRestoreModes = "m"));
12172
12173
  t.WebTTY = class {
12173
12174
  term;
12174
12175
  connectionFactory;
@@ -12193,6 +12194,9 @@
12193
12194
  this.reconnectDeadline = 0;
12194
12195
  this.reconnectAttempts = 0;
12195
12196
  this.zmodemExpectedSeq = 0;
12197
+ this.term.onClipboard?.(data => {
12198
+ if (!this.disconnected) this.connection.send("C" + data);
12199
+ });
12196
12200
  }
12197
12201
  open() {
12198
12202
  let e;
@@ -12330,6 +12334,9 @@
12330
12334
  const e = JSON.parse(i);
12331
12335
  this.term.setPreferences(e);
12332
12336
  break;
12337
+ case t.msgRestoreModes:
12338
+ this.term.applySessionModes(JSON.parse(i));
12339
+ break;
12333
12340
  case t.msgSetReconnect:
12334
12341
  const r = JSON.parse(i);
12335
12342
  "number" == typeof r
@@ -12540,12 +12547,30 @@
12540
12547
  })),
12541
12548
  (this.fitAddOn = new s.FitAddon()),
12542
12549
  (this.zmodemAddon = new a.ZModemAddon({
12543
- toTerminal: (e) => this.term.write(e),
12550
+ toTerminal: (e) => this.writeTerminal(e),
12544
12551
  toServer: (e) => this.sendInput(e),
12545
12552
  })),
12546
12553
  (this.kittyKeyboard = new globalThis.GoTTYKittyKeyboard.KittyKeyboard(this.term, {
12547
12554
  android: /Android/i.test(navigator.userAgent),
12548
12555
  })),
12556
+ this.term.parser.registerOscHandler(52, data => {
12557
+ if (!this.disconnected && data.length <= 1048576) this.clipboardHandler?.(data);
12558
+ return true;
12559
+ }),
12560
+ this.term.parser.registerOscHandler(777, data => {
12561
+ const match = /^jsgotty-modes;([a-f0-9]{32})$/.exec(data);
12562
+ if (!match) {
12563
+ const restore = /^jsgotty-kitty-v1;(normal|alternate);(\d{1,2});(\d{1,2}(?:,\d{1,2})*)?$/.exec(data);
12564
+ if (!restore) return false;
12565
+ const flags = +restore[2], stack = restore[3] ? restore[3].split(',').map(Number) : [];
12566
+ if (flags > 31 || stack.length > 32 || stack.some(n => n > 31)) return true;
12567
+ this.kittyKeyboard.states[restore[1]] = {flags: flags & 11, stack: stack.map(n => n & 11)};
12568
+ return true;
12569
+ }
12570
+ this.term.input("\x1b]777;jsgotty-modes;" + match[1] + ";" +
12571
+ btoa(JSON.stringify({...this.sessionModes(), kittyRestore: 1})) + "\x07", false);
12572
+ return true;
12573
+ }),
12549
12574
  this.term.attachCustomKeyEventHandler((event) =>
12550
12575
  this.kittyKeyboard.handle(event) && this.handleAndroidAltKey(event),
12551
12576
  ),
@@ -12676,8 +12701,73 @@
12676
12701
  getMessage() {
12677
12702
  return this.message;
12678
12703
  }
12704
+ enqueueTerminal(task) {
12705
+ this.terminalTasks ||= [];
12706
+ this.terminalTasks.push(task);
12707
+ if (this.terminalBusy) return;
12708
+ this.terminalBusy = true;
12709
+ const next = () => {
12710
+ const task = this.terminalTasks.shift();
12711
+ if (!task) { this.terminalBusy = false; return; }
12712
+ task(next);
12713
+ };
12714
+ next();
12715
+ }
12716
+ writeTerminal(data) {
12717
+ this.enqueueTerminal(done => this.term.write(data, done));
12718
+ }
12719
+ onClipboard(handler) {
12720
+ this.clipboardHandler = handler;
12721
+ }
12722
+ sessionModes() {
12723
+ const core = this.term._core;
12724
+ return {dec: {...core.coreService.decPrivateModes},
12725
+ mouse: core.coreMouseService.activeProtocol,
12726
+ encoding: core.coreMouseService.activeEncoding,
12727
+ cursorHidden: !!core.coreService.isCursorHidden,
12728
+ alternate: this.term.buffer.active.type === "alternate",
12729
+ kitty: JSON.parse(JSON.stringify(this.kittyKeyboard.states))};
12730
+ }
12731
+ applySessionModes(state) {
12732
+ this.enqueueTerminal(done => this.term.write("", () => this.restoreSessionModes(state, done)));
12733
+ }
12734
+ restoreSessionModes(state, done) {
12735
+ if (!state || !state.dec) { done(); return; }
12736
+ const core = this.term._core;
12737
+ const dec = core.coreService.decPrivateModes;
12738
+ const mouse = core.coreMouseService;
12739
+ // A socket-only reconnect keeps xterm's modes, so send only differences:
12740
+ // DECOM homes the cursor and 1049 overwrites the saved cursor.
12741
+ let sequence = "";
12742
+ const alternate = this.term.buffer.active.type === "alternate";
12743
+ if (!!state.alternate !== alternate) sequence += `\x1b[?1049${state.alternate ? "h" : "l"}`;
12744
+ const flags = {1:"applicationCursorKeys", 6:"origin", 7:"wraparound", 45:"reverseWraparound",
12745
+ 66:"applicationKeypad", 1004:"sendFocus", 2004:"bracketedPasteMode"};
12746
+ for (const [mode, name] of Object.entries(flags))
12747
+ if (!!state.dec[name] !== !!dec[name]) sequence += `\x1b[?${mode}${state.dec[name] ? "h" : "l"}`;
12748
+ if (!!state.cursorHidden !== !!core.coreService.isCursorHidden)
12749
+ sequence += `\x1b[?25${state.cursorHidden ? "l" : "h"}`;
12750
+ if (state.mouse !== mouse.activeProtocol) {
12751
+ const modes = {X10:9, VT200:1000, DRAG:1002, ANY:1003};
12752
+ sequence += modes[state.mouse] ? `\x1b[?${modes[state.mouse]}h` : "\x1b[?1000l";
12753
+ }
12754
+ if (state.encoding !== mouse.activeEncoding) {
12755
+ const modes = {SGR:1006, SGR_PIXELS:1016};
12756
+ sequence += modes[state.encoding] ? `\x1b[?${modes[state.encoding]}h` : "\x1b[?1006l";
12757
+ }
12758
+ const complete = () => {
12759
+ // Assigned rather than replayed so the push stack never doubles; the server
12760
+ // keeps every flag, so mask to the ones kitty-keyboard.js implements.
12761
+ const supported = (s) => ({flags: s.flags & 11, stack: s.stack.map((f) => f & 11)});
12762
+ if (state.kitty?.normal && state.kitty?.alternate)
12763
+ this.kittyKeyboard.states = {normal: supported(state.kitty.normal), alternate: supported(state.kitty.alternate)};
12764
+ done();
12765
+ };
12766
+ if (sequence) this.term.write(sequence, complete);
12767
+ else complete();
12768
+ }
12679
12769
  setDisconnected(e) {
12680
- if (e) this.kittyKeyboard.reset();
12770
+ if (e) this.kittyKeyboard.clearHeld(false);
12681
12771
  this.disconnected = !!e;
12682
12772
  }
12683
12773
  setReconnectAction(e) {
@@ -12789,7 +12879,8 @@
12789
12879
  this.term.blur();
12790
12880
  }
12791
12881
  reset() {
12792
- this.kittyKeyboard.reset();
12882
+ // Reconnecting the transport does not reset the running PTY's modes.
12883
+ this.kittyKeyboard.clearHeld(false);
12793
12884
  (this.removeMessage(), this.term.clear());
12794
12885
  }
12795
12886
  close() {
@@ -0,0 +1,332 @@
1
+ "use strict";
2
+
3
+ // All flags defined by the kitty keyboard protocol. Kept unmasked so a CLI
4
+ // client on a real kitty terminal can restore them; the browser masks its own.
5
+ const KITTY_KEYBOARD_FLAGS = 0x1f;
6
+ const TRACKED_DEC_FLAGS = {
7
+ 1: "applicationCursorKeys",
8
+ 6: "origin",
9
+ 7: "wraparound",
10
+ 45: "reverseWraparound",
11
+ 66: "applicationKeypad",
12
+ 1004: "sendFocus",
13
+ 2004: "bracketedPasteMode",
14
+ };
15
+ const MOUSE_PROTOCOLS = { 9: "X10", 1000: "VT200", 1002: "DRAG", 1003: "ANY" };
16
+ const MOUSE_ENCODINGS = { 1006: "SGR", 1016: "SGR_PIXELS" };
17
+
18
+ // Mirrors the PTY's input/protocol modes so a resumed client can be brought
19
+ // back in sync. Mode semantics follow xterm.js, which renders the output.
20
+ class TerminalModeTracker {
21
+ constructor() {
22
+ this.reset();
23
+ this._state = "text";
24
+ this._seq = "";
25
+ }
26
+
27
+ reset() {
28
+ this.dec = {
29
+ applicationCursorKeys: false,
30
+ applicationKeypad: false,
31
+ bracketedPasteMode: false,
32
+ origin: false,
33
+ reverseWraparound: false,
34
+ sendFocus: false,
35
+ wraparound: true,
36
+ };
37
+ this.cursorHidden = false;
38
+ this.mouse = "NONE";
39
+ this.encoding = "DEFAULT";
40
+ this.alternate = false;
41
+ this.kitty = { normal: { flags: 0, stack: [] }, alternate: { flags: 0, stack: [] } };
42
+ }
43
+
44
+ snapshot() {
45
+ return {
46
+ dec: { ...this.dec },
47
+ cursorHidden: this.cursorHidden,
48
+ mouse: this.mouse,
49
+ encoding: this.encoding,
50
+ alternate: this.alternate,
51
+ kitty: {
52
+ normal: { flags: this.kitty.normal.flags, stack: [...this.kitty.normal.stack] },
53
+ alternate: { flags: this.kitty.alternate.flags, stack: [...this.kitty.alternate.stack] },
54
+ },
55
+ };
56
+ }
57
+
58
+ consume(buffer) {
59
+ // Control sequences are ASCII, so bytes can be scanned without UTF-8 decoding.
60
+ for (let i = 0; i < buffer.length; i += 1) {
61
+ const byte = buffer[i];
62
+ switch (this._state) {
63
+ case "text":
64
+ if (byte === 0x1b) {
65
+ this._state = "esc";
66
+ }
67
+ break;
68
+ case "esc":
69
+ if (byte === 0x5b) {
70
+ this._state = "csi";
71
+ this._seq = "";
72
+ } else if (byte === 0x5d || byte === 0x50 || byte === 0x5f || byte === 0x5e || byte === 0x58) {
73
+ this._state = "string";
74
+ this._osc = byte === 0x5d ? "" : null;
75
+ } else if (byte === 0x1b) {
76
+ this._state = "esc";
77
+ } else if (byte >= 0x20 && byte <= 0x2f) {
78
+ this._state = "esc-intermediate";
79
+ } else {
80
+ this._state = "text";
81
+ this.handleEsc(byte);
82
+ }
83
+ break;
84
+ case "esc-intermediate":
85
+ if (byte < 0x20 || byte > 0x2f) {
86
+ this._state = byte === 0x1b ? "esc" : "text";
87
+ }
88
+ break;
89
+ case "csi":
90
+ if (byte >= 0x40 && byte <= 0x7e) {
91
+ this._state = "text";
92
+ this.handleCsi(this._seq, String.fromCharCode(byte));
93
+ } else if (byte === 0x1b) {
94
+ this._state = "esc";
95
+ } else if (byte === 0x18 || byte === 0x1a || this._seq.length >= 64) {
96
+ this._state = "text";
97
+ } else if (byte >= 0x20) {
98
+ this._seq += String.fromCharCode(byte);
99
+ }
100
+ break;
101
+ case "string":
102
+ if (byte === 0x07 || byte === 0x18 || byte === 0x1a) {
103
+ if (byte === 0x07) this.handleOsc();
104
+ this._state = "text";
105
+ } else if (byte === 0x1b) {
106
+ this._state = "string-esc";
107
+ } else if (this._osc != null) {
108
+ this._osc = this._osc.length < 256 ? this._osc + String.fromCharCode(byte) : null;
109
+ }
110
+ break;
111
+ case "string-esc":
112
+ if (byte === 0x5c) this.handleOsc();
113
+ else this._osc = null;
114
+ this._state = byte === 0x1b ? "string-esc" : byte === 0x5c ? "text" : "string";
115
+ break;
116
+ }
117
+ }
118
+ }
119
+
120
+ handleOsc() {
121
+ const match = /^777;jsgotty-kitty-v1;(normal|alternate);(\d{1,2});(\d{1,2}(?:,\d{1,2})*)?$/.exec(this._osc || '');
122
+ if (!match) return;
123
+ const flags = +match[2], stack = match[3] ? match[3].split(',').map(Number) : [];
124
+ if (flags > 31 || stack.length > 32 || stack.some(n=>n > 31)) return;
125
+ this.kitty[match[1]] = {flags, stack};
126
+ }
127
+
128
+ handleEsc(byte) {
129
+ if (byte === 0x3d) {
130
+ this.dec.applicationKeypad = true;
131
+ } else if (byte === 0x3e) {
132
+ this.dec.applicationKeypad = false;
133
+ } else if (byte === 0x63) {
134
+ this.reset();
135
+ }
136
+ }
137
+
138
+ handleCsi(seq, final) {
139
+ const prefix = /^[<=>?]/.test(seq) ? seq[0] : "";
140
+ const body = prefix ? seq.slice(1) : seq;
141
+ if (!/^[0-9;:]*$/.test(body)) {
142
+ if (body === "!" && final === "p") {
143
+ this.softReset();
144
+ }
145
+ return;
146
+ }
147
+ const params = body === "" ? [] : body.split(";").map((part) => {
148
+ const value = Number.parseInt(part, 10);
149
+ return Number.isFinite(value) ? value : 0;
150
+ });
151
+ if (prefix === "?" && (final === "h" || final === "l")) {
152
+ for (const mode of params) {
153
+ this.setPrivateMode(mode, final === "h");
154
+ }
155
+ } else if (final === "u" && prefix !== "?") {
156
+ this.handleKittyKeyboard(prefix, params);
157
+ }
158
+ }
159
+
160
+ softReset() {
161
+ // DECSTR keeps the active screen and mouse reporting, like xterm.js.
162
+ const { alternate, mouse, encoding } = this;
163
+ this.reset();
164
+ Object.assign(this, { alternate, mouse, encoding });
165
+ }
166
+
167
+ setPrivateMode(mode, enabled) {
168
+ if (TRACKED_DEC_FLAGS[mode]) {
169
+ this.dec[TRACKED_DEC_FLAGS[mode]] = enabled;
170
+ } else if (MOUSE_PROTOCOLS[mode]) {
171
+ this.mouse = enabled ? MOUSE_PROTOCOLS[mode] : "NONE";
172
+ } else if (MOUSE_ENCODINGS[mode]) {
173
+ this.encoding = enabled ? MOUSE_ENCODINGS[mode] : "DEFAULT";
174
+ } else if (mode === 25) {
175
+ this.cursorHidden = !enabled;
176
+ } else if (mode === 47 || mode === 1047 || mode === 1049) {
177
+ this.alternate = enabled;
178
+ }
179
+ }
180
+
181
+ handleKittyKeyboard(prefix, params) {
182
+ const state = this.kitty[this.alternate ? "alternate" : "normal"];
183
+ const value = (index, fallback = 0) => (Number.isInteger(params[index]) ? params[index] : fallback);
184
+ if (prefix === ">") {
185
+ state.stack.push(state.flags);
186
+ if (state.stack.length > 32) {
187
+ state.stack.shift();
188
+ }
189
+ state.flags = value(0) & KITTY_KEYBOARD_FLAGS;
190
+ } else if (prefix === "<") {
191
+ let count = Math.min(value(0) || 1, 33);
192
+ while (count--) {
193
+ state.flags = state.stack.pop() ?? 0;
194
+ }
195
+ } else if (prefix === "=") {
196
+ const flags = value(0) & KITTY_KEYBOARD_FLAGS;
197
+ const mode = value(1) || 1;
198
+ if (mode === 1) {
199
+ state.flags = flags;
200
+ } else if (mode === 2) {
201
+ state.flags |= flags;
202
+ } else if (mode === 3) {
203
+ state.flags &= ~flags;
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ const DEFAULT_MODES = new TerminalModeTracker().snapshot();
210
+ const MOUSE_PROTOCOL_MODES = { X10: 9, VT200: 1000, DRAG: 1002, ANY: 1003 };
211
+ const MOUSE_ENCODING_MODES = { SGR: 1006, SGR_PIXELS: 1016 };
212
+
213
+ function sameKitty(a, b) {
214
+ return a.flags === b.flags && a.stack.length === b.stack.length &&
215
+ a.stack.every((flags, index) => flags === b.stack[index]);
216
+ }
217
+
218
+ // Work only on the observed stack; never pop an arbitrary number of entries.
219
+ // With a terminal that cannot report its stack, [] denotes the unseen baseline.
220
+ function kittyTransition(from, to) {
221
+ if (to.flags == null) return "";
222
+ let common = 0;
223
+ while (common < from.stack.length && common < to.stack.length &&
224
+ from.stack[common] === to.stack[common]) common++;
225
+ let sequence = "";
226
+ let flags = from.flags;
227
+ const pops = from.stack.length - common;
228
+ if (pops) {
229
+ sequence += `\x1b[<${pops}u`;
230
+ flags = from.stack[common];
231
+ }
232
+ const first = to.stack[common] ?? to.flags;
233
+ if (flags !== first) sequence += `\x1b[=${first};1u`;
234
+ for (let i = common; i < to.stack.length; i++)
235
+ sequence += `\x1b[>${to.stack[i + 1] ?? to.flags}u`;
236
+ return sequence;
237
+ }
238
+
239
+ // Escape sequence that moves a terminal known to be in `from` to `to`, emitting
240
+ // only differences: DECOM homes the cursor and 1049 overwrites the saved cursor.
241
+ function modeTransition(from, to = DEFAULT_MODES, { normalizeMouse = false, kittyRestore = false } = {}) {
242
+ let sequence = "";
243
+ const fromScreen = from.alternate ? "alternate" : "normal";
244
+ const toScreen = to.alternate ? "alternate" : "normal";
245
+ // Kitty keyboard stacks are per screen, so each is fixed while it is active.
246
+ if (from.alternate != null && !sameKitty(from.kitty[fromScreen], to.kitty[fromScreen])) {
247
+ sequence += kittyTransition(from.kitty[fromScreen], to.kitty[fromScreen]);
248
+ }
249
+ if (to.alternate != null && from.alternate !== to.alternate) {
250
+ sequence += `\x1b[?1049${to.alternate ? "h" : "l"}`;
251
+ if (!sameKitty(from.kitty[toScreen], to.kitty[toScreen])) {
252
+ sequence += kittyTransition(from.kitty[toScreen], to.kitty[toScreen]);
253
+ }
254
+ }
255
+ const inactive = toScreen === "normal" ? "alternate" : "normal";
256
+ // Switching screens just to edit a hidden stack destroys alternate contents.
257
+ // Only emit this extension after the actual terminal advertised support.
258
+ const hidden = to.kitty[inactive];
259
+ if (kittyRestore && from.alternate != null && to.alternate != null && fromScreen === toScreen &&
260
+ !sameKitty(from.kitty[inactive], hidden) && hidden.flags != null && hidden.stack.every(Number.isInteger)) {
261
+ sequence += `\x1b]777;jsgotty-kitty-v1;${inactive};${hidden.flags};${hidden.stack.join(',')}\x07`;
262
+ }
263
+ for (const [mode, name] of Object.entries(TRACKED_DEC_FLAGS)) {
264
+ if (to.dec[name] == null || from.dec[name] === to.dec[name]) {
265
+ continue;
266
+ }
267
+ if (name === "applicationKeypad") {
268
+ sequence += to.dec[name] ? "\x1b=" : "\x1b>"; // more widely supported than ?66
269
+ } else {
270
+ sequence += `\x1b[?${mode}${to.dec[name] ? "h" : "l"}`;
271
+ }
272
+ }
273
+ if (to.cursorHidden != null && from.cursorHidden !== to.cursorHidden) {
274
+ sequence += `\x1b[?25${to.cursorHidden ? "l" : "h"}`;
275
+ }
276
+ // Raw query results preserve independent switches on native terminals. Reset
277
+ // first, then enable in priority order so exclusive terminals also end in the
278
+ // intended effective mode. Unanswered switches are never guessed.
279
+ for (const [name, bitsName, table] of [
280
+ ['mouse', 'mouseModeBits', MOUSE_PROTOCOL_MODES],
281
+ ['encoding', 'mouseEncodingBits', MOUSE_ENCODING_MODES],
282
+ ]) {
283
+ const bits = to[bitsName];
284
+ if (bits) {
285
+ for (const mode of Object.values(table))
286
+ if (bits[mode] === false) sequence += `\x1b[?${mode}l`;
287
+ for (const mode of Object.values(table))
288
+ if (bits[mode] === true) sequence += `\x1b[?${mode}h`;
289
+ } else if (to[name] != null && (normalizeMouse || from[name] !== to[name])) {
290
+ for (const mode of Object.values(table)) sequence += `\x1b[?${mode}l`;
291
+ if (table[to[name]]) sequence += `\x1b[?${table[to[name]]}h`;
292
+ }
293
+ }
294
+ return sequence;
295
+ }
296
+
297
+ function validateSnapshot(value) {
298
+ if (!value || !value.dec || !value.kitty) throw Error("Invalid terminal modes");
299
+ const result = new TerminalModeTracker().snapshot();
300
+ for (const key of Object.values(TRACKED_DEC_FLAGS)) {
301
+ if (typeof value.dec[key] !== 'boolean' && value.dec[key] !== null) throw Error("Invalid DEC mode");
302
+ result.dec[key] = value.dec[key];
303
+ }
304
+ for (const key of ['alternate', 'cursorHidden']) {
305
+ if (typeof value[key] !== 'boolean' && value[key] !== null) throw Error("Invalid screen mode");
306
+ result[key] = value[key];
307
+ }
308
+ for (const [key, choices] of [['mouse', ['NONE', ...Object.values(MOUSE_PROTOCOLS)]], ['encoding', ['DEFAULT', ...Object.values(MOUSE_ENCODINGS)]]]) {
309
+ if (value[key] !== null && !choices.includes(value[key])) throw Error("Invalid mouse mode");
310
+ result[key] = value[key];
311
+ }
312
+ for (const [key, table] of [['mouseModeBits', MOUSE_PROTOCOLS], ['mouseEncodingBits', MOUSE_ENCODINGS]]) {
313
+ if (value[key] === undefined) continue;
314
+ if (!value[key] || typeof value[key] !== 'object') throw Error('Invalid mouse bits');
315
+ result[key] = {};
316
+ for (const mode of Object.keys(table)) {
317
+ const bit = value[key][mode];
318
+ if (bit !== null && typeof bit !== 'boolean') throw Error('Invalid mouse bit');
319
+ result[key][mode] = bit;
320
+ }
321
+ }
322
+ for (const key of ['normal', 'alternate']) {
323
+ const state = value.kitty[key];
324
+ const flag = n => Number.isInteger(n) && n >= 0 && n <= KITTY_KEYBOARD_FLAGS;
325
+ if (!state || (state.flags !== null && !flag(state.flags)) || !Array.isArray(state.stack) ||
326
+ state.stack.length > 32 || !state.stack.every(n => n === null || flag(n))) throw Error("Invalid Kitty state");
327
+ result.kitty[key] = {flags: state.flags, stack: [...state.stack]};
328
+ }
329
+ return result;
330
+ }
331
+
332
+ module.exports = { TerminalModeTracker, modeTransition, validateSnapshot, TRACKED_DEC_FLAGS, MOUSE_PROTOCOLS, MOUSE_ENCODINGS };
@@ -0,0 +1,45 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import vm from 'node:vm';
5
+ const source=fs.readFileSync(new URL('../client.mjs',import.meta.url),'utf8');
6
+ const methods=source.slice(source.indexOf(' let exiting = false;'),source.indexOf(' const stopViewerInput ='));
7
+ function fixture(){
8
+ const calls=[],callbacks=[];
9
+ const handlers=vm.runInNewContext(`(()=>{${methods};return {exitClient,handleViewerInput};})()`,{
10
+ cleanup:()=>calls.push('cleanup'),reportDisconnected:()=>calls.push('report'),
11
+ ws:{close:()=>calls.push('close')},terminalInput:b=>b,
12
+ process:{stdout:{write:(s,done)=>callbacks.push(done)},exit:code=>calls.push(code)},
13
+ });
14
+ return {calls,callbacks,...handlers};
15
+ }
16
+ for(const [input,code] of [[[3,4],130],[[4,3],0],[[17,3],0]]){
17
+ test(`viewer exit handles first key only: ${input}`,()=>{
18
+ const f=fixture();f.handleViewerInput(Buffer.from(input));
19
+ assert.deepEqual(f.calls,['cleanup','report','close']);assert.equal(f.callbacks.length,1);
20
+ assert.ok(!f.calls.includes(code),'does not exit before output flush');
21
+ f.callbacks[0]();assert.equal(f.calls.at(-1),code);
22
+ });
23
+ }
24
+ test('concurrent exit requests queue only one flush and retain first exit code',()=>{
25
+ const f=fixture();f.exitClient(130);f.exitClient(143);f.exitClient(0);
26
+ assert.equal(f.callbacks.length,1);assert.deepEqual(f.calls,['cleanup','report','close']);
27
+ f.callbacks[0]();assert.equal(f.calls.at(-1),130);
28
+ });
29
+ for (const [key, code] of [[99,130],[100,0],[113,0]]) {
30
+ for (const sequence of ['\x1b['+key+';5u', '\x1b['+key+';5:1u', '\x1b['+key+';197u', '\x1b[27;5;'+key+'~']) {
31
+ test('viewer encoded Ctrl key '+JSON.stringify(sequence)+' split',()=>{
32
+ const f=fixture();
33
+ for(const byte of Buffer.from(sequence)) f.handleViewerInput(Buffer.from([byte]));
34
+ assert.equal(f.callbacks.length,1);f.callbacks[0]();assert.equal(f.calls.at(-1),code);
35
+ });
36
+ }
37
+ }
38
+ test('viewer ignores release and other modifiers before exit press',()=>{
39
+ const f=fixture();
40
+ for(const sequence of ['\x1b[99;5:3u','\x1b[100;1u','\x1b[113;7u','\x1b[<0;99;100M'])
41
+ f.handleViewerInput(Buffer.from(sequence));
42
+ assert.equal(f.callbacks.length,0);
43
+ f.handleViewerInput(Buffer.from('\x1b[113;5:2u'));
44
+ assert.equal(f.callbacks.length,1);
45
+ });
@@ -0,0 +1,105 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {ModeProbe,captureTerminalModes} from '../client-modes.mjs';
4
+ import modes from '../terminal-modes.js';
5
+ import {PassThrough} from 'node:stream';
6
+ const {TerminalModeTracker}=modes;
7
+ test('private reply split at every byte preserves typing and both Kitty stacks',()=>{
8
+ const p=new ModeProbe('a'.repeat(32));const t=new TerminalModeTracker();
9
+ t.consume(Buffer.from('\x1b[>1u\x1b[?1049h\x1b[>11u'));
10
+ const reply=p.privatePrefix+Buffer.from(JSON.stringify(t.snapshot())).toString('base64')+'\x07';
11
+ const out=[];for(const c of Buffer.from('before'+reply+'after'))out.push(p.consume(Buffer.from([c])));
12
+ assert.equal(Buffer.concat(out).toString(),'beforeafter');assert.equal(p.complete,true);assert.deepEqual(p.state,t.snapshot());
13
+ assert.equal(p.consume('\x1b').toString(),'\x1b','Escape is immediate after successful negotiation');
14
+ });
15
+ test('standard replies are consumed once; later application queries pass unchanged',()=>{
16
+ const p=new ModeProbe();p.standardQuery();
17
+ const replies='\x1b[?1049;2$y\x1b[?1002;1$y\x1b[?7;0$y\x1b[?11u';
18
+ assert.equal(p.consume(replies).length,0);assert.equal(p.state.mouse,'DRAG');assert.equal(p.state.alternate,false);
19
+ assert.equal(p.state.kitty.normal.flags,11);assert.equal(p.state.dec.wraparound,null);
20
+ assert.equal(p.consume(replies).toString(),replies);
21
+ });
22
+ test('no reply does not mean defaults, nor intercept application replies forever',()=>{
23
+ const p=new ModeProbe();p.standardQuery();p.replyDeadline=1;
24
+ const reply='\x1b[?1049;1$y\x1b[?11u';assert.equal(p.consume(reply).toString(),reply);
25
+ assert.equal(p.state.alternate,null);assert.equal(p.state.kitty.normal.flags,null);
26
+ });
27
+ test('foreign nonce, keys, mouse and a large paste are transparent',()=>{
28
+ const p=new ModeProbe();const data=Buffer.from('\x1b]777;jsgotty-modes;other;ignored\x07\x1b[<64;3;4M'+ '漢字'.repeat(200000));
29
+ assert.deepEqual(p.consume(data),data);
30
+ });
31
+ test('capture falls back after silence and restores stdin raw state',async()=>{
32
+ const input=new PassThrough();input.isTTY=true;input.isRaw=false;input.setRawMode=v=>{input.isRaw=v;};
33
+ const output=new PassThrough();output.isTTY=true;const sent=[];output.on('data',b=>sent.push(b.toString()));
34
+ const result=await captureTerminalModes(input,output,{timeout:30});
35
+ assert.equal(input.isRaw,false);assert.equal(result.state.mouse,null);assert.equal(sent.length,2);
36
+ assert.match(sent[0],/jsgotty-modes/);assert.match(sent[1],/\?1049\$p/);
37
+ });
38
+
39
+ for (const unsupportedFirst of [false,true]) {
40
+ test(`unsupported mouse modes count as inactive replies (${unsupportedFirst?'first':'last'})`,()=>{
41
+ const p=new ModeProbe();p.standardQuery();
42
+ const known='\x1b[?1000;2$y\x1b[?1002;2$y\x1b[?1003;2$y\x1b[?1006;2$y';
43
+ const unsupported='\x1b[?9;0$y\x1b[?1016;0$y';
44
+ p.consume(unsupportedFirst?unsupported+known:known+unsupported);
45
+ assert.equal(p.state.mouse,'NONE');assert.equal(p.state.encoding,'DEFAULT');
46
+ p.consume('\x1b[?7;0$y\x1b[?25;0$y\x1b[?1049;0$y');
47
+ assert.equal(p.state.dec.wraparound,null);assert.equal(p.state.cursorHidden,null);assert.equal(p.state.alternate,null);
48
+ const local=new TerminalModeTracker();Object.assign(local,structuredClone(p.state));
49
+ local.consume(Buffer.from('\x1b[?1003h\x1b[?1006h'));
50
+ const cleanup=modes.modeTransition(local.snapshot(),p.state);
51
+ assert.ok(cleanup.includes('\x1b[?1003l'));assert.ok(cleanup.includes('\x1b[?1006l'));
52
+ local.consume(Buffer.from(cleanup));assert.equal(local.mouse,'NONE');assert.equal(local.encoding,'DEFAULT');
53
+ });
54
+ }
55
+ test('unsupported replies do not override active modes or fill unanswered fields',()=>{
56
+ const p=new ModeProbe();p.standardQuery();
57
+ p.consume('\x1b[?9;0$y\x1b[?1016;0$y');
58
+ assert.equal(p.state.mouse,null);assert.equal(p.state.encoding,null);
59
+ p.consume('\x1b[?1002;1$y\x1b[?1006;1$y');
60
+ assert.equal(p.state.mouse,'DRAG');assert.equal(p.state.encoding,'SGR');
61
+ p.consume('\x1b[?1000;0$y\x1b[?1003;0$y');
62
+ assert.equal(p.state.mouse,'DRAG');
63
+ });
64
+
65
+ test('multiple active replies preserve individual switches and select explicit priorities',()=>{
66
+ const p=new ModeProbe();p.standardQuery();
67
+ for(const n of [1003,1002,1000,1016,1006])p.consume(`\x1b[?${n};1$y`);
68
+ p.consume('\x1b[?9;2$y');
69
+ assert.equal(p.state.mouse,'ANY');assert.equal(p.state.encoding,'SGR_PIXELS');
70
+ assert.deepEqual(p.state.mouseModeBits,{9:false,1000:true,1002:true,1003:true});
71
+ assert.deepEqual(p.state.mouseEncodingBits,{1006:true,1016:true});
72
+ assert.deepEqual(modes.validateSnapshot(p.state),p.state);
73
+ const bits={9:true,1000:false,1002:false,1003:false,1006:false,1016:false};
74
+ const cleanup=modes.modeTransition(new TerminalModeTracker().snapshot(),p.state);
75
+ for(const [,n,on] of cleanup.matchAll(/\x1b\[\?(\d+)([hl])/g))bits[n]=on==='h';
76
+ assert.deepEqual(bits,{...p.state.mouseModeBits,...p.state.mouseEncodingBits});
77
+ // The same sequence on an exclusive xterm-style terminal ends at the
78
+ // effective mode, without trying to change its parser semantics.
79
+ const exclusive=new TerminalModeTracker();exclusive.consume(Buffer.from(cleanup));
80
+ assert.equal(exclusive.mouse,'ANY');assert.equal(exclusive.encoding,'SGR_PIXELS');
81
+ });
82
+
83
+ test('partial query restoration leaves unreported switches untouched',()=>{
84
+ const p=new ModeProbe();p.standardQuery();p.consume('\x1b[?1000;2$y\x1b[?1006;1$y');
85
+ const cleanup=modes.modeTransition(new TerminalModeTracker().snapshot(),p.state,{normalizeMouse:true});
86
+ assert.equal(cleanup,'\x1b[?1000l\x1b[?1006h');
87
+ });
88
+
89
+ test('normalization clears every conflicting switch, even if aggregate appears unchanged',()=>{
90
+ for(const target of [{mouse:'NONE',encoding:'DEFAULT'},{mouse:'VT200',encoding:'SGR'}]){
91
+ const state={...new TerminalModeTracker().snapshot(),...target};
92
+ const bits={9:true,1000:true,1002:true,1003:true,1006:true,1016:true};
93
+ const seq=modes.modeTransition(state,state,{normalizeMouse:true});
94
+ for(const [,n,on] of seq.matchAll(/\x1b\[\?(\d+)([hl])/g))bits[n]=on==='h';
95
+ assert.deepEqual(bits,{9:false,1000:target.mouse==='VT200',1002:false,1003:false,1006:target.encoding==='SGR',1016:false});
96
+ }
97
+ });
98
+
99
+ test('hidden Kitty restore requires an explicit capability in a valid private reply',()=>{
100
+ for(const advertised of [undefined,0,true,1]){
101
+ const p=new ModeProbe(),reply={...new TerminalModeTracker().snapshot(),kittyRestore:advertised};
102
+ p.consume(p.privatePrefix+Buffer.from(JSON.stringify(reply)).toString('base64')+'\x07');
103
+ assert.equal(p.complete,true);assert.equal(p.kittyRestore,advertised===1);
104
+ }
105
+ });