@leverageaiapps/gogogo 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,729 @@
1
+ // Clean token from URL after cookie-based auth is established
2
+ if (window.location.search.includes('token=')) {
3
+ const url = new URL(window.location);
4
+ url.searchParams.delete('token');
5
+ history.replaceState(null, '', url.pathname + url.search);
6
+ }
7
+
8
+ // Theme management
9
+ const darkTheme = { background: '#0a0a0a', foreground: '#ededed', cursor: '#ededed', selectionBackground: '#3b82f644' };
10
+ const lightTheme = { background: '#f5f5f5', foreground: '#1a1a1a', cursor: '#1a1a1a', selectionBackground: '#2563eb44' };
11
+ let isLightTheme = (() => {
12
+ const saved = localStorage.getItem('theme');
13
+ if (saved) return saved === 'light';
14
+ try { return window.matchMedia('(prefers-color-scheme: light)').matches; } catch(e) { return false; }
15
+ })();
16
+
17
+ window.term = new Terminal({
18
+ cursorBlink: true,
19
+ fontSize: 14,
20
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
21
+ smoothScrollDuration: 120,
22
+ theme: isLightTheme ? lightTheme : darkTheme,
23
+ scrollback: 10000,
24
+ allowTransparency: false,
25
+ });
26
+
27
+ const fitAddon = new FitAddon.FitAddon();
28
+ term.loadAddon(fitAddon);
29
+ term.open(document.getElementById('terminal-container'));
30
+ try { fitAddon.fit(); } catch(e) {}
31
+
32
+ const statusDot = document.getElementById('status-dot');
33
+ const input = document.getElementById('input');
34
+ const scrollBtn = document.getElementById('scroll-to-bottom');
35
+ const specialKeysBtn = document.getElementById('special-keys-btn');
36
+ const specialKeysPopup = document.getElementById('special-keys-popup');
37
+
38
+ let ws = null;
39
+ let reconnectAttempts = 0;
40
+ const MAX_RECONNECT_ATTEMPTS = 3;
41
+ let isUserScrolling = false;
42
+ let lastSeq = 0; // track last received sequence number for delta sync
43
+ let nativeInputMode = false;
44
+
45
+ // Apply saved theme on load
46
+ function applyTheme(light) {
47
+ const root = document.documentElement;
48
+ const sunIcon = document.getElementById('theme-icon-sun');
49
+ const moonIcon = document.getElementById('theme-icon-moon');
50
+ if (light) {
51
+ root.classList.add('light');
52
+ term.options.theme = lightTheme;
53
+ sunIcon.style.display = '';
54
+ moonIcon.style.display = 'none';
55
+ } else {
56
+ root.classList.remove('light');
57
+ term.options.theme = darkTheme;
58
+ sunIcon.style.display = 'none';
59
+ moonIcon.style.display = '';
60
+ }
61
+ }
62
+ applyTheme(isLightTheme);
63
+
64
+ document.getElementById('theme-toggle').addEventListener('click', () => {
65
+ isLightTheme = !isLightTheme;
66
+ localStorage.setItem('theme', isLightTheme ? 'light' : 'dark');
67
+ applyTheme(isLightTheme);
68
+ });
69
+
70
+ // Follow system theme changes when user hasn't manually overridden
71
+ try {
72
+ window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (e) => {
73
+ if (!localStorage.getItem('theme')) {
74
+ isLightTheme = e.matches;
75
+ applyTheme(isLightTheme);
76
+ }
77
+ });
78
+ } catch(e) {}
79
+
80
+ // Title sync — follow terminal escape sequences
81
+ const DEFAULT_TITLE = 'gogogo Terminal';
82
+ let terminalTitle = DEFAULT_TITLE;
83
+ term.onTitleChange((title) => {
84
+ // Strip leading emoji — favicon handles branding
85
+ terminalTitle = (title || DEFAULT_TITLE).replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\uFE0F]\s*/u, '');
86
+ document.title = terminalTitle;
87
+ });
88
+
89
+ // Touch scrolling state
90
+ const terminalContainer = document.getElementById('terminal-container');
91
+ const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
92
+
93
+ // Dynamic input area height -> adjust terminal container bottom
94
+ const inputArea = document.getElementById('input-area');
95
+ function updateTerminalBottom() {
96
+ const h = inputArea.offsetHeight;
97
+ terminalContainer.style.bottom = h + 'px';
98
+ scrollBtn.style.bottom = (h + 10) + 'px';
99
+ }
100
+
101
+ // Initialize touch scrolling for mobile devices
102
+ if (isTouchDevice) {
103
+ initTouchScrolling(terminalContainer, () => { isUserScrolling = true; });
104
+ } else {
105
+ // Desktop: click on terminal activates native input
106
+ terminalContainer.addEventListener('click', (e) => {
107
+ if (e.target.closest('#scroll-to-bottom')) return;
108
+ activateNativeInput();
109
+ });
110
+ }
111
+
112
+ // Switch back to input box mode when input is focused
113
+ input.addEventListener('focus', () => {
114
+ activateInputBoxMode();
115
+ });
116
+
117
+ // Touch scrolling implementation for smooth mobile scrolling
118
+ function initTouchScrolling(container, onScrollStart) {
119
+ const touchState = {
120
+ startY: 0, lastY: 0, lastTime: 0,
121
+ velocity: 0, identifier: null,
122
+ touching: false, velocityHistory: [],
123
+ accumulator: 0, inertiaId: null
124
+ };
125
+
126
+ // Create touch overlay
127
+ const overlay = createTouchOverlay(container);
128
+
129
+ // Attach event handlers
130
+ overlay.addEventListener('touchstart', handleTouchStart, { passive: false });
131
+ overlay.addEventListener('touchmove', handleTouchMove, { passive: false });
132
+ overlay.addEventListener('touchend', handleTouchEnd, { passive: false });
133
+ overlay.addEventListener('touchcancel', handleTouchCancel, { passive: false });
134
+
135
+ // Prevent conflicts with input area
136
+ const inputArea = document.getElementById('input-area');
137
+ if (inputArea) {
138
+ inputArea.addEventListener('touchstart', e => e.stopPropagation(), { passive: true });
139
+ }
140
+
141
+ function createTouchOverlay(parent) {
142
+ const div = document.createElement('div');
143
+ Object.assign(div.style, {
144
+ position: 'absolute', top: '0', left: '0', right: '0', bottom: '0',
145
+ zIndex: '1', touchAction: 'none', webkitTouchCallout: 'none',
146
+ webkitUserSelect: 'none', userSelect: 'none', pointerEvents: 'auto'
147
+ });
148
+ parent.appendChild(div);
149
+ return div;
150
+ }
151
+
152
+ // Use xterm scrollLines API for v6 virtual scrolling
153
+ let lineAccumulator = 0;
154
+ function performScroll(deltaY) {
155
+ lineAccumulator += deltaY;
156
+ // Approximate line height for current font size
157
+ const lineHeight = 17;
158
+ const lines = Math.trunc(lineAccumulator / lineHeight);
159
+ if (lines !== 0) {
160
+ term.scrollLines(lines);
161
+ lineAccumulator -= lines * lineHeight;
162
+ }
163
+ }
164
+
165
+ function handleTouchStart(e) {
166
+ e.preventDefault();
167
+ cancelInertia();
168
+ touchState.accumulator = 0;
169
+ lineAccumulator = 0;
170
+
171
+ if (e.touches.length > 0) {
172
+ const touch = e.touches[0];
173
+ Object.assign(touchState, {
174
+ identifier: touch.identifier,
175
+ startX: touch.clientX,
176
+ startY: touch.clientY,
177
+ lastY: touch.clientY,
178
+ lastTime: performance.now(),
179
+ startTime: performance.now(),
180
+ velocity: 0,
181
+ velocityHistory: [],
182
+ touching: true,
183
+ didMove: false
184
+ });
185
+ onScrollStart();
186
+ }
187
+ }
188
+
189
+ function handleTouchMove(e) {
190
+ e.preventDefault();
191
+ if (!touchState.touching || e.touches.length === 0) return;
192
+
193
+ const touch = findTrackedTouch(e.touches) || e.touches[0];
194
+ const currentY = touch.clientY;
195
+ const deltaY = touchState.lastY - currentY;
196
+ const currentTime = performance.now();
197
+ const timeDelta = Math.max(1, currentTime - touchState.lastTime);
198
+
199
+ // Track if finger moved significantly (distinguishes tap from scroll)
200
+ if (!touchState.didMove) {
201
+ const dx = Math.abs(touch.clientX - touchState.startX);
202
+ const dy = Math.abs(currentY - touchState.startY);
203
+ if (dx > 10 || dy > 10) {
204
+ touchState.didMove = true;
205
+ }
206
+ }
207
+
208
+ // Update velocity
209
+ updateVelocity(deltaY / timeDelta);
210
+
211
+ touchState.lastY = currentY;
212
+ touchState.lastTime = currentTime;
213
+ touchState.accumulator += deltaY;
214
+
215
+ // Apply scroll when threshold reached
216
+ if (Math.abs(touchState.accumulator) >= 0.5) {
217
+ performScroll(touchState.accumulator * 1.8);
218
+ touchState.accumulator = touchState.accumulator % 0.5;
219
+ }
220
+ }
221
+
222
+ function handleTouchEnd(e) {
223
+ e.preventDefault();
224
+ if (!isTouchEnded(e.touches)) return;
225
+
226
+ const touchDuration = performance.now() - touchState.startTime;
227
+ const wasTap = !touchState.didMove && touchDuration < 300;
228
+
229
+ touchState.touching = false;
230
+ touchState.identifier = null;
231
+
232
+ if (wasTap) {
233
+ // Short tap with no movement -> activate native input
234
+ // Must blur input first to dismiss its caret on mobile
235
+ input.blur();
236
+ // Must call focus synchronously within user gesture for mobile keyboard
237
+ term.focus();
238
+ nativeInputMode = true;
239
+ document.getElementById('input-area').classList.add('native-mode');
240
+ return;
241
+ }
242
+
243
+ // Apply remaining scroll
244
+ if (Math.abs(touchState.accumulator) > 0) {
245
+ performScroll(touchState.accumulator * 1.8);
246
+ touchState.accumulator = 0;
247
+ }
248
+
249
+ // Start inertia if needed
250
+ if (Math.abs(touchState.velocity) > 0.01) {
251
+ startInertia();
252
+ }
253
+ }
254
+
255
+ function handleTouchCancel(e) {
256
+ e.preventDefault();
257
+ resetTouchState();
258
+ cancelInertia();
259
+ }
260
+
261
+ function findTrackedTouch(touches) {
262
+ for (let i = 0; i < touches.length; i++) {
263
+ if (touches[i].identifier === touchState.identifier) {
264
+ return touches[i];
265
+ }
266
+ }
267
+ return null;
268
+ }
269
+
270
+ function isTouchEnded(touches) {
271
+ return !findTrackedTouch(touches);
272
+ }
273
+
274
+ function updateVelocity(instant) {
275
+ touchState.velocityHistory.push(instant);
276
+ if (touchState.velocityHistory.length > 5) {
277
+ touchState.velocityHistory.shift();
278
+ }
279
+
280
+ // Calculate weighted average
281
+ let weightedSum = 0, totalWeight = 0;
282
+ touchState.velocityHistory.forEach((v, i) => {
283
+ const weight = i + 1;
284
+ weightedSum += v * weight;
285
+ totalWeight += weight;
286
+ });
287
+ touchState.velocity = totalWeight ? weightedSum / totalWeight : 0;
288
+ }
289
+
290
+ function startInertia() {
291
+ const friction = 0.95;
292
+ const minVelocity = 0.01;
293
+
294
+ function animate() {
295
+ if (Math.abs(touchState.velocity) < minVelocity || touchState.touching) {
296
+ touchState.inertiaId = null;
297
+ touchState.velocity = 0;
298
+ return;
299
+ }
300
+
301
+ performScroll(touchState.velocity * 25);
302
+ touchState.velocity *= friction;
303
+ touchState.inertiaId = requestAnimationFrame(animate);
304
+ }
305
+ animate();
306
+ }
307
+
308
+ function cancelInertia() {
309
+ if (touchState.inertiaId) {
310
+ cancelAnimationFrame(touchState.inertiaId);
311
+ touchState.inertiaId = null;
312
+ }
313
+ }
314
+
315
+ function resetTouchState() {
316
+ Object.assign(touchState, {
317
+ touching: false, identifier: null,
318
+ velocity: 0, velocityHistory: [],
319
+ accumulator: 0
320
+ });
321
+ }
322
+ }
323
+
324
+ function setInputEnabled(enabled) {
325
+ input.disabled = !enabled;
326
+ input.style.opacity = enabled ? '1' : '0.5';
327
+ input.style.cursor = enabled ? 'text' : 'not-allowed';
328
+ if (!enabled) {
329
+ input.placeholder = 'Reconnecting...';
330
+ } else {
331
+ input.placeholder = 'Type command...';
332
+ }
333
+ }
334
+
335
+ function updateStatus(state) {
336
+ statusDot.className = '';
337
+ if (state === 'disconnected') {
338
+ statusDot.classList.add('disconnected');
339
+ } else if (state === 'connecting') {
340
+ statusDot.classList.add('connecting');
341
+ }
342
+ }
343
+
344
+ function connect() {
345
+ updateStatus('connecting');
346
+ // WebSocket will automatically include cookies with the request
347
+ const wsUrl = location.protocol.replace('http', 'ws') + '//' + location.host + '/ws';
348
+ ws = new WebSocket(wsUrl);
349
+
350
+ ws.onopen = () => {
351
+ console.log('WebSocket connected');
352
+ updateStatus('connected');
353
+ reconnectAttempts = 0;
354
+ try { fitAddon.fit(); } catch(e) {}
355
+ ws.send(JSON.stringify({ type: 'sync', lastSeq }));
356
+ ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
357
+ };
358
+
359
+ ws.onclose = () => {
360
+ console.log('WebSocket closed');
361
+ updateStatus('disconnected');
362
+ ws = null;
363
+ if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
364
+ reconnectAttempts++;
365
+ setTimeout(() => {
366
+ connect();
367
+ }, 500);
368
+ } else {
369
+ setInputEnabled(false);
370
+ input.placeholder = 'Connection failed. Refresh page.';
371
+ }
372
+ };
373
+
374
+ ws.onerror = (err) => {
375
+ console.log('WebSocket error');
376
+ };
377
+
378
+ ws.onmessage = (e) => {
379
+ let msg;
380
+ try { msg = JSON.parse(e.data); } catch { return; }
381
+ if (msg.type === 'output') {
382
+ if (msg.seq != null) lastSeq = msg.seq;
383
+ if (typeof msg.data === 'string') term.write(msg.data);
384
+ checkScrollPosition();
385
+ } else if (msg.type === 'history') {
386
+ if (msg.lastSeq != null) lastSeq = msg.lastSeq;
387
+ term.clear();
388
+ if (Array.isArray(msg.data)) msg.data.forEach(d => term.write(d));
389
+ setInputEnabled(true);
390
+ setTimeout(() => { restoreScrollState(); }, 100);
391
+ } else if (msg.type === 'history-delta') {
392
+ if (msg.lastSeq != null) lastSeq = msg.lastSeq;
393
+ if (Array.isArray(msg.data)) msg.data.forEach(d => term.write(d));
394
+ setInputEnabled(true);
395
+ checkScrollPosition();
396
+ } else if (msg.type === 'exit') {
397
+ term.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n');
398
+ setInputEnabled(false);
399
+ input.placeholder = 'Process exited. Refresh page to restart.';
400
+ } else if (msg.type === 'image_uploaded') {
401
+ if (msg.error) {
402
+ console.error('[Image] Upload error:', msg.error);
403
+ } else if (msg.path) {
404
+ if (nativeInputMode) {
405
+ if (ws && ws.readyState === 1) {
406
+ ws.send(JSON.stringify({ type: 'input', data: msg.path }));
407
+ }
408
+ } else {
409
+ const pos = input.selectionStart ?? input.value.length;
410
+ const before = input.value.slice(0, pos);
411
+ const after = input.value.slice(pos);
412
+ const needSpace = before.length > 0 && !before.endsWith(' ');
413
+ input.value = before + (needSpace ? ' ' : '') + msg.path + (after.length > 0 && !after.startsWith(' ') ? ' ' : '') + after;
414
+ input.style.height = 'auto';
415
+ input.style.height = input.scrollHeight + 'px';
416
+ updateTerminalBottom();
417
+ input.focus();
418
+ }
419
+ }
420
+ }
421
+ };
422
+ }
423
+
424
+ // Input handling - must send text and Enter key separately for Claude Code to work
425
+ input.addEventListener('keydown', (e) => {
426
+ if (e.key === 'Enter' && !e.shiftKey) {
427
+ e.preventDefault();
428
+ if (ws && ws.readyState === 1) {
429
+ const cmd = input.value;
430
+ // Clear via execCommand so browser preserves undo history (Ctrl+Z to restore)
431
+ input.focus();
432
+ input.select();
433
+ document.execCommand('delete');
434
+ input.style.height = 'auto';
435
+ updateTerminalBottom();
436
+ if (cmd) {
437
+ // Send text first, then Enter key separately after delay
438
+ ws.send(JSON.stringify({ type: 'input', data: cmd }));
439
+ setTimeout(() => {
440
+ if (ws && ws.readyState === 1) {
441
+ ws.send(JSON.stringify({ type: 'input', data: String.fromCharCode(13) }));
442
+ }
443
+ }, 50);
444
+ } else {
445
+ // Just send Enter if empty
446
+ ws.send(JSON.stringify({ type: 'input', data: String.fromCharCode(13) }));
447
+ }
448
+ }
449
+ }
450
+ });
451
+
452
+ // Auto-resize textarea and adjust terminal container (no terminal refit)
453
+ function resizeInput() {
454
+ input.style.height = 'auto';
455
+ input.style.height = input.scrollHeight + 'px';
456
+ updateTerminalBottom();
457
+ }
458
+ input.addEventListener('input', resizeInput);
459
+
460
+ // Shared image upload helper (used by both input textarea and native mode paste)
461
+ const MAX_CLIENT_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
462
+ function uploadImageFile(file) {
463
+ if (!file) return;
464
+ if (file.size > MAX_CLIENT_IMAGE_SIZE) {
465
+ console.error('[Image] File too large (max 5MB):', file.name);
466
+ return;
467
+ }
468
+ const reader = new FileReader();
469
+ reader.onload = () => {
470
+ const parts = reader.result.split(',');
471
+ const base64 = parts.length > 1 ? parts[1] : '';
472
+ if (!base64) return;
473
+ const ext = file.type.split('/')[1] || 'png';
474
+ const filename = (file.name && file.name !== 'image.png') ? file.name : 'clipboard-' + Date.now() + '.' + ext;
475
+ if (ws && ws.readyState === 1) {
476
+ ws.send(JSON.stringify({ type: 'image_upload', data: base64, filename: filename }));
477
+ }
478
+ };
479
+ reader.onerror = () => {
480
+ console.error('[Image] Failed to read file:', file.name);
481
+ };
482
+ reader.readAsDataURL(file);
483
+ }
484
+
485
+ // Paste image from clipboard
486
+ input.addEventListener('paste', (e) => {
487
+ const items = e.clipboardData && e.clipboardData.items;
488
+ if (!items) return;
489
+ for (let i = 0; i < items.length; i++) {
490
+ if (items[i].type.indexOf('image/') === 0) {
491
+ e.preventDefault();
492
+ uploadImageFile(items[i].getAsFile());
493
+ return;
494
+ }
495
+ }
496
+ });
497
+
498
+ // File upload button (mobile fallback for image paste)
499
+ const fileUploadBtn = document.getElementById('file-upload-btn');
500
+ const fileUploadInput = document.getElementById('file-upload-input');
501
+ if (fileUploadBtn && fileUploadInput) {
502
+ fileUploadBtn.addEventListener('click', () => fileUploadInput.click());
503
+ fileUploadInput.addEventListener('change', () => {
504
+ const file = fileUploadInput.files[0];
505
+ if (file) uploadImageFile(file);
506
+ fileUploadInput.value = '';
507
+ });
508
+ }
509
+
510
+ // Special keys handling
511
+ specialKeysBtn.addEventListener('click', (e) => {
512
+ e.stopPropagation();
513
+ specialKeysPopup.classList.toggle('show');
514
+ });
515
+
516
+ document.addEventListener('click', (e) => {
517
+ if (!specialKeysPopup.contains(e.target) && e.target !== specialKeysBtn) {
518
+ specialKeysPopup.classList.remove('show');
519
+ }
520
+ });
521
+
522
+ document.querySelectorAll('.special-key').forEach(btn => {
523
+ btn.addEventListener('click', () => {
524
+ const key = btn.getAttribute('data-key');
525
+ handleSpecialKey(key);
526
+ specialKeysPopup.classList.remove('show');
527
+ });
528
+ });
529
+
530
+ function handleSpecialKey(key) {
531
+ if (!ws || ws.readyState !== 1) return;
532
+
533
+ const keyMap = {
534
+ 'Escape': '\x1b',
535
+ 'Tab': '\t',
536
+ 'Up': '\x1b[A',
537
+ 'Down': '\x1b[B',
538
+ 'Left': '\x1b[D',
539
+ 'Right': '\x1b[C',
540
+ 'Ctrl+C': '\x03',
541
+ 'Ctrl+D': '\x04',
542
+ 'Ctrl+Z': '\x1a',
543
+ 'Ctrl+L': '\x0c',
544
+ 'Home': '\x1b[H',
545
+ 'End': '\x1b[F',
546
+ 'PageUp': '\x1b[5~',
547
+ 'PageDown': '\x1b[6~',
548
+ 'F1': '\x1bOP',
549
+ 'F2': '\x1bOQ',
550
+ 'F3': '\x1bOR',
551
+ 'F4': '\x1bOS'
552
+ };
553
+
554
+ if (keyMap[key]) {
555
+ ws.send(JSON.stringify({ type: 'input', data: keyMap[key] }));
556
+ }
557
+ }
558
+
559
+ // Scroll handling - use xterm buffer API (works with v6's virtual scrolling)
560
+ function checkScrollPosition() {
561
+ const buf = term.buffer.active;
562
+ const atBottom = buf.viewportY >= buf.baseY;
563
+
564
+ if (atBottom) {
565
+ scrollBtn.classList.remove('visible');
566
+ isUserScrolling = false;
567
+ } else {
568
+ scrollBtn.classList.add('visible');
569
+ }
570
+
571
+ // Save scroll state for reconnection recovery
572
+ const state = {
573
+ viewportY: buf.viewportY,
574
+ baseY: buf.baseY,
575
+ atBottom: atBottom
576
+ };
577
+ sessionStorage.setItem('scrollState', JSON.stringify(state));
578
+ }
579
+
580
+ function restoreScrollState() {
581
+ const raw = sessionStorage.getItem('scrollState');
582
+ if (!raw) {
583
+ // First load - scroll to bottom
584
+ term.scrollToBottom();
585
+ isUserScrolling = false;
586
+ return;
587
+ }
588
+ try {
589
+ const state = JSON.parse(raw);
590
+ if (state.atBottom) {
591
+ term.scrollToBottom();
592
+ isUserScrolling = false;
593
+ } else {
594
+ // Restore approximate position by offset from bottom
595
+ const buf = term.buffer.active;
596
+ const offsetFromBottom = state.baseY - state.viewportY;
597
+ const targetLine = Math.max(0, buf.baseY - offsetFromBottom);
598
+ term.scrollToLine(targetLine);
599
+ isUserScrolling = true;
600
+ }
601
+ checkScrollPosition();
602
+ } catch (e) {
603
+ term.scrollToBottom();
604
+ }
605
+ }
606
+
607
+ scrollBtn.addEventListener('click', () => {
608
+ term.scrollToBottom();
609
+ isUserScrolling = false;
610
+ scrollBtn.classList.remove('visible');
611
+ });
612
+
613
+ // Monitor scroll via xterm API
614
+ term.onScroll(() => checkScrollPosition());
615
+ // Wheel event as supplement (user scrolling may not trigger term.onScroll immediately)
616
+ terminalContainer.addEventListener('wheel', () => {
617
+ setTimeout(checkScrollPosition, 50);
618
+ });
619
+
620
+ // Window resize
621
+ let resizeTimeout;
622
+ window.addEventListener('resize', () => {
623
+ clearTimeout(resizeTimeout);
624
+ resizeTimeout = setTimeout(() => {
625
+ updateTerminalBottom();
626
+ try { fitAddon.fit(); } catch(e) {}
627
+ if (ws && ws.readyState === 1) {
628
+ ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
629
+ }
630
+ }, 100);
631
+ });
632
+
633
+ // Initial layout
634
+ updateTerminalBottom();
635
+
636
+ // Mobile keyboard compensation for native input mode
637
+ // On iOS Safari, position:fixed body keeps full layout viewport height when
638
+ // the keyboard opens, so the keyboard covers the terminal bottom (cursor area).
639
+ // We use transform:translateY to shift the terminal container upward so the
640
+ // cursor area is visible above the keyboard. No PTY resize is sent.
641
+ if (isTouchDevice && window.visualViewport) {
642
+ const vv = window.visualViewport;
643
+ // Track the largest observed viewport height as "full" (no keyboard).
644
+ let fullHeight = vv.height;
645
+ let keyboardCompensating = false;
646
+
647
+ function adjustForKeyboard() {
648
+ // Update full height when viewport grows (keyboard closed, rotation, etc.)
649
+ if (vv.height > fullHeight) fullHeight = vv.height;
650
+
651
+ const keyboardHeight = fullHeight - vv.height;
652
+ const isKeyboardOpen = keyboardHeight > 100;
653
+
654
+ if (isKeyboardOpen && nativeInputMode) {
655
+ keyboardCompensating = true;
656
+ // Shift terminal container up so cursor area (at bottom) is visible
657
+ // above the keyboard. The top portion scrolls off-screen.
658
+ terminalContainer.style.transform = 'translateY(-' + keyboardHeight + 'px)';
659
+ term.scrollToBottom();
660
+ } else if (keyboardCompensating && !isKeyboardOpen) {
661
+ keyboardCompensating = false;
662
+ terminalContainer.style.transform = '';
663
+ }
664
+ }
665
+
666
+ vv.addEventListener('resize', adjustForKeyboard);
667
+ // iOS Safari sometimes fires scroll instead of resize during keyboard animation
668
+ vv.addEventListener('scroll', adjustForKeyboard);
669
+ }
670
+
671
+ // Native input mode: forward xterm.js keyboard data directly to PTY
672
+ term.onData((data) => {
673
+ if (nativeInputMode && ws && ws.readyState === 1) {
674
+ ws.send(JSON.stringify({ type: 'input', data: data }));
675
+ }
676
+ });
677
+
678
+ // Only let xterm.js process keyboard input when in native mode
679
+ term.attachCustomKeyEventHandler(() => nativeInputMode);
680
+
681
+ function activateNativeInput() {
682
+ if (nativeInputMode) return;
683
+ nativeInputMode = true;
684
+ term.focus();
685
+ document.getElementById('input-area').classList.add('native-mode');
686
+ }
687
+
688
+ function activateInputBoxMode() {
689
+ if (!nativeInputMode) return;
690
+ nativeInputMode = false;
691
+ term.blur();
692
+ document.getElementById('input-area').classList.remove('native-mode');
693
+ // Restore terminal layout in case keyboard was compensating
694
+ terminalContainer.style.transform = '';
695
+ updateTerminalBottom();
696
+ }
697
+
698
+ // Auto-deactivate native mode when terminal loses focus
699
+ // Attach handlers to term.textarea via polling (more reliable than fixed timeout)
700
+ (function waitForTextarea() {
701
+ if (!term.textarea) {
702
+ setTimeout(waitForTextarea, 100);
703
+ return;
704
+ }
705
+ term.textarea.addEventListener('blur', () => {
706
+ setTimeout(() => {
707
+ if (nativeInputMode && document.activeElement !== term.textarea) {
708
+ activateInputBoxMode();
709
+ }
710
+ }, 100);
711
+ });
712
+
713
+ // Intercept image paste in native input mode (xterm.js focused)
714
+ term.textarea.addEventListener('paste', (e) => {
715
+ const items = e.clipboardData && e.clipboardData.items;
716
+ if (!items) return;
717
+ for (let i = 0; i < items.length; i++) {
718
+ if (items[i].type.indexOf('image/') === 0) {
719
+ e.preventDefault();
720
+ e.stopPropagation();
721
+ uploadImageFile(items[i].getAsFile());
722
+ return;
723
+ }
724
+ }
725
+ });
726
+ })();
727
+
728
+ // Initial connection
729
+ connect();