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.
Files changed (39) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/apps/bunmsh/CHANGELOG.md +4 -0
  3. package/apps/bunmsh/package.json +1 -1
  4. package/apps/bunmsh/src/shell.js +1 -1
  5. package/apps/jsgotty/LICENSES/MIT-jsmdcui.txt +22 -0
  6. package/apps/jsgotty/NOTICE +5 -0
  7. package/apps/jsgotty/README.en.md +57 -0
  8. package/apps/jsgotty/README.md +57 -0
  9. package/apps/jsgotty/client-modes.mjs +117 -0
  10. package/apps/jsgotty/client.mjs +100 -10
  11. package/apps/jsgotty/clipboard-powershell.js +18 -0
  12. package/apps/jsgotty/clipboard.js +148 -0
  13. package/apps/jsgotty/gotty.js +17 -2
  14. package/apps/jsgotty/package-lock.json +2 -2
  15. package/apps/jsgotty/package.json +1 -1
  16. package/apps/jsgotty/static/css/index.css +7 -1
  17. package/apps/jsgotty/static/index.html +1 -0
  18. package/apps/jsgotty/static/js/gotty.js +175 -19
  19. package/apps/jsgotty/static/js/kitty-keyboard.js +193 -0
  20. package/apps/jsgotty/terminal-modes.js +332 -0
  21. package/apps/jsgotty/tests/android-alt.test.js +115 -0
  22. package/apps/jsgotty/tests/arrow-modifiers.test.js +80 -0
  23. package/apps/jsgotty/tests/client-exit.test.mjs +45 -0
  24. package/apps/jsgotty/tests/client-modes.test.mjs +105 -0
  25. package/apps/jsgotty/tests/clipboard-browser.test.js +81 -0
  26. package/apps/jsgotty/tests/clipboard.test.js +75 -0
  27. package/apps/jsgotty/tests/kitty-keyboard-browser.test.js +53 -0
  28. package/apps/jsgotty/tests/kitty-keyboard.test.js +102 -0
  29. package/apps/jsgotty/tests/manual/README.md +20 -0
  30. package/apps/jsgotty/tests/manual/keyboard-collector.cjs +55 -0
  31. package/apps/jsgotty/tests/manual/keyboard-debug.js +74 -0
  32. package/apps/jsgotty/tests/manual/kitty-keyboard.js +80 -0
  33. package/apps/jsgotty/tests/manual-touch-diagnostics.js +33 -0
  34. package/apps/jsgotty/tests/manual-touch-diagnostics.mjs +22 -0
  35. package/apps/jsgotty/tests/nested-cli-pty.test.mjs +116 -0
  36. package/apps/jsgotty/tests/reconnect-modes-browser.test.js +144 -0
  37. package/apps/jsgotty/tests/reconnect-modes.md +44 -0
  38. package/apps/jsgotty/tests/reconnect-modes.test.js +166 -0
  39. package/package.json +1 -1
@@ -0,0 +1,74 @@
1
+ // Manually loaded diagnostic helper. Never loaded by the production frontend.
2
+ window.startGottyKeyboardDebug = ({ endpoint, term } = {}) => {
3
+ window.stopGottyKeyboardDebug?.();
4
+ term ??= Array.from(document.querySelectorAll("*")).find(el => el.__gottyXterm)?.__gottyXterm;
5
+ if (!term) throw new Error("No jsgotty xterm found; connect the terminal first.");
6
+ const listeners = [];
7
+ let stopped = false;
8
+ const records = [];
9
+ const started = Date.now();
10
+ const session = Math.random().toString(36).slice(2);
11
+ window.__gottyKeyboardRecords = records;
12
+ window.__gottyKeyboardDebug = (stage, details = {}) => {
13
+ if (stopped || records.length >= 2000 || Date.now() - started > 600000) return;
14
+ const record = { session, sequence: records.length, time: new Date().toISOString(), stage, ...details };
15
+ records.push(record);
16
+ console.log("[gotty-keyboard]", record);
17
+ if (endpoint) fetch(endpoint, {
18
+ method: "POST", headers: { "Content-Type": "text/plain" },
19
+ body: JSON.stringify(record),
20
+ }).catch(error => { window.__gottyKeyboardDeliveryError = String(error); });
21
+ };
22
+ window.__gottyKeyboardDebug("ready", {
23
+ userAgent: navigator.userAgent, platform: navigator.platform,
24
+ userAgentData: navigator.userAgentData?.platform,
25
+ });
26
+ for (const type of ["keydown", "keypress", "keyup", "beforeinput", "input",
27
+ "compositionstart", "compositionupdate", "compositionend"]) {
28
+ const listener = event => {
29
+ const details = {
30
+ type, key: event.key, code: event.code, keyCode: event.keyCode,
31
+ charCode: event.charCode, which: event.which, altKey: event.altKey,
32
+ ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey,
33
+ altGraph: event.getModifierState?.("AltGraph"),
34
+ isComposing: event.isComposing, repeat: event.repeat,
35
+ cancelable: event.cancelable, defaultPrevented: event.defaultPrevented,
36
+ inputType: event.inputType, data: event.data?.slice(0, 64),
37
+ target: event.target?.tagName, trusted: event.isTrusted,
38
+ };
39
+ window.__gottyKeyboardDebug("capture", details);
40
+ setTimeout(() => window.__gottyKeyboardDebug("after-dispatch", {
41
+ ...details, defaultPrevented: event.defaultPrevented,
42
+ }));
43
+ };
44
+ window.addEventListener(type, listener, true);
45
+ listeners.push([type, listener]);
46
+ }
47
+ // Preserve the existing Android handler, including its return value.
48
+ const previousHandler = term._core?._customKeyEventHandler;
49
+ if (!term._core) throw new Error("Unsupported xterm internals");
50
+ const handler = event => {
51
+ const result = previousHandler ? previousHandler.call(term._core, event) : true;
52
+ window.__gottyKeyboardDebug("alt-handler", {
53
+ type: event.type, key: event.key, code: event.code,
54
+ result, defaultPrevented: event.defaultPrevented,
55
+ });
56
+ return result;
57
+ };
58
+ term.attachCustomKeyEventHandler(handler);
59
+ const subscription = term.onData(text => {
60
+ const bytes = new TextEncoder().encode(text);
61
+ window.__gottyKeyboardDebug("terminal-data", {
62
+ length: bytes.length, bytes: Array.from(bytes.slice(0, 64)),
63
+ });
64
+ });
65
+ window.stopGottyKeyboardDebug = () => {
66
+ stopped = true;
67
+ for (const [type, listener] of listeners) window.removeEventListener(type, listener, true);
68
+ subscription.dispose();
69
+ if (term._core._customKeyEventHandler === handler)
70
+ term.attachCustomKeyEventHandler(previousHandler);
71
+ };
72
+ return { records, stop: window.stopGottyKeyboardDebug };
73
+ };
74
+
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ // Run inside jsgotty. Exits after 60 seconds or unmodified Escape/q/Ctrl-C.
3
+ // Uses only built-ins and restores the prior Kitty mode and raw mode on exit.
4
+ const input=process.stdin, output=process.stdout;
5
+ const cyan = '\x1b[96m', reset = '\x1b[0m';
6
+ const keyNames = {
7
+ 9: 'tab', 13: 'enter', 27: 'esc', 32: 'space', 127: 'backspace',
8
+ 57358: 'caps-lock', 57359: 'scroll-lock', 57360: 'num-lock',
9
+ 57361: 'print-screen', 57362: 'pause', 57363: 'menu',
10
+ 57409: 'kp-decimal', 57410: 'kp-divide', 57411: 'kp-multiply',
11
+ 57412: 'kp-subtract', 57413: 'kp-add', 57414: 'kp-enter',
12
+ 57415: 'kp-equal', 57416: 'kp-separator',
13
+ 57417: 'kp-left', 57418: 'kp-right', 57419: 'kp-up', 57420: 'kp-down',
14
+ 57421: 'kp-page-up', 57422: 'kp-page-down', 57423: 'kp-home',
15
+ 57424: 'kp-end', 57425: 'kp-insert', 57426: 'kp-delete', 57427: 'kp-begin',
16
+ 57428: 'media-play', 57429: 'media-pause', 57430: 'media-play-pause',
17
+ 57431: 'media-reverse', 57432: 'media-stop', 57433: 'media-fast-forward',
18
+ 57434: 'media-rewind', 57435: 'media-next', 57436: 'media-previous',
19
+ 57437: 'media-record', 57438: 'volume-down', 57439: 'volume-up',
20
+ 57440: 'volume-mute', 57453: 'altgr', 57454: 'iso-level5-shift',
21
+ };
22
+ for (let n=13; n<=35; n++) keyNames[57376+n-13] = 'f'+n;
23
+ for (let n=0; n<=9; n++) keyNames[57399+n] = 'kp-'+n;
24
+ const modifierKeys = ['shift', 'ctrl', 'alt', 'super', 'hyper', 'meta'];
25
+ const modifierBits = [1, 4, 2, 8, 16, 32];
26
+ function readableChord(key, suffix, bits) {
27
+ const number = Number(key.split(':')[0] || 1);
28
+ let name;
29
+ if (suffix === 'u') {
30
+ if (number >= 57441 && number <= 57452) {
31
+ const index = (number-57441)%6;
32
+ name = modifierKeys[index] + (number >= 57447 ? '-right' : '-left');
33
+ bits &= ~modifierBits[index]; // "super-left", not "super-super-left".
34
+ } else {
35
+ name = keyNames[number];
36
+ if (!name) name = number >= 33 && number <= 0x10ffff &&
37
+ !(number >= 0x7f && number <= 0x9f) &&
38
+ !(number >= 0xd800 && number <= 0xf8ff)
39
+ ? String.fromCodePoint(number) : 'key-'+number;
40
+ }
41
+ } else if (suffix === '~') {
42
+ name = {2:'insert', 3:'delete', 5:'page-up', 6:'page-down', 7:'home', 8:'end',
43
+ 11:'f1', 12:'f2', 13:'f3', 14:'f4', 15:'f5', 17:'f6',
44
+ 18:'f7', 19:'f8', 20:'f9', 21:'f10', 23:'f11', 24:'f12'}[number];
45
+ } else {
46
+ name = {A:'up', B:'down', C:'right', D:'left', E:'begin',
47
+ F:'end', H:'home', P:'f1', Q:'f2', S:'f4'}[suffix];
48
+ }
49
+ const modifiers = [[4,'ctrl'], [2,'alt'], [1,'shift'], [8,'super'],
50
+ [16,'hyper'], [32,'meta'], [64,'caps-lock'], [128,'num-lock']]
51
+ .filter(([bit]) => bits & bit).map(([,label]) => label);
52
+ return [...modifiers, name || `key-${number}${suffix}`].join('-');
53
+ }
54
+ if(!input.isTTY || !output.isTTY) {console.error('Run this inside an interactive jsgotty terminal.');process.exit(1);}
55
+ const wasRaw=Boolean(input.isRaw);let closed=false,buffer='';
56
+ const finish=()=>{if(closed)return;closed=true;clearTimeout(timer);output.write('\x1b[<u\r\nKeyboard mode restored.\r\n');input.setRawMode(wasRaw);input.pause();};
57
+ const timer=setTimeout(finish,60000);
58
+ process.on('SIGINT',finish);process.on('SIGTERM',finish);process.on('exit',finish);
59
+ input.setRawMode(true);input.setEncoding('utf8');input.resume();
60
+ output.write('Kitty keyboard test: try SUP, CTRL, ALT, SHFT + letters/arrows.\r\nEscape, q or Ctrl-C exits; automatic exit in 60 seconds.\r\n\x1b[>11u\x1b[?u');
61
+ input.on('data',data=>{
62
+ if(closed)return;
63
+ buffer+=data;
64
+ while(buffer) {
65
+ const match=/^\x1b\[([?]?)([0-9:;]*)([u~A-Z])/.exec(buffer);
66
+ if(!match) {
67
+ if(buffer==='\x1b' || /^\x1b\[[?0-9:;]*$/.test(buffer))break;
68
+ const ch=[...buffer][0];buffer=buffer.slice(ch.length);output.write('legacy '+JSON.stringify(ch)+'\r\n');
69
+ if(ch==='q'||ch==='\x03') {finish();return;}continue;
70
+ }
71
+ buffer=buffer.slice(match[0].length);
72
+ if(match[1]==='?'){output.write('negotiated flags='+match[2]+' (expected 11)\r\n');continue;}
73
+ const [key,mod='1']=match[2].split(';');const [encoded,type='1']=mod.split(':');const bits=Number(encoded)-1;
74
+ const names=[[1,'Shift'],[2,'Alt'],[4,'Ctrl'],[8,'Super'],[16,'Hyper'],[32,'Meta'],[64,'CapsLock'],[128,'NumLock']].filter(([bit])=>bits&bit).map(([,name])=>name);
75
+ const chord = readableChord(key, match[3], bits);
76
+ output.write(`${cyan}${chord}${reset} ${{1:'press',2:'repeat',3:'release'}[type]||type} raw=${JSON.stringify(match[0])} key=${key}${match[3]} modifiers=${names.join('+')||'none'}\r\n`);
77
+ if(match[3]==='u' && type==='1' && ((!(bits&63)&&['27','113'].includes(key)) || ((bits&63)===4 && key==='99'))) {finish();return;}
78
+ }
79
+ if(buffer.length>4096)buffer='';
80
+ });
@@ -0,0 +1,33 @@
1
+ // Injected only by manual-touch-diagnostics.mjs. Observes without cancelling events.
2
+ (()=>{
3
+ const endpoint=globalThis.__touchDiagnosticEndpoint, rows=[];
4
+ const state=()=>({viewport:document.querySelector('meta[name=viewport]')?.content,
5
+ width:innerWidth,clientWidth:document.documentElement.clientWidth,dpr:devicePixelRatio,
6
+ visual:visualViewport&&{scale:visualViewport.scale,width:visualViewport.width,height:visualViewport.height,x:visualViewport.offsetLeft,y:visualViewport.offsetTop},
7
+ mouse:globalThis.__gottyXterm?.modes?.mouseTrackingMode});
8
+ const push=row=>{rows.push({time:Date.now(),...row});if(rows.length>500)rows.shift()};
9
+ const describe=el=>el?.tagName+'.'+String(el?.className||'');
10
+ for(const type of ['touchstart','touchend','touchcancel','pointerdown','pointerup','pointercancel','mousedown','mouseup','click','dblclick','contextmenu']){
11
+ document.addEventListener(type,e=>{
12
+ const point=e.changedTouches?.[0]||e;
13
+ const row={type,target:describe(e.target),trusted:e.isTrusted,cancelable:e.cancelable,
14
+ prevented:e.defaultPrevented,detail:e.detail,touches:e.touches?.length,
15
+ pointerType:e.pointerType,x:point.clientX,y:point.clientY,
16
+ touchAction:e.target?.nodeType===1?getComputedStyle(e.target).touchAction:null,...state()};
17
+ queueMicrotask(()=>push({...row,preventedAfter:e.defaultPrevented}));
18
+ },{capture:true,passive:true});
19
+ }
20
+ for(const type of ['resize','scroll'])visualViewport?.addEventListener(type,()=>push({type:'visual-'+type,...state()}));
21
+ new MutationObserver(()=>push({type:'viewport-change',...state()})).observe(document.head,{attributes:true,subtree:true,childList:true});
22
+ let term;
23
+ setInterval(()=>{
24
+ const current=globalThis.__gottyXterm;
25
+ if(current&&current!==term){term=current;term.onData(data=>{
26
+ // Only terminal mouse reports; never collect typed commands or clipboard data.
27
+ if(/^\x1b\[<(\d+;){2}\d+[mM]$/.test(data)||data.startsWith('\x1b[M'))push({type:'xterm-mouse',data,...state()});
28
+ });push({type:'xterm-ready',...state()});}
29
+ if(rows.length)fetch(endpoint,{method:'POST',headers:{'Content-Type':'text/plain'},body:JSON.stringify(rows.splice(0)),keepalive:true}).catch(()=>{});
30
+ },500);
31
+ push({type:'diagnostic-ready',...state()});
32
+ globalThis.__touchDiagnostics={state,mark:label=>push({type:'mark',label,...state()})};
33
+ })();
@@ -0,0 +1,22 @@
1
+ // bun tests/manual-touch-diagnostics.mjs [shell]
2
+ // Isolated custom index: production assets are unchanged. Ctrl-C stops both servers.
3
+ import {join,resolve} from 'node:path';
4
+ import {mkdtempSync,appendFileSync} from 'node:fs';
5
+ import {tmpdir} from 'node:os';
6
+ const root=resolve(import.meta.dir,'..'),dir=mkdtempSync(join(tmpdir(),'jsgotty-touch-'));
7
+ const log=join(dir,'events.jsonl'),token=crypto.randomUUID();
8
+ const collector=Bun.serve({hostname:'127.0.0.1',port:0,async fetch(req){
9
+ if(new URL(req.url).pathname!=='/'+token)return new Response('',{status:404});
10
+ if(req.method==='POST'){
11
+ const data=await req.text();if(data.length<256000){try{for(const row of JSON.parse(data))appendFileSync(log,JSON.stringify(row)+'\n')}catch{}}
12
+ }
13
+ return new Response('ok',{headers:{'Access-Control-Allow-Origin':'*'}});
14
+ }});
15
+ const script=await Bun.file(join(import.meta.dir,'manual-touch-diagnostics.js')).text();
16
+ const injection=`<script>globalThis.__touchDiagnosticEndpoint=${JSON.stringify('http://127.0.0.1:'+collector.port+'/'+token)};${script}</script>`;
17
+ const html=(await Bun.file(join(root,'static/index.html')).text()).replace('</body>',injection+'\n</body>');
18
+ const index=join(dir,'index.html');await Bun.write(index,html);
19
+ console.log('Touch diagnostic log:',log);
20
+ const child=Bun.spawn([process.execPath,join(root,'gotty.js'),'-w','-r','--session','-p','0','--index',index,process.argv[2]||process.env.SHELL||'/system/bin/sh'],{stdio:['inherit','inherit','inherit']});
21
+ for(const sig of ['SIGINT','SIGTERM'])process.on(sig,()=>child.kill(sig));
22
+ try{process.exitCode=await child.exited;}finally{collector.stop(true)}
@@ -0,0 +1,116 @@
1
+ // Actual client.mjs processes and PTYs over WebSockets; servers below simulate
2
+ // only attach/detach and preserve the production TerminalModeTracker snapshots.
3
+ // bun test tests/nested-cli-pty.test.mjs
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import path from 'node:path';
7
+ import modes from '../terminal-modes.js';
8
+ const {TerminalModeTracker}=modes;
9
+ const sleep=ms=>new Promise(r=>setTimeout(r,ms));
10
+ async function until(check,label){for(let i=0;i<300;i++){if(check())return;await sleep(10);}throw Error('Timed out: '+label);}
11
+ function tracker(sequence=''){const t=new TerminalModeTracker();t.consume(Buffer.from(sequence));return t;}
12
+ function simulatedServer(initial, viewer=false){
13
+ const state=tracker(initial);let socket=null,input=()=>{};
14
+ const server=Bun.serve({hostname:'127.0.0.1',port:0,
15
+ fetch(req,server){if(server.upgrade(req))return;return new Response('fixture');},
16
+ websocket:{
17
+ message(ws,message){const text=String(message);
18
+ if(text[0]==='{'){
19
+ socket=ws;ws.send('m'+JSON.stringify({...state.snapshot(),kittyRestore:1}));
20
+ ws.send('a'+JSON.stringify({role:viewer?'viewer':'writer',writable:!viewer}));
21
+ }else if(text[0]==='1')input(Buffer.from(text.slice(1),'base64'));
22
+ else if(text[0]==='2')ws.send('2');
23
+ },close(ws){if(socket===ws)socket=null;}
24
+ }});
25
+ return {state,server,get socket(){return socket;},set input(fn){input=fn;},
26
+ output(data){state.consume(Buffer.from(data));socket?.send('1'+Buffer.from(data).toString('base64'));},
27
+ detach(){socket?.close(1000,'test detach');},
28
+ url:`ws://127.0.0.1:${server.port}/ws`};
29
+ }
30
+ const client=path.resolve(import.meta.dirname,'../client.mjs');
31
+ function spawnClient(url,onData){
32
+ return Bun.spawn([process.execPath,client,url,'-r','preserved'],{
33
+ terminal:{cols:100,rows:30,data(terminal,data){onData(terminal,Buffer.from(data));}},
34
+ });
35
+ }
36
+ // Emulate the outer browser's private parser reply; the separate Chromium test
37
+ // checks this against the real bundled xterm parser and rendering queue.
38
+ function terminalEndpoint(parent){
39
+ const state=tracker();Object.assign(state,structuredClone(parent));let pending='',output='';
40
+ return {state,get output(){return output;},data(terminal,data){
41
+ output+=data.toString('latin1');
42
+ state.consume(data);pending+=data.toString('latin1');
43
+ const re=/\x1b\]777;jsgotty-modes;([a-f0-9]{32})\x07/g;let m;
44
+ while((m=re.exec(pending)))terminal.write(`\x1b]777;jsgotty-modes;${m[1]};${Buffer.from(JSON.stringify({...state.snapshot(),kittyRestore:1})).toString('base64')}\x07`);
45
+ const start=pending.lastIndexOf('\x1b]');pending=start>=0&&!pending.slice(start).includes('\x07')?pending.slice(start):'';
46
+ }};
47
+ }
48
+ test('real nested CLI PTYs: detach each client, preserve parents, direct fresh attach', {skip:typeof Bun==='undefined',timeout:30000},async()=>{
49
+ const parentA=tracker('\x1b[=1u\x1b[>9u\x1b[?2004h').snapshot();
50
+ const b=simulatedServer('\x1b[>3u\x1b[?1049h\x1b[>11u\x1b[?1002h\x1b[?1006h');
51
+ const c=simulatedServer('\x1b[>9u\x1b[?1049h\x1b[>1u\x1b[>11u\x1b[?1003h\x1b[?1016h');
52
+ const parentB=b.state.snapshot(),outer=terminalEndpoint(parentA);const children=[];
53
+ const spawn=(url,onData)=>{const p=spawnClient(url,onData);children.push(p);return p;};
54
+ let first,second;
55
+ const equals=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
56
+ const freshAttach=async server=>{
57
+ const clean=terminalEndpoint(tracker().snapshot());
58
+ const proc=spawn(server.url,clean.data);await until(()=>server.socket,'fresh socket');
59
+ await until(()=>equals(clean.state.snapshot(),server.state.snapshot()),'fresh terminal restore');
60
+ server.detach();await proc.exited;proc.terminal.close();
61
+ assert.deepEqual(clean.state.snapshot(),tracker().snapshot(),'fresh terminal cleanup');
62
+ };
63
+ try{
64
+ first=spawn(b.url,outer.data);await until(()=>b.socket,'middle attach');
65
+ await until(()=>equals(outer.state.snapshot(),parentB),'middle initial state');
66
+ b.input=data=>second?.terminal.write(data);
67
+ second=spawn(c.url,(_,data)=>b.output(data));await until(()=>c.socket,'inner attach');
68
+ await until(()=>equals(outer.state.snapshot(),c.state.snapshot()),'nested initial state');
69
+ // Close innermost transport, keeping both its PTY state and the middle server.
70
+ c.detach();await second.exited;second.terminal.close();
71
+ await until(()=>equals(outer.state.snapshot(),parentB),'inner cleanup restores parent');
72
+ c.output('\x1b[<u\x1b[?1049l\x1b[>3u\x1b[?1000l');
73
+ await freshAttach(c);
74
+ second=spawn(c.url,(_,data)=>b.output(data));await until(()=>c.socket,'inner reattach');
75
+ await until(()=>equals(outer.state.snapshot(),c.state.snapshot()),'inner resumed state');
76
+ // Drop middle transport; innermost CLI remains alive and keeps tracking.
77
+ b.detach();await first.exited;first.terminal.close();
78
+ assert.deepEqual(outer.state.snapshot(),parentA,'middle cleanup restores original parent stack');
79
+ c.output('\x1b[?1049h\x1b[>9u\x1b[?1002h\x1b[?1006h');
80
+ await until(()=>equals(b.state.snapshot(),c.state.snapshot()),'output while middle detached');
81
+ await freshAttach(b);
82
+ first=spawn(b.url,outer.data);await until(()=>b.socket,'middle reattach');
83
+ await until(()=>equals(outer.state.snapshot(),c.state.snapshot()),'middle resumed state');
84
+ // CLI clients must forward OSC52 untouched to the actual outer emulator.
85
+ const osc52='\x1b]52;c;dGVzdA==\x07\x1b]52;c;?\x1b\\';
86
+ c.output(osc52);await until(()=>outer.output.includes(osc52),'OSC52 pass-through');
87
+ // Real input still traverses both clients after querying/restoring.
88
+ let received='';c.input=data=>{received+=data.toString();};
89
+ first.terminal.write('\x1b[120;9u');await until(()=>received.includes('\x1b[120;9u'),'Super-X input through both clients');
90
+ c.detach();await second.exited;second.terminal.close();
91
+ await until(()=>equals(outer.state.snapshot(),parentB),'final inner cleanup');
92
+ b.detach();await first.exited;first.terminal.close();
93
+ assert.deepEqual(outer.state.snapshot(),parentA);
94
+ }finally{
95
+ for(const p of children){try{p.kill();p.terminal.close();}catch{}}
96
+ b.server.stop(true);c.server.stop(true);
97
+ }
98
+ });
99
+ test('real viewer PTYs exit with legacy and Kitty Ctrl-C/D/Q and restore parent', {skip:typeof Bun==='undefined',timeout:30000},async()=>{
100
+ for(const [key,code] of [[3,130],[4,0],[17,0],[99,130],[100,0],[113,0]]){
101
+ const server=simulatedServer('\x1b[>11u',true);
102
+ const outer=terminalEndpoint(tracker().snapshot());
103
+ const proc=spawnClient(server.url,outer.data);
104
+ try{
105
+ await until(()=>outer.output.includes('Exit by Ctrl+C/D/Q'),'viewer ready');
106
+ const sequence=key<32?String.fromCharCode(key):'\x1b['+key+';5u';
107
+ for(const byte of Buffer.from(sequence)) proc.terminal.write(Buffer.from([byte]));
108
+ await until(()=>proc.exitCode!==null,'viewer exited');
109
+ assert.equal(await proc.exited,code);
110
+ assert.deepEqual(outer.state.snapshot(),tracker().snapshot());
111
+ }finally{
112
+ if(proc.exitCode===null){proc.kill();await proc.exited;}
113
+ proc.terminal.close();server.server.stop(true);
114
+ }
115
+ }
116
+ });
@@ -0,0 +1,144 @@
1
+ // Opt-in real bundled-xterm integration; no browser/dependency downloads.
2
+ // CHROMIUM_HEADLESS=/path/to/chromium-headless-shell node --test tests/kitty-keyboard-browser.test.js
3
+ const test=require('node:test');
4
+ const assert=require('node:assert/strict');
5
+ const fs=require('node:fs');
6
+ const path=require('node:path');
7
+ const http=require('node:http');
8
+ const {spawn}=require('node:child_process');
9
+ const {once}=require('node:events');
10
+ const WebSocket=require('ws');
11
+ const root=path.join(__dirname,'../static');
12
+ test('browser -> CLI -> CLI: independent disconnects preserve terminal modes', {skip:!process.env.CHROMIUM_HEADLESS,timeout:60000},async()=>{
13
+ const bundle=fs.readFileSync(path.join(root,'js/gotty.js'),'utf8').replace('(i.nc = void 0));','(i.nc = void 0)); window.TestGoTTYXterm = i(634).GoTTYXterm;');
14
+ const modesSource=fs.readFileSync(path.join(__dirname,'../terminal-modes.js'),'utf8');
15
+ const server=http.createServer((req,res)=>{
16
+ if(req.url==='/modes.js'){res.setHeader('Content-Type','text/javascript');res.end('(function(){var module={exports:{}};'+modesSource+';window.modes=module.exports;})();');}
17
+ else if(req.url==='/bundle.js'){res.setHeader('Content-Type','text/javascript');res.end(bundle);}
18
+ else if(req.url==='/keyboard.js'){res.setHeader('Content-Type','text/javascript');res.end(fs.readFileSync(path.join(root,'js/kitty-keyboard.js')));}
19
+ else res.end('<!doctype html><div id="fixture" style="width:800px;height:600px"></div><script src="/modes.js"></script><script src="/keyboard.js"></script><script src="/bundle.js"></script><script>window.wrapper=new TestGoTTYXterm(document.getElementById("fixture"));window.sent=[];wrapper.onInput(b=>sent.push(new TextDecoder().decode(b)));wrapper.kittyKeyboard.android=true;</script>');
20
+ });
21
+ server.listen(0,'127.0.0.1');await once(server,'listening');
22
+ const child=spawn(process.env.CHROMIUM_HEADLESS,['--no-sandbox','--disable-gpu','--remote-debugging-port=0','http://127.0.0.1:'+server.address().port],{detached:true,stdio:['ignore','ignore','pipe']});
23
+ let ws;
24
+ try{
25
+ const endpoint=await new Promise((resolve,reject)=>{let log='';const timer=setTimeout(()=>reject(Error('Browser startup timeout: '+log)),15000);child.on('error',reject);child.on('exit',code=>{clearTimeout(timer);reject(Error('Browser exited '+code+': '+log));});child.stderr.on('data',b=>{log+=b;const m=log.match(/DevTools listening on (ws:\/\/[^\s]+)/);if(m){clearTimeout(timer);resolve(m[1]);}});});
26
+ const u=new URL(endpoint);let tab;
27
+ for(let i=0;i<50;i++){tab=(await (await fetch('http://'+u.host+'/json/list')).json()).find(x=>x.type==='page');if(tab)break;await new Promise(r=>setTimeout(r,100));}
28
+ ws=new WebSocket(tab.webSocketDebuggerUrl);await once(ws,'open');let id=0;const pending=new Map();ws.on('message',data=>{const m=JSON.parse(data);if(pending.has(m.id)){const {resolve,reject}=pending.get(m.id);pending.delete(m.id);m.error?reject(m.error):resolve(m.result);}});
29
+ const call=(method,params)=>new Promise((resolve,reject)=>{pending.set(++id,{resolve,reject});ws.send(JSON.stringify({id,method,params}));});
30
+ for(let i=0;i<50;i++){const r=await call('Runtime.evaluate',{expression:'Boolean(window.wrapper)',returnByValue:true});if(r.result.value)break;await new Promise(r=>setTimeout(r,100));}
31
+ const result=await call('Runtime.evaluate',{awaitPromise:true,returnByValue:true,expression:`(async()=>{
32
+
33
+ const term=wrapper.term;
34
+ const {TerminalModeTracker,validateSnapshot}=modes;
35
+ const modeTransition=(from,to)=>modes.modeTransition(from,to,{kittyRestore:true});
36
+ const tracker=()=>new TerminalModeTracker();
37
+ const equal=(a,b,label)=>{if(JSON.stringify(a)!==JSON.stringify(b))throw Error(label+JSON.stringify({actual:a,expected:b}));};
38
+ const snapshot=()=>validateSnapshot(wrapper.sessionModes());
39
+ const drain=()=>new Promise(r=>wrapper.enqueueTerminal(done=>{done();r();}));
40
+ const write=s=>new Promise(r=>term.write(s,r));
41
+
42
+ // Mode-only restoration must preserve visible cells and cursor on either
43
+ // screen, even if the other screen's Kitty stack differs.
44
+ for(const alternate of [false,true]){
45
+ await write('\\x1bcNORMAL CONTENT');
46
+ if(alternate)await write('\\x1b[?1049hVISIBLE FULLSCREEN CONTENT');
47
+ await write('\\x1b[4;9H');
48
+ const view=()=>({type:term.buffer.active.type,x:term.buffer.active.cursorX,y:term.buffer.active.cursorY,
49
+ lines:Array.from({length:term.rows},(_,i)=>term.buffer.active.getLine(i)?.translateToString(true))});
50
+ const before=view(),from=snapshot(),to=structuredClone(from);
51
+ const inactive=alternate?'normal':'alternate';
52
+ to.kitty[inactive]={flags:9,stack:[0,1]};
53
+ // No negotiated support: do not switch screens to repair a hidden stack.
54
+ const fallback=modes.modeTransition(from,to);
55
+ equal(fallback,'','unnegotiated hidden restore');
56
+ await write(fallback);equal(view(),before,'fallback preserves cells/cursor');
57
+ const sequence=modeTransition(from,to);
58
+ if(sequence.includes('[?1047')||sequence.includes('[?1049'))throw Error('hidden restore switched screens');
59
+ await write(sequence);
60
+ equal(view(),before,'negotiated restore preserves cells/cursor');
61
+ equal(snapshot(),to,'hidden stack restored without switching');
62
+ }
63
+ await write('\\x1bc');
64
+ // Regression: raw xterm writes already queued before a restore, followed by
65
+ // newer output. Restoration must happen between these two generations.
66
+ term.write('\\x1b[?1002h\\x1b[>11u');
67
+ wrapper.applySessionModes(tracker().snapshot());
68
+ wrapper.writeTerminal('\\x1b[>1u');await drain();
69
+ equal(snapshot().mouse,'NONE','pending mouse output');
70
+ equal(snapshot().kitty.normal,{flags:1,stack:[0]},'pending Kitty output');
71
+ await write('\\x1bc');
72
+ const outer=tracker(),middle=tracker(),inner=tracker();
73
+ let browserConnected=true,middleConnected=true,innerConnected=true;
74
+ const outerOutput=s=>{outer.consume(new TextEncoder().encode(s));if(browserConnected)wrapper.writeTerminal(s);};
75
+ const middleOutput=s=>{middle.consume(new TextEncoder().encode(s));if(middleConnected)outerOutput(s);};
76
+ const innerOutput=s=>{inner.consume(new TextEncoder().encode(s));if(innerConnected)middleOutput(s);};
77
+ const browserResume=async()=>{browserConnected=true;wrapper.applySessionModes(outer.snapshot());await drain();equal(snapshot(),outer.snapshot(),'browser resume');};
78
+
79
+ const freshAttach=async(state,label)=>{
80
+ const div=document.createElement('div');document.body.append(div);
81
+ const fresh=new TestGoTTYXterm(div);
82
+ // A new terminal attaches directly to that server's preserved session.
83
+ fresh.applySessionModes(state);
84
+ await new Promise(r=>fresh.enqueueTerminal(done=>{done();r();}));
85
+ equal(validateSnapshot(fresh.sessionModes()),state,label+' fresh direct attach');
86
+ // Applying the snapshot twice is idempotent, including Kitty stack depth.
87
+ fresh.applySessionModes(state);
88
+ await new Promise(r=>fresh.enqueueTerminal(done=>{done();r();}));
89
+ equal(validateSnapshot(fresh.sessionModes()),state,label+' repeated snapshot');
90
+ fresh.close();div.remove();
91
+ };
92
+ const a='\\x1b[=1u\\x1b[>9u\\x1b[?2004h';
93
+ const b='\\x1b[>3u\\x1b[?1049h\\x1b[>11u\\x1b[?1002h\\x1b[?1006h';
94
+ const c='\\x1b[>9u\\x1b[?1049h\\x1b[>1u\\x1b[>11u\\x1b[?1003h\\x1b[?1016h\\x1b[?25l';
95
+ outerOutput(a);await drain();const parentA=outer.snapshot();
96
+ middle.consume(new TextEncoder().encode(b));
97
+ outerOutput(modeTransition(outer.snapshot(),middle.snapshot()));await drain();const parentB=middle.snapshot();
98
+ inner.consume(new TextEncoder().encode(c));
99
+ middleOutput(modeTransition(middle.snapshot(),inner.snapshot()));await drain();
100
+ equal(snapshot(),inner.snapshot(),'both clients attached');
101
+ // Outermost browser transport drops: both CLI sessions keep tracking output.
102
+ browserConnected=false;wrapper.setDisconnected(true);
103
+ innerOutput('\\x1b[<u\\x1b[?1002h\\x1b[?1006h');
104
+ await freshAttach(outer.snapshot(),'outer disconnected');
105
+ await browserResume();wrapper.setDisconnected(false);
106
+ equal(snapshot(),inner.snapshot(),'browser sees offline changes');
107
+ // Innermost CLI disconnects. Its cleanup must restore middle parent, not
108
+ // defaults, and its remote PTY continues changing while detached.
109
+ innerConnected=false;
110
+ middleOutput(modeTransition(middle.snapshot(),parentB));await drain();
111
+ equal(snapshot(),parentB,'inner CLI cleanup');
112
+ innerOutput('\\x1b[?1049l\\x1b[>3u\\x1b[?1000l\\x1b[?1016l');
113
+ await freshAttach(inner.snapshot(),'inner disconnected');
114
+ innerConnected=true;middleOutput(modeTransition(middle.snapshot(),inner.snapshot()));await drain();
115
+ equal(snapshot(),inner.snapshot(),'inner CLI resume');
116
+ // Middle CLI disconnects while its inner CLI stays alive.
117
+ middleConnected=false;outerOutput(modeTransition(outer.snapshot(),parentA));await drain();
118
+ equal(snapshot(),parentA,'middle CLI cleanup');
119
+ innerOutput('\\x1b[?1049h\\x1b[>9u\\x1b[?1002h\\x1b[?1006h');
120
+ await freshAttach(middle.snapshot(),'middle disconnected');
121
+ middleConnected=true;outerOutput(modeTransition(outer.snapshot(),middle.snapshot()));await drain();
122
+ equal(snapshot(),inner.snapshot(),'middle CLI resume');
123
+ // Drop all transports, reconnect inside-out, then repeat the browser snapshot.
124
+ browserConnected=middleConnected=innerConnected=false;
125
+ innerOutput('\\x1b[<u\\x1b[?2004h');
126
+ innerConnected=true;middleOutput(modeTransition(middle.snapshot(),inner.snapshot()));
127
+ middleConnected=true;outerOutput(modeTransition(outer.snapshot(),middle.snapshot()));
128
+ await browserResume();await browserResume();
129
+ equal(snapshot(),inner.snapshot(),'all layers resumed; no stack duplication');
130
+ // Private query traverses output and returns the exact current state once.
131
+ sent.length=0;
132
+ await write('\\x1b]777;jsgotty-modes;0123456789abcdef0123456789abcdef\\x07');
133
+ equal(sent.length,1,'private query reply count');
134
+ const encoded=sent[0].split(';')[3].slice(0,-1);
135
+ equal(JSON.parse(atob(encoded)).kittyRestore,1,'restore capability advertised');
136
+ equal(validateSnapshot(JSON.parse(atob(encoded))),snapshot(),'private query state');
137
+ wrapper.close();return 'ok';
138
+ })()`});
139
+ assert.equal(result.exceptionDetails,undefined,JSON.stringify(result.exceptionDetails));assert.equal(result.result.value,'ok');
140
+ }finally{
141
+ ws?.close();if(child.pid){try{process.kill(-child.pid,'SIGTERM');}catch{}}
142
+ server.closeAllConnections();await new Promise(r=>server.close(r));
143
+ }
144
+ });
@@ -0,0 +1,44 @@
1
+ # Reconnect mode tests
2
+
3
+ No extra dependencies are needed. Use an already installed Chromium:
4
+
5
+ ```sh
6
+ node --test tests/client-modes.test.mjs tests/client-exit.test.mjs tests/reconnect-modes.test.js
7
+ bun test tests/nested-cli-pty.test.mjs
8
+ CHROMIUM_HEADLESS=/usr/local/bin/chromium-headless-shell node --test tests/reconnect-modes-browser.test.js tests/kitty-keyboard-browser.test.js
9
+ ```
10
+
11
+ - `reconnect-modes-browser.test.js` runs the shipped xterm in real Chromium. It simulates browser → CLI → CLI output routing using the production mode trackers and transition code. It drops the browser, middle, and inner connections independently, changes modes while disconnected, and reconnects. Each dropped session is also attached directly to a newly constructed, clean xterm. It checks queued old output, new output after restoration, idempotent snapshots, mouse protocol/encoding, DEC flags, both screens' Kitty stacks, and the private reply.
12
+ - `nested-cli-pty.test.mjs` runs two actual `client.mjs` processes using Bun PTYs and WebSockets. Minimal server fixtures implement attach/output/detach with production mode trackers. It checks inner and middle disconnects, direct attachments from clean terminal models, changes while detached, parent restoration, and Super-X forwarding through both clients. The outer terminal model's reply is independently checked against real xterm by the browser test.
13
+ - `client-modes.test.mjs` checks fragmented replies, unknown modes, fallback, raw-mode restoration, transparent ordinary input/large pastes, foreign nonces, and that startup reply filtering does not consume later application queries indefinitely.
14
+
15
+ These are mode/input-state tests, not screen-content replay tests. The minimal servers are not a replacement for testing the full GoTTY server's session lifecycle. Forced process death (SIGKILL), without a chance to execute cleanup, cannot restore the local terminal.
16
+
17
+ ## Private query and native terminal compatibility
18
+
19
+ The query is `ESC ] 777 ; jsgotty-modes ; <32 hex nonce> BEL`. The browser replies once with `ESC ] 777 ; jsgotty-modes ; <same nonce> ; <base64 JSON snapshot> BEL`. A new nonce prevents replies intended for a nested client from being consumed by a different layer. Only the browser extension implements this query; it is not a standard terminal query.
20
+
21
+ Upstream source inspection (not GUI runtime testing):
22
+
23
+ - Ghostty, revision `8ef0ec1238220a5a714be452ed2844f0691fcf83`: [OSC dispatch](https://github.com/ghostty-org/ghostty/blob/8ef0ec1238220a5a714be452ed2844f0691fcf83/src/terminal/osc.zig), [rxvt extension parser](https://github.com/ghostty-org/ghostty/blob/8ef0ec1238220a5a714be452ed2844f0691fcf83/src/terminal/osc/parsers/rxvt_extension.zig). OSC 777 accepts `notify`; `jsgotty-modes` takes the unknown-extension branch, logs `unknown rxvt extension: jsgotty-modes`, returns null, and produces no terminal reply or notification. Thus this probe is not silent in Ghostty's diagnostic log.
24
+ - Konsole, revision `a25cd724d5790fbe9cda01db63d478ff7eba8d7b`: [Vt102Emulation](https://github.com/KDE/konsole/blob/a25cd724d5790fbe9cda01db63d478ff7eba8d7b/src/Vt102Emulation.cpp), [Session](https://github.com/KDE/konsole/blob/a25cd724d5790fbe9cda01db63d478ff7eba8d7b/src/session/Session.cpp), [ContainerRegistry](https://github.com/KDE/konsole/blob/a25cd724d5790fbe9cda01db63d478ff7eba8d7b/src/containers/ContainerRegistry.cpp). OSC 777 emits an internal Qt signal; its built-in handlers accept `notify` or `container`. `jsgotty-modes` matches neither, so no terminal reply, notification, or container change results.
25
+
26
+ OSC 777 is already used for rxvt-style extensions; the jsgotty subcommand avoids `notify` and `container`, but future or third-party terminal handlers are not covered by this review.
27
+
28
+ After 200 ms without the private reply, the CLI requests standard DECRQM and Kitty flags, with a total startup timeout of 1 second. Unsupported/unanswered fields remain unknown rather than becoming guessed defaults. Standard Kitty queries report current flags, not the hidden stack. Full stack restoration is therefore available between updated jsgotty clients/browser; native terminals cannot promise restoration of pre-existing hidden entries if the remote application itself pops them. The CLI never clears an arbitrary 100 entries.
29
+
30
+ Startup standard replies are consumed at most once per requested mode, with a bounded late-reply window. Standard replies have no nonce: a reply to an application's identical query during that short outstanding window cannot be distinguished perfectly from a delayed startup reply.
31
+
32
+ ## Native mouse switches and exit ordering
33
+
34
+ Standard probing also retains optional `mouseModeBits` and `mouseEncodingBits`, with boolean values for answered switches and null for silence. For multiple active replies, the effective priority is explicitly ANY > DRAG > VT200 > X10 and SGR_PIXELS > SGR. This is a compatibility policy for the represented modes, not a claim that larger DEC numbers universally win. Cleanup restores the recorded switches (known disabled first, enabled in increasing priority); partial replies do not invent values for unanswered switches. CLI attachment to a known session normalizes the whole represented group before enabling its effective mode, even when the local aggregate name matches, because native terminals may retain independent hidden switches. Server tracking continues to follow xterm's exclusive-mode semantics.
35
+
36
+ The tests exercise both independent-switch and exclusive-mode terminal models, downgrade from stronger modes, unchanged aggregates with conflicting underlying switches, and partial replies. `client-exit.test.mjs` executes the production exit handlers with delayed stdout callbacks: the first exit request wins, subsequent bytes/signals do not schedule a second exit, and exit still waits for stdout to flush.
37
+
38
+ ## Preserve screen contents during hidden Kitty restoration
39
+
40
+ A private snapshot reply now advertises `kittyRestore: 1`. Only after receiving that capability may the CLI emit `OSC 777 ; jsgotty-kitty-v1 ; normal|alternate ; flags ; comma-separated-stack BEL` (an empty stack has an empty last field). This updates the named Kitty state in place; it never activates either screen. The browser masks unsupported flags as usual. Intermediate session trackers recognize bounded, validated BEL/ST-terminated commands so reconnect snapshots continue to match the terminal.
41
+
42
+ Without this capability, a mode-only transition leaves the inactive stack unchanged. It must not use a temporary `?1047l`/`?1047h` roundtrip: xterm clears the alternate buffer when leaving it, destroying the visible fullscreen contents even if the final active screen is unchanged. A requested screen change can still restore each stack while that screen is naturally active. Native/older terminals therefore retain a limitation on restoring inactive Kitty state; preserving visible contents takes precedence.
43
+
44
+ The real Chromium test now checks visible rows and cursor coordinates on both normal and alternate screens, with negotiated restoration and without the capability. It also checks that the advertised extension restores the hidden stack, alongside the existing nested-disconnect/direct-fresh-attach scenarios. Tracker tests cover byte-split commands, BEL/ST termination, cancellation, malformed values, oversized stacks, and unrelated DCS strings.