buninu 0.4.21 → 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.
- package/CHANGELOG.md +40 -0
- package/apps/bunmsh/CHANGELOG.md +4 -0
- package/apps/bunmsh/package.json +1 -1
- package/apps/bunmsh/src/shell.js +1 -1
- package/apps/jsgotty/LICENSES/MIT-jsmdcui.txt +22 -0
- package/apps/jsgotty/NOTICE +5 -0
- package/apps/jsgotty/README.en.md +57 -0
- package/apps/jsgotty/README.md +57 -0
- package/apps/jsgotty/client-modes.mjs +117 -0
- package/apps/jsgotty/client.mjs +100 -10
- package/apps/jsgotty/clipboard-powershell.js +18 -0
- package/apps/jsgotty/clipboard.js +148 -0
- package/apps/jsgotty/gotty.js +17 -2
- package/apps/jsgotty/package-lock.json +2 -2
- package/apps/jsgotty/package.json +1 -1
- package/apps/jsgotty/static/css/index.css +7 -1
- package/apps/jsgotty/static/index.html +1 -0
- package/apps/jsgotty/static/js/gotty.js +175 -19
- package/apps/jsgotty/static/js/kitty-keyboard.js +193 -0
- package/apps/jsgotty/terminal-modes.js +332 -0
- package/apps/jsgotty/tests/android-alt.test.js +115 -0
- package/apps/jsgotty/tests/arrow-modifiers.test.js +80 -0
- package/apps/jsgotty/tests/client-exit.test.mjs +45 -0
- package/apps/jsgotty/tests/client-modes.test.mjs +105 -0
- package/apps/jsgotty/tests/clipboard-browser.test.js +81 -0
- package/apps/jsgotty/tests/clipboard.test.js +75 -0
- package/apps/jsgotty/tests/kitty-keyboard-browser.test.js +53 -0
- package/apps/jsgotty/tests/kitty-keyboard.test.js +102 -0
- package/apps/jsgotty/tests/manual/README.md +20 -0
- package/apps/jsgotty/tests/manual/keyboard-collector.cjs +55 -0
- package/apps/jsgotty/tests/manual/keyboard-debug.js +74 -0
- package/apps/jsgotty/tests/manual/kitty-keyboard.js +80 -0
- package/apps/jsgotty/tests/manual-touch-diagnostics.js +33 -0
- package/apps/jsgotty/tests/manual-touch-diagnostics.mjs +22 -0
- package/apps/jsgotty/tests/nested-cli-pty.test.mjs +116 -0
- package/apps/jsgotty/tests/reconnect-modes-browser.test.js +144 -0
- package/apps/jsgotty/tests/reconnect-modes.md +44 -0
- package/apps/jsgotty/tests/reconnect-modes.test.js +166 -0
- package/package.json +1 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Kitty keyboard transport for the shipped xterm wrapper (no build step).
|
|
2
|
+
// Spec: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
|
3
|
+
(function (root) {
|
|
4
|
+
'use strict';
|
|
5
|
+
// Supported progressive enhancements: disambiguation, event types, all keys.
|
|
6
|
+
// Do not advertise alternate-layout/text reporting until those are implemented.
|
|
7
|
+
const SUPPORTED = 1 | 2 | 8;
|
|
8
|
+
const functional = {
|
|
9
|
+
Escape:[27,'u'], Enter:[13,'u'], Tab:[9,'u'], Backspace:[127,'u'],
|
|
10
|
+
Insert:[2,'~'], Delete:[3,'~'], ArrowLeft:[1,'D'], ArrowRight:[1,'C'],
|
|
11
|
+
ArrowUp:[1,'A'], ArrowDown:[1,'B'], PageUp:[5,'~'], PageDown:[6,'~'],
|
|
12
|
+
Home:[1,'H'], End:[1,'F'], CapsLock:[57358,'u'], ScrollLock:[57359,'u'],
|
|
13
|
+
NumLock:[57360,'u'], PrintScreen:[57361,'u'], Pause:[57362,'u'], ContextMenu:[57363,'u'],
|
|
14
|
+
MediaPlay:[57428,'u'], MediaPause:[57429,'u'], MediaPlayPause:[57430,'u'],
|
|
15
|
+
MediaStop:[57432,'u'], MediaFastForward:[57433,'u'], MediaRewind:[57434,'u'],
|
|
16
|
+
MediaTrackNext:[57435,'u'], MediaTrackPrevious:[57436,'u'], MediaRecord:[57437,'u'],
|
|
17
|
+
AudioVolumeDown:[57438,'u'], AudioVolumeUp:[57439,'u'], AudioVolumeMute:[57440,'u'],
|
|
18
|
+
};
|
|
19
|
+
[11,12,13,14,15,17,18,19,20,21,23,24].forEach((n,i) => functional['F'+(i+1)] = [n,'~']);
|
|
20
|
+
for (let i=13;i<=35;i++) functional['F'+i] = [57376+i-13,'u'];
|
|
21
|
+
const punctuation = {
|
|
22
|
+
Backquote:['`','~'], Minus:['-','_'], Equal:['=','+'], BracketLeft:['[','{'],
|
|
23
|
+
BracketRight:[']','}'], Backslash:['\\','|'], Semicolon:[';',':'], Quote:["'",'"'],
|
|
24
|
+
Comma:[',','<'], Period:['.','>'], Slash:['/','?'], Space:[' ',' '],
|
|
25
|
+
};
|
|
26
|
+
const modifiers = {Shift:[1,57441], Control:[4,57442], Alt:[2,57443], Meta:[8,57444],
|
|
27
|
+
OS:[8,57444], Super:[8,57444], Hyper:[16,57445], AltGraph:[0,57453]};
|
|
28
|
+
const has = (object,key) => Object.prototype.hasOwnProperty.call(object,key);
|
|
29
|
+
const lock = (e,name) => Boolean(e.getModifierState?.(name));
|
|
30
|
+
function physicalCode(e) {
|
|
31
|
+
if (e.code && e.code !== 'Unidentified') return e.code;
|
|
32
|
+
if (e.keyCode>=65 && e.keyCode<=90) return 'Key'+String.fromCharCode(e.keyCode);
|
|
33
|
+
if (e.keyCode>=48 && e.keyCode<=57) return 'Digit'+String.fromCharCode(e.keyCode);
|
|
34
|
+
return ({32:'Space',186:'Semicolon',187:'Equal',188:'Comma',189:'Minus',190:'Period',
|
|
35
|
+
191:'Slash',192:'Backquote',219:'BracketLeft',220:'Backslash',221:'BracketRight',222:'Quote'})[e.keyCode] || '';
|
|
36
|
+
}
|
|
37
|
+
function baseCharacter(e, android) {
|
|
38
|
+
const code=physicalCode(e);
|
|
39
|
+
// Android's Alt accent conversion must not turn an E/I/N/U shortcut into a dead key.
|
|
40
|
+
const recover=android && e.altKey && !lock(e,'AltGraph');
|
|
41
|
+
if (recover || ([...e.key || ''].length===1 && e.key.codePointAt(0)<32)) {
|
|
42
|
+
if (/^Key[A-Z]$/.test(code)) return code.slice(3).toLowerCase();
|
|
43
|
+
if (/^Digit[0-9]$/.test(code)) return code.slice(5);
|
|
44
|
+
if (has(punctuation,code)) return punctuation[code][0];
|
|
45
|
+
}
|
|
46
|
+
if ([...e.key || ''].length!==1) return null;
|
|
47
|
+
if (e.shiftKey) {
|
|
48
|
+
if (has(punctuation,code) && punctuation[code][1]===e.key) return punctuation[code][0];
|
|
49
|
+
if (/^Digit[0-9]$/.test(code) && ')!@#$%^&*('[Number(code.slice(5))]===e.key) return code.slice(5);
|
|
50
|
+
}
|
|
51
|
+
const lower=e.key.toLowerCase();
|
|
52
|
+
return [...lower].length===1 ? lower : e.key;
|
|
53
|
+
}
|
|
54
|
+
function keyDefinition(e, flags, android) {
|
|
55
|
+
if (has(modifiers,e.key)) {
|
|
56
|
+
const [bit,left]=modifiers[e.key];
|
|
57
|
+
const right=e.location===2 || /Right$/.test(e.code || '');
|
|
58
|
+
return {number:left+(right && e.key!=='AltGraph'?6:0),suffix:'u',modifier:bit};
|
|
59
|
+
}
|
|
60
|
+
if ((flags & 9) && (e.location===3 || /^Numpad/.test(e.code || ''))) {
|
|
61
|
+
const numpad={NumpadDecimal:57409,NumpadDivide:57410,NumpadMultiply:57411,
|
|
62
|
+
NumpadSubtract:57412,NumpadAdd:57413,NumpadEnter:57414,NumpadEqual:57415,
|
|
63
|
+
NumpadComma:57416};
|
|
64
|
+
const nav={ArrowLeft:57417,ArrowRight:57418,ArrowUp:57419,ArrowDown:57420,
|
|
65
|
+
PageUp:57421,PageDown:57422,Home:57423,End:57424,Insert:57425,Delete:57426,Clear:57427};
|
|
66
|
+
const number=has(nav,e.key)?nav[e.key]:/^Numpad[0-9]$/.test(e.code || '')
|
|
67
|
+
?57399+Number(e.code.slice(6)):numpad[e.code];
|
|
68
|
+
if (number) return {number,suffix:'u'};
|
|
69
|
+
}
|
|
70
|
+
if (has(functional,e.key)) {
|
|
71
|
+
const [number,suffix]=functional[e.key];
|
|
72
|
+
return {number,suffix,recovery:['Enter','Tab','Backspace'].includes(e.key)};
|
|
73
|
+
}
|
|
74
|
+
const ch=baseCharacter(e,android);
|
|
75
|
+
return ch===null?null:{number:ch.codePointAt(0),suffix:'u',text:true};
|
|
76
|
+
}
|
|
77
|
+
class KittyKeyboard {
|
|
78
|
+
constructor(term, {android=false}={}) {
|
|
79
|
+
this.term=term; this.android=android; this.disposables=[];
|
|
80
|
+
this.metaKeys=new Set(); this.down=new Map(); this.suppressed=new Set(); this.reset();
|
|
81
|
+
const register=(prefix,fn) => this.disposables.push(term.parser.registerCsiHandler({prefix,final:'u'},p=>{fn(p);return true;}));
|
|
82
|
+
const value=(p,i,fallback=0) => Number.isInteger(p[i])?p[i]:fallback;
|
|
83
|
+
register('?',()=>term.input(`\x1b[?${this.flags}u`,false));
|
|
84
|
+
register('>',p=>{const s=this.state;s.stack.push(s.flags);if(s.stack.length>32)s.stack.shift();s.flags=value(p,0)&SUPPORTED;});
|
|
85
|
+
register('<',p=>{const s=this.state;let count=Math.min(value(p,0)||1,33);while(count--)s.flags=s.stack.pop()??0;});
|
|
86
|
+
register('=',p=>{const s=this.state,f=value(p,0)&SUPPORTED,m=value(p,1)||1;if(m===1)s.flags=f;else if(m===2)s.flags|=f;else if(m===3)s.flags&=~f;});
|
|
87
|
+
this.disposables.push(term.parser.registerEscHandler({final:'c'},()=>{this.reset();return false;}));
|
|
88
|
+
this.disposables.push(term.parser.registerCsiHandler({intermediates:'!',final:'p'},()=>{this.reset();return false;}));
|
|
89
|
+
}
|
|
90
|
+
get state() { return this.states[this.term.buffer.active.type==='alternate'?'alternate':'normal']; }
|
|
91
|
+
get flags() { return this.state.flags; }
|
|
92
|
+
reset() {
|
|
93
|
+
this.states={normal:{flags:0,stack:[]},alternate:{flags:0,stack:[]}};
|
|
94
|
+
this.clearHeld(false);
|
|
95
|
+
}
|
|
96
|
+
clearHeld(report=true) {
|
|
97
|
+
// Release keys reported to the client before losing focus, avoiding stuck remote keys.
|
|
98
|
+
if (report && (this.flags & 2)) for (const key of this.down.values()) {
|
|
99
|
+
if(key.reported) this.term.input(this.sequence(key.def,0,3),true);
|
|
100
|
+
}
|
|
101
|
+
this.metaKeys.clear();this.down.clear();this.suppressed.clear();this.suppressKeypress=false;
|
|
102
|
+
}
|
|
103
|
+
attach(element) {
|
|
104
|
+
const doc=element.ownerDocument, win=doc.defaultView;
|
|
105
|
+
const blur=()=>this.clearHeld();
|
|
106
|
+
const visibility=()=>{if(doc.hidden)this.clearHeld();};
|
|
107
|
+
element.addEventListener('blur',blur);win.addEventListener('blur',blur);
|
|
108
|
+
doc.addEventListener('visibilitychange',visibility);
|
|
109
|
+
this.disposables.push({dispose:()=>{element.removeEventListener('blur',blur);win.removeEventListener('blur',blur);doc.removeEventListener('visibilitychange',visibility);}});
|
|
110
|
+
}
|
|
111
|
+
dispose() {this.clearHeld(false);for(const d of this.disposables)d.dispose();}
|
|
112
|
+
sequence(def,bits,type) {
|
|
113
|
+
const event=(this.flags&2) && type!==1?':'+type:'';
|
|
114
|
+
const mods=bits+1;
|
|
115
|
+
if(def.suffix!=='u' && mods===1 && !event)
|
|
116
|
+
return '\x1b['+(def.number===1?'':def.number)+def.suffix;
|
|
117
|
+
return `\x1b[${def.number}${mods!==1||event?';'+mods+event:''}${def.suffix}`;
|
|
118
|
+
}
|
|
119
|
+
handle(event) {
|
|
120
|
+
if (!['keydown','keyup','keypress'].includes(event.type)) return true;
|
|
121
|
+
const identity=physicalCode(event)||event.key;
|
|
122
|
+
const metaKey=['Meta','OS','Super'].includes(event.key);
|
|
123
|
+
if(this.android && metaKey) {
|
|
124
|
+
if(event.type==='keydown')this.metaKeys.add(event.code||String(event.location||0));
|
|
125
|
+
if(event.type==='keyup')this.metaKeys.delete(event.code||String(event.location||0));
|
|
126
|
+
}
|
|
127
|
+
// Patch only this terminal's Android event, preserving the native DOM event.
|
|
128
|
+
if(this.android && this.metaKeys.size && !event.metaKey) {
|
|
129
|
+
const original=event.getModifierState?.bind(event);
|
|
130
|
+
Object.defineProperties(event,{
|
|
131
|
+
metaKey:{configurable:true,value:true},
|
|
132
|
+
getModifierState:{configurable:true,value:name=>name==='Meta'||Boolean(original?.(name))},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if(!this.flags || this.term.options.disableStdin) return true;
|
|
136
|
+
const consume=()=>{event.preventDefault();event.stopPropagation();return false;};
|
|
137
|
+
if(event.type==='keypress')return this.suppressKeypress?consume():true;
|
|
138
|
+
let previous=this.down.get(identity);
|
|
139
|
+
if(event.type==='keyup') {
|
|
140
|
+
// Some Android IMEs emit only the release of an Alt dead key. Recover
|
|
141
|
+
// that chord here too, never falling back to legacy ESC text in Kitty mode.
|
|
142
|
+
if(!previous && this.android && event.altKey && event.key==='Dead' &&
|
|
143
|
+
!event.isComposing && !lock(event,'AltGraph') && (this.flags&9)) {
|
|
144
|
+
const def=keyDefinition(event,this.flags,this.android);
|
|
145
|
+
if(def) {
|
|
146
|
+
this.term.input(this.sequence(def,this.bits(event),1),true);
|
|
147
|
+
previous={def,reported:true};this.suppressed.add(identity);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
this.down.delete(identity);
|
|
151
|
+
const suppressed=this.suppressed.delete(identity);
|
|
152
|
+
this.suppressKeypress=false;
|
|
153
|
+
if(previous?.reported && (this.flags&2)) {
|
|
154
|
+
let bits=this.bits(event);
|
|
155
|
+
if(previous.def.modifier && ![...this.down.values()].some(k=>k.def.modifier===previous.def.modifier)) bits &= ~previous.def.modifier;
|
|
156
|
+
if(metaKey && this.metaKeys.size) bits|=8;
|
|
157
|
+
this.term.input(this.sequence(previous.def,bits,3),true);
|
|
158
|
+
}
|
|
159
|
+
return suppressed?consume():true;
|
|
160
|
+
}
|
|
161
|
+
this.suppressKeypress=false;
|
|
162
|
+
if(event.isComposing || event.keyCode===229 && event.key==='Unidentified')return true;
|
|
163
|
+
if(event.key==='Dead' && !(this.android && event.altKey && !lock(event,'AltGraph')))return true;
|
|
164
|
+
const def=keyDefinition(event,this.flags,this.android);
|
|
165
|
+
if(!def)return true;
|
|
166
|
+
let bits=this.bits(event);
|
|
167
|
+
if(def.modifier!==undefined) {
|
|
168
|
+
if(!(this.flags&8))return true;
|
|
169
|
+
bits|=def.modifier;
|
|
170
|
+
}
|
|
171
|
+
const all=Boolean(this.flags&8);
|
|
172
|
+
// Enter/Tab/Backspace remain recovery keys unless modified or report-all is requested.
|
|
173
|
+
if(!all && def.recovery && !(bits&~192))return true;
|
|
174
|
+
const shortcut=Boolean(bits&62);
|
|
175
|
+
if(def.text && !all && !(this.flags&1 && shortcut))return true;
|
|
176
|
+
// With only event reporting requested, leave legacy printable shortcuts untouched.
|
|
177
|
+
if(def.text && !all && !(this.flags&1))return true;
|
|
178
|
+
const type=event.repeat?2:1;
|
|
179
|
+
this.down.set(identity,{def,reported:!(def.recovery && !all)});this.suppressed.add(identity);this.suppressKeypress=true;
|
|
180
|
+
this.term.input(this.sequence(def,bits,type),true);
|
|
181
|
+
return consume();
|
|
182
|
+
}
|
|
183
|
+
bits(e) {
|
|
184
|
+
const altgr=lock(e,'AltGraph');
|
|
185
|
+
return (e.shiftKey?1:0)|(!altgr&&e.altKey?2:0)|(!altgr&&e.ctrlKey?4:0)|
|
|
186
|
+
(e.metaKey || this.android&&this.metaKeys.size?8:0)|
|
|
187
|
+
(lock(e,'Hyper')?16:0)|(lock(e,'CapsLock')?64:0)|(lock(e,'NumLock')?128:0);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const api={KittyKeyboard,SUPPORTED};
|
|
191
|
+
if(typeof module==='object' && module.exports)module.exports=api;
|
|
192
|
+
else root.GoTTYKittyKeyboard=api;
|
|
193
|
+
})(globalThis);
|
|
@@ -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,115 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const vm = require('node:vm');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
// Exercise the shipped bundle's wrapper method, without a DOM or new dependencies.
|
|
8
|
+
const bundle = fs.readFileSync(path.join(__dirname, '../static/js/gotty.js'), 'utf8');
|
|
9
|
+
const start = bundle.indexOf(' handleAndroidAltKey(event) {');
|
|
10
|
+
assert.ok(start >= 0);
|
|
11
|
+
const end = bundle.indexOf(' info() {', start);
|
|
12
|
+
assert.ok(end > start);
|
|
13
|
+
const method = bundle.slice(start, end);
|
|
14
|
+
function setup(userAgent = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 wv') {
|
|
15
|
+
const handler = vm.runInNewContext(`({${method}}).handleAndroidAltKey`, {
|
|
16
|
+
navigator: { userAgent },
|
|
17
|
+
});
|
|
18
|
+
const sent = [];
|
|
19
|
+
const target = { term: { input: (text, userInput) => sent.push([text, userInput]) } };
|
|
20
|
+
function dispatch(overrides = {}) {
|
|
21
|
+
const event = {
|
|
22
|
+
type: 'keydown', code: 'KeyE', key: 'Dead', keyCode: 69,
|
|
23
|
+
altKey: true, ctrlKey: false, metaKey: false, shiftKey: false,
|
|
24
|
+
isComposing: false, getModifierState: () => false,
|
|
25
|
+
prevented: false, stopped: false,
|
|
26
|
+
preventDefault() { this.prevented = true; },
|
|
27
|
+
stopPropagation() { this.stopped = true; },
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
return { result: handler.call(target, event), event };
|
|
31
|
+
}
|
|
32
|
+
return { sent, dispatch };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test('Android accent shortcuts emit one ESC sequence; keypress/keyup do not duplicate it', () => {
|
|
36
|
+
for (const letter of ['E', 'I', 'N', 'U']) {
|
|
37
|
+
for (const keyCode of [letter.charCodeAt(0), 229]) {
|
|
38
|
+
const { sent, dispatch } = setup();
|
|
39
|
+
const { event, result } = dispatch({ code: `Key${letter}`, keyCode });
|
|
40
|
+
assert.equal(result, false);
|
|
41
|
+
assert.equal(event.prevented, true);
|
|
42
|
+
assert.equal(event.stopped, true);
|
|
43
|
+
dispatch({ type: 'keypress', code: `Key${letter}`, keyCode });
|
|
44
|
+
dispatch({ type: 'keyup', code: `Key${letter}`, keyCode });
|
|
45
|
+
assert.deepEqual(sent, [[`\x1b${letter.toLowerCase()}`, true]]);
|
|
46
|
+
// No dead-key state is retained in this handler for the next ordinary key.
|
|
47
|
+
assert.equal(dispatch({ altKey: false, key: 'e' }).result, true);
|
|
48
|
+
assert.equal(sent.length, 1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('all printable physical Alt keys bypass dead-key handling, including Shift and repeat', () => {
|
|
54
|
+
const { sent, dispatch } = setup();
|
|
55
|
+
for (const [code, shiftKey, expected] of [
|
|
56
|
+
['KeyA', false, 'a'], ['KeyE', true, 'E'], ['Digit0', false, '0'],
|
|
57
|
+
['Digit1', true, '!'], ['Digit0', true, ')'], ['Quote', false, "'"],
|
|
58
|
+
['Backquote', true, '~'], ['Backslash', false, '\\'], ['Space', false, ' '],
|
|
59
|
+
]) {
|
|
60
|
+
dispatch({ code, shiftKey, key: 'Dead' });
|
|
61
|
+
assert.deepEqual(sent.at(-1), [`\x1b${expected}`, true]);
|
|
62
|
+
}
|
|
63
|
+
dispatch({ code: 'KeyE', key: 'é', repeat: true });
|
|
64
|
+
assert.deepEqual(sent.at(-1), ['\x1be', true]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('composition, AltGr, other modifiers, and unmapped keys retain existing handling', () => {
|
|
68
|
+
for (const overrides of [
|
|
69
|
+
{ isComposing: true, keyCode: 229 }, { getModifierState: key => key === 'AltGraph' },
|
|
70
|
+
{ altKey: false }, { ctrlKey: true }, { metaKey: true },
|
|
71
|
+
{ code: '', keyCode: 229 }, { code: 'ArrowLeft' }, { code: 'Enter' },
|
|
72
|
+
{ code: 'Unidentified', keyCode: 229 }, { code: 'toString' },
|
|
73
|
+
{ type: 'keyup', key: 'e' },
|
|
74
|
+
]) {
|
|
75
|
+
const { sent, dispatch } = setup();
|
|
76
|
+
const { result, event } = dispatch(overrides);
|
|
77
|
+
assert.equal(result, true);
|
|
78
|
+
assert.equal(event.prevented, false);
|
|
79
|
+
assert.deepEqual(sent, []);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('non-Android browsers retain existing Alt/dead-key handling', () => {
|
|
84
|
+
for (const ua of ['Mozilla/5.0 (X11; Linux x86_64)', 'Mozilla/5.0 (Macintosh)', 'Windows']) {
|
|
85
|
+
const { sent, dispatch } = setup(ua);
|
|
86
|
+
assert.equal(dispatch().result, true);
|
|
87
|
+
assert.deepEqual(sent, []);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Replay the actual Android 16 WebView trace: no letter keydown and empty code.
|
|
92
|
+
test('Android keyup-only dead keys use legacy keyCode, and ordinary IME input stays untouched', () => {
|
|
93
|
+
const { sent, dispatch } = setup();
|
|
94
|
+
for (const keyCode of [69, 78, 85, 73]) {
|
|
95
|
+
dispatch({ type: 'keydown', code: '', key: 'Alt', keyCode: 18 });
|
|
96
|
+
assert.equal(dispatch({ type: 'keyup', code: '', key: 'Dead', keyCode }).result, false);
|
|
97
|
+
dispatch({ type: 'keyup', code: '', key: 'Alt', keyCode: 18 });
|
|
98
|
+
}
|
|
99
|
+
assert.deepEqual(sent, ['e', 'n', 'u', 'i'].map(c => ['\x1b' + c, true]));
|
|
100
|
+
assert.equal(dispatch({ code: '', key: 'Unidentified', keyCode: 229, altKey: false }).result, true);
|
|
101
|
+
assert.equal(sent.length, 4);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('legacy keydown plus dead keyup emits once, even with different code availability', () => {
|
|
105
|
+
for (const [downCode, upCode] of [['', ''], ['KeyE', ''], ['', 'KeyE']]) {
|
|
106
|
+
const { sent, dispatch } = setup();
|
|
107
|
+
dispatch({ code: downCode, keyCode: 69 });
|
|
108
|
+
dispatch({ type: 'keypress', code: downCode, keyCode: 69 });
|
|
109
|
+
dispatch({ type: 'keyup', code: upCode, keyCode: 69 });
|
|
110
|
+
assert.deepEqual(sent, [['\x1be', true]]);
|
|
111
|
+
// The next keyup-only shortcut must not be suppressed by the preceding press.
|
|
112
|
+
dispatch({ type: 'keyup', code: '', keyCode: 69 });
|
|
113
|
+
assert.equal(sent.length, 2);
|
|
114
|
+
}
|
|
115
|
+
});
|