aigo 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,759 @@
1
+ /**
2
+ * vigo terminal client
3
+ * Connects to the WebSocket server and renders the terminal
4
+ */
5
+
6
+ (function() {
7
+ 'use strict';
8
+
9
+ // Wait for DOM to be ready
10
+ if (document.readyState === 'loading') {
11
+ document.addEventListener('DOMContentLoaded', init);
12
+ } else {
13
+ init();
14
+ }
15
+
16
+ function init() {
17
+ // Extract token from URL path: /t/:token
18
+ const pathParts = window.location.pathname.split('/');
19
+ const tokenIndex = pathParts.indexOf('t') + 1;
20
+ const token = pathParts[tokenIndex];
21
+
22
+ if (!token) {
23
+ document.body.innerHTML = '<div style="color: #ff4444; padding: 20px;">Error: Invalid URL. Token not found.</div>';
24
+ return;
25
+ }
26
+
27
+ // Check if xterm.js is loaded
28
+ if (typeof Terminal === 'undefined') {
29
+ document.body.innerHTML = '<div style="color: #ff4444; padding: 20px;">Error: Terminal library failed to load. Please refresh.</div>';
30
+ console.error('Terminal (xterm.js) is not defined. Check CDN script loading.');
31
+ return;
32
+ }
33
+
34
+ // DOM elements
35
+ const terminalEl = document.getElementById('terminal');
36
+ const statusEl = document.getElementById('status');
37
+ const statusText = statusEl.querySelector('.text');
38
+ const overlay = document.getElementById('reconnect-overlay');
39
+ const overlayMessage = overlay.querySelector('.message');
40
+ const reconnectBtn = document.getElementById('reconnect-btn');
41
+ const authOverlay = document.getElementById('auth-overlay');
42
+ const passwordInput = document.getElementById('password-input');
43
+ const authBtn = document.getElementById('auth-btn');
44
+ const authError = document.getElementById('auth-error');
45
+ const authTitle = authOverlay.querySelector('.auth-title');
46
+ const exitBtn = document.getElementById('exit-btn');
47
+ const exitOverlay = document.getElementById('exit-overlay');
48
+ const exitCancel = document.getElementById('exit-cancel');
49
+ const exitConfirm = document.getElementById('exit-confirm');
50
+ const mobileControls = document.getElementById('mobile-controls');
51
+ const mobileToggleBtn = document.getElementById('mobile-mode-toggle');
52
+ const mobileEnterBtn = document.getElementById('mobile-enter-btn');
53
+ const mobileStopBtn = document.getElementById('mobile-stop-btn');
54
+ const mobileExitBtn = document.getElementById('mobile-exit-btn');
55
+
56
+ // Terminal setup
57
+ const terminal = new Terminal({
58
+ theme: {
59
+ background: '#0a0a0f',
60
+ foreground: '#e0e0e0',
61
+ cursor: '#00ff88',
62
+ cursorAccent: '#0a0a0f',
63
+ selectionBackground: 'rgba(0, 255, 136, 0.3)',
64
+ black: '#1a1a25',
65
+ red: '#ff5555',
66
+ green: '#00ff88',
67
+ yellow: '#ffaa00',
68
+ blue: '#66aaff',
69
+ magenta: '#ff79c6',
70
+ cyan: '#00ffff',
71
+ white: '#e0e0e0',
72
+ brightBlack: '#555555',
73
+ brightRed: '#ff6e6e',
74
+ brightGreen: '#69ff94',
75
+ brightYellow: '#ffffa5',
76
+ brightBlue: '#d6acff',
77
+ brightMagenta: '#ff92df',
78
+ brightCyan: '#a4ffff',
79
+ brightWhite: '#ffffff'
80
+ },
81
+ fontFamily: '"SF Mono", "Fira Code", "JetBrains Mono", "Cascadia Code", Menlo, Monaco, monospace',
82
+ fontSize: 14,
83
+ lineHeight: 1.2,
84
+ cursorBlink: true,
85
+ cursorStyle: 'block',
86
+ scrollback: 10000,
87
+ allowProposedApi: true
88
+ });
89
+
90
+ // Load addons (check for different possible global names)
91
+ const FitAddonClass = window.FitAddon?.FitAddon || window.FitAddon;
92
+ const WebLinksAddonClass = window.WebLinksAddon?.WebLinksAddon || window.WebLinksAddon;
93
+
94
+ let fitAddon = null;
95
+ if (FitAddonClass) {
96
+ fitAddon = new FitAddonClass();
97
+ terminal.loadAddon(fitAddon);
98
+ } else {
99
+ console.warn('FitAddon not available');
100
+ }
101
+
102
+ if (WebLinksAddonClass) {
103
+ const webLinksAddon = new WebLinksAddonClass();
104
+ terminal.loadAddon(webLinksAddon);
105
+ }
106
+
107
+ // Open terminal in DOM
108
+ terminal.open(terminalEl);
109
+ if (fitAddon) {
110
+ fitAddon.fit();
111
+ }
112
+
113
+ // WebSocket connection
114
+ let ws = null;
115
+ let reconnectAttempts = 0;
116
+ let reconnectTimer = null;
117
+ let authenticated = false;
118
+ let savedPassword = null;
119
+ let isLocked = false;
120
+ const MAX_RECONNECT_ATTEMPTS = 10;
121
+ const RECONNECT_DELAY = 2000;
122
+
123
+ // Timeout config (received from server)
124
+ let lockTimeout = 15; // minutes, 0 = disabled
125
+ let exitTimeout = 30; // minutes, 0 = disabled
126
+
127
+ // Activity tracking
128
+ let lastActivityTime = Date.now();
129
+ let lockCheckTimer = null;
130
+ let activityPingTimer = null;
131
+
132
+ // Browser notifications
133
+ let notificationsEnabled = false;
134
+ let lastNotificationTime = 0;
135
+ const NOTIFICATION_COOLDOWN = 30000; // 30 seconds between notifications
136
+
137
+ // Mobile device detection
138
+ function isMobileDevice() {
139
+ // Check for touch capability
140
+ const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
141
+ // Check for mobile user agent
142
+ const mobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
143
+ // Check screen width
144
+ const smallScreen = window.innerWidth <= 768;
145
+ // Consider coarse pointer (touch screens)
146
+ const coarsePointer = window.matchMedia('(pointer: coarse)').matches;
147
+
148
+ return (hasTouch && (mobileUA || smallScreen)) || coarsePointer;
149
+ }
150
+
151
+ // Show/hide mobile control buttons
152
+ function updateMobileControlsVisibility() {
153
+ if (isMobileDevice() && authenticated && !isLocked) {
154
+ mobileControls.classList.add('visible');
155
+ } else {
156
+ mobileControls.classList.remove('visible');
157
+ }
158
+ }
159
+
160
+ // Alias for backward compatibility
161
+ const updateMobileToggleVisibility = updateMobileControlsVisibility;
162
+
163
+ // Patterns that indicate Claude is waiting for input or finished
164
+ const idlePatterns = [
165
+ />\s*$/, // Shell prompt
166
+ /\$\s*$/, // Bash prompt
167
+ /claude>\s*$/i, // Claude prompt
168
+ /waiting for input/i,
169
+ /press enter to continue/i,
170
+ /\[Y\/n\]/i, // Confirmation prompt
171
+ /completed successfully/i,
172
+ /finished/i,
173
+ /done\./i
174
+ ];
175
+
176
+ // Update activity timestamp
177
+ function updateActivity() {
178
+ lastActivityTime = Date.now();
179
+
180
+ // If locked, don't auto-unlock (need password)
181
+ // But do send activity to server
182
+ if (ws && ws.readyState === WebSocket.OPEN && authenticated) {
183
+ ws.send(JSON.stringify({ type: 'activity' }));
184
+ }
185
+ }
186
+
187
+ // Check for lock timeout
188
+ function startLockChecker() {
189
+ if (lockCheckTimer) clearInterval(lockCheckTimer);
190
+ if (lockTimeout <= 0) return;
191
+
192
+ lockCheckTimer = setInterval(() => {
193
+ if (!authenticated || isLocked) return;
194
+
195
+ const inactiveMinutes = (Date.now() - lastActivityTime) / 1000 / 60;
196
+ if (inactiveMinutes >= lockTimeout) {
197
+ lockSession();
198
+ }
199
+ }, 10000); // Check every 10 seconds
200
+ }
201
+
202
+ // Lock the session (show password prompt)
203
+ function lockSession() {
204
+ if (isLocked) return;
205
+ isLocked = true;
206
+ authTitle.textContent = 'Session Locked';
207
+ showAuth();
208
+ setStatus('disconnected', 'Locked');
209
+ updateMobileToggleVisibility();
210
+ }
211
+
212
+ // Unlock the session
213
+ function unlockSession() {
214
+ isLocked = false;
215
+ authTitle.textContent = 'Enter Password';
216
+ hideAuth();
217
+ setStatus('connected', 'Connected');
218
+ terminal.focus();
219
+ updateActivity();
220
+ updateMobileToggleVisibility();
221
+ }
222
+
223
+ // Track user interactions
224
+ function setupActivityTracking() {
225
+ const events = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'];
226
+ events.forEach(event => {
227
+ document.addEventListener(event, () => {
228
+ if (!isLocked && authenticated) {
229
+ updateActivity();
230
+ }
231
+ // Request notification permission on first interaction
232
+ requestNotificationPermission();
233
+ }, { passive: true, once: event === 'keydown' || event === 'mousedown' });
234
+ });
235
+ }
236
+
237
+ // Request notification permission
238
+ function requestNotificationPermission() {
239
+ if (!('Notification' in window)) return;
240
+ if (Notification.permission === 'granted') {
241
+ notificationsEnabled = true;
242
+ return;
243
+ }
244
+ if (Notification.permission !== 'denied') {
245
+ Notification.requestPermission().then(permission => {
246
+ notificationsEnabled = permission === 'granted';
247
+ });
248
+ }
249
+ }
250
+
251
+ // Show browser notification
252
+ function showNotification(title, body) {
253
+ if (!notificationsEnabled) return;
254
+ if (document.visibilityState === 'visible') return; // Don't notify if tab is active
255
+
256
+ const now = Date.now();
257
+ if (now - lastNotificationTime < NOTIFICATION_COOLDOWN) return;
258
+ lastNotificationTime = now;
259
+
260
+ try {
261
+ const notification = new Notification(title, {
262
+ body: body,
263
+ icon: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">▸</text></svg>',
264
+ tag: 'vigo-notification',
265
+ requireInteraction: false
266
+ });
267
+
268
+ notification.onclick = () => {
269
+ window.focus();
270
+ notification.close();
271
+ };
272
+
273
+ // Auto-close after 5 seconds
274
+ setTimeout(() => notification.close(), 5000);
275
+ } catch (e) {
276
+ console.warn('Failed to show notification:', e);
277
+ }
278
+ }
279
+
280
+ // Check terminal output for idle patterns
281
+ let outputBuffer = '';
282
+ function checkForIdlePatterns(data) {
283
+ // Add to buffer (keep last 500 chars)
284
+ outputBuffer += data;
285
+ if (outputBuffer.length > 500) {
286
+ outputBuffer = outputBuffer.slice(-500);
287
+ }
288
+
289
+ // Check patterns
290
+ for (const pattern of idlePatterns) {
291
+ if (pattern.test(outputBuffer)) {
292
+ showNotification('vigo', 'Claude Code is waiting for input');
293
+ outputBuffer = ''; // Reset to avoid repeated notifications
294
+ return;
295
+ }
296
+ }
297
+ }
298
+
299
+ function showAuth() {
300
+ authOverlay.classList.add('visible');
301
+ authError.textContent = '';
302
+ passwordInput.value = '';
303
+ passwordInput.focus();
304
+ }
305
+
306
+ function hideAuth() {
307
+ authOverlay.classList.remove('visible');
308
+ }
309
+
310
+ function submitPassword() {
311
+ const pwd = passwordInput.value.trim();
312
+ if (pwd.length === 0) {
313
+ authError.textContent = 'Password required';
314
+ return;
315
+ }
316
+ authError.textContent = '';
317
+
318
+ if (isLocked) {
319
+ // Re-auth for unlock
320
+ ws.send(JSON.stringify({
321
+ type: 're-auth',
322
+ password: pwd
323
+ }));
324
+ } else {
325
+ // Initial auth
326
+ savedPassword = pwd;
327
+ const dimensions = fitAddon ? fitAddon.proposeDimensions() : { cols: 80, rows: 24 };
328
+ ws.send(JSON.stringify({
329
+ type: 'auth',
330
+ password: pwd,
331
+ cols: dimensions?.cols || 80,
332
+ rows: dimensions?.rows || 24
333
+ }));
334
+ }
335
+ }
336
+
337
+ // Auth button and enter key
338
+ authBtn.addEventListener('click', submitPassword);
339
+ passwordInput.addEventListener('keypress', (e) => {
340
+ if (e.key === 'Enter') submitPassword();
341
+ });
342
+
343
+ // Exit/cleanup functionality
344
+ function showExitConfirm() {
345
+ exitOverlay.classList.add('visible');
346
+ }
347
+
348
+ function hideExitConfirm() {
349
+ exitOverlay.classList.remove('visible');
350
+ terminal.focus();
351
+ }
352
+
353
+ function requestCleanup() {
354
+ if (ws && ws.readyState === WebSocket.OPEN) {
355
+ exitOverlay.classList.remove('visible');
356
+ showOverlay(true, 'Cleaning up session...');
357
+ ws.send(JSON.stringify({ type: 'cleanup' }));
358
+ }
359
+ }
360
+
361
+ function showCleanupComplete() {
362
+ hideAuth();
363
+ showOverlay(false);
364
+
365
+ const completeDiv = document.createElement('div');
366
+ completeDiv.id = 'cleanup-overlay';
367
+ completeDiv.innerHTML = `
368
+ <div class="cleanup-box">
369
+ <div class="cleanup-icon">✓</div>
370
+ <div class="cleanup-title">Session Ended</div>
371
+ <div class="cleanup-message">The session has been cleaned up.<br>tmux session killed, server stopped.</div>
372
+ <button onclick="window.close()">Close Window</button>
373
+ </div>
374
+ `;
375
+ completeDiv.style.cssText = `
376
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
377
+ background: #0a0a0f; display: flex; justify-content: center;
378
+ align-items: center; z-index: 300;
379
+ `;
380
+ const box = completeDiv.querySelector('.cleanup-box');
381
+ box.style.cssText = `
382
+ background: #12121a; border: 1px solid #1a1a25; border-radius: 8px;
383
+ padding: 40px; text-align: center;
384
+ `;
385
+ const icon = completeDiv.querySelector('.cleanup-icon');
386
+ icon.style.cssText = 'font-size: 48px; margin-bottom: 16px; color: #00ff88;';
387
+ const title = completeDiv.querySelector('.cleanup-title');
388
+ title.style.cssText = 'color: #00ff88; font-size: 20px; font-weight: 600; margin-bottom: 12px;';
389
+ const msg = completeDiv.querySelector('.cleanup-message');
390
+ msg.style.cssText = 'color: #888; margin-bottom: 24px; line-height: 1.5;';
391
+ const btn = completeDiv.querySelector('button');
392
+ btn.style.cssText = `
393
+ padding: 12px 32px; background: #1a1a25; color: #e0e0e0;
394
+ border: 1px solid #2a2a35; border-radius: 4px; cursor: pointer;
395
+ font-family: inherit; font-size: 14px;
396
+ `;
397
+
398
+ document.body.appendChild(completeDiv);
399
+ }
400
+
401
+ // Exit button handlers
402
+ exitBtn.addEventListener('click', showExitConfirm);
403
+ exitCancel.addEventListener('click', hideExitConfirm);
404
+ exitConfirm.addEventListener('click', requestCleanup);
405
+
406
+ // Helper for mobile button visual feedback
407
+ function mobileButtonFeedback(btn) {
408
+ btn.style.transform = 'scale(0.95)';
409
+ setTimeout(() => {
410
+ btn.style.transform = '';
411
+ }, 100);
412
+ }
413
+
414
+ // Mobile mode toggle button handler (sends Shift+Tab)
415
+ mobileToggleBtn.addEventListener('click', () => {
416
+ if (ws && ws.readyState === WebSocket.OPEN && authenticated && !isLocked) {
417
+ // Send Shift+Tab escape sequence: ESC [ Z (reverse tab / backtab)
418
+ // This is the standard escape sequence for Shift+Tab
419
+ ws.send('\x1b[Z');
420
+ updateActivity();
421
+ mobileButtonFeedback(mobileToggleBtn);
422
+ terminal.focus();
423
+ }
424
+ });
425
+
426
+ // Mobile enter button handler (sends Enter key)
427
+ mobileEnterBtn.addEventListener('click', () => {
428
+ if (ws && ws.readyState === WebSocket.OPEN && authenticated && !isLocked) {
429
+ // Send Enter key (carriage return)
430
+ ws.send('\r');
431
+ updateActivity();
432
+ mobileButtonFeedback(mobileEnterBtn);
433
+ terminal.focus();
434
+ }
435
+ });
436
+
437
+ // Mobile stop button handler (sends Ctrl+C)
438
+ mobileStopBtn.addEventListener('click', () => {
439
+ if (ws && ws.readyState === WebSocket.OPEN && authenticated && !isLocked) {
440
+ // Send Ctrl+C (ASCII code 3, ETX - End of Text)
441
+ ws.send('\x03');
442
+ updateActivity();
443
+ mobileButtonFeedback(mobileStopBtn);
444
+ terminal.focus();
445
+ }
446
+ });
447
+
448
+ // Mobile exit button handler (shows confirmation dialog)
449
+ mobileExitBtn.addEventListener('click', () => {
450
+ if (authenticated && !isLocked) {
451
+ mobileButtonFeedback(mobileExitBtn);
452
+ showExitConfirm();
453
+ }
454
+ });
455
+
456
+ // ESC to close exit confirm
457
+ document.addEventListener('keydown', (e) => {
458
+ if (e.key === 'Escape' && exitOverlay.classList.contains('visible')) {
459
+ hideExitConfirm();
460
+ }
461
+ });
462
+
463
+ function setStatus(status, text) {
464
+ statusEl.className = status;
465
+ statusText.textContent = text;
466
+ }
467
+
468
+ function showOverlay(show, message = '', showButton = false) {
469
+ if (show) {
470
+ overlay.classList.add('visible');
471
+ overlayMessage.textContent = message;
472
+ reconnectBtn.style.display = showButton ? 'block' : 'none';
473
+ } else {
474
+ overlay.classList.remove('visible');
475
+ }
476
+ }
477
+
478
+ function showSessionExpired() {
479
+ hideAuth();
480
+ showOverlay(false);
481
+
482
+ // Create expired overlay
483
+ const expiredDiv = document.createElement('div');
484
+ expiredDiv.id = 'expired-overlay';
485
+ expiredDiv.innerHTML = `
486
+ <div class="expired-box">
487
+ <div class="expired-icon">⏱</div>
488
+ <div class="expired-title">Session Expired</div>
489
+ <div class="expired-message">The session was closed due to inactivity.</div>
490
+ <button onclick="window.close()">Close Window</button>
491
+ </div>
492
+ `;
493
+ expiredDiv.style.cssText = `
494
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
495
+ background: #0a0a0f; display: flex; justify-content: center;
496
+ align-items: center; z-index: 300;
497
+ `;
498
+ const box = expiredDiv.querySelector('.expired-box');
499
+ box.style.cssText = `
500
+ background: #12121a; border: 1px solid #1a1a25; border-radius: 8px;
501
+ padding: 40px; text-align: center;
502
+ `;
503
+ const icon = expiredDiv.querySelector('.expired-icon');
504
+ icon.style.cssText = 'font-size: 48px; margin-bottom: 16px;';
505
+ const title = expiredDiv.querySelector('.expired-title');
506
+ title.style.cssText = 'color: #ff5555; font-size: 20px; font-weight: 600; margin-bottom: 12px;';
507
+ const msg = expiredDiv.querySelector('.expired-message');
508
+ msg.style.cssText = 'color: #888; margin-bottom: 24px;';
509
+ const btn = expiredDiv.querySelector('button');
510
+ btn.style.cssText = `
511
+ padding: 12px 32px; background: #1a1a25; color: #e0e0e0;
512
+ border: 1px solid #2a2a35; border-radius: 4px; cursor: pointer;
513
+ font-family: inherit; font-size: 14px;
514
+ `;
515
+
516
+ document.body.appendChild(expiredDiv);
517
+ }
518
+
519
+ function connect() {
520
+ // Build WebSocket URL
521
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
522
+ const wsUrl = `${protocol}//${window.location.host}/ws/${token}`;
523
+
524
+ setStatus('connecting', 'Connecting...');
525
+
526
+ try {
527
+ ws = new WebSocket(wsUrl);
528
+ } catch (err) {
529
+ console.error('Failed to create WebSocket:', err);
530
+ setStatus('disconnected', 'Connection failed');
531
+ scheduleReconnect();
532
+ return;
533
+ }
534
+
535
+ ws.onopen = () => {
536
+ console.log('WebSocket connected');
537
+ setStatus('connecting', 'Authenticating...');
538
+ showOverlay(false);
539
+ reconnectAttempts = 0;
540
+ };
541
+
542
+ ws.onmessage = (event) => {
543
+ const data = event.data;
544
+
545
+ // Check for JSON control messages
546
+ if (data.startsWith('{')) {
547
+ try {
548
+ const msg = JSON.parse(data);
549
+
550
+ if (msg.type === 'auth_required') {
551
+ if (savedPassword && !isLocked) {
552
+ const dimensions = fitAddon ? fitAddon.proposeDimensions() : { cols: 80, rows: 24 };
553
+ ws.send(JSON.stringify({
554
+ type: 'auth',
555
+ password: savedPassword,
556
+ cols: dimensions?.cols || 80,
557
+ rows: dimensions?.rows || 24
558
+ }));
559
+ } else {
560
+ authTitle.textContent = 'Enter Password';
561
+ showAuth();
562
+ }
563
+ return;
564
+ }
565
+
566
+ if (msg.type === 'auth_success') {
567
+ // Get config if provided
568
+ if (msg.config) {
569
+ lockTimeout = msg.config.lockTimeout || 15;
570
+ exitTimeout = msg.config.exitTimeout || 30;
571
+ }
572
+
573
+ if (isLocked) {
574
+ // Unlock successful
575
+ unlockSession();
576
+ } else {
577
+ // Initial auth successful
578
+ authenticated = true;
579
+ hideAuth();
580
+ setStatus('connected', 'Connected');
581
+ terminal.focus();
582
+ updateActivity();
583
+ startLockChecker();
584
+ setupActivityTracking();
585
+ updateMobileToggleVisibility();
586
+ }
587
+ return;
588
+ }
589
+
590
+ if (msg.type === 'auth_failed') {
591
+ authError.textContent = 'Invalid password';
592
+ if (!isLocked) {
593
+ savedPassword = null;
594
+ }
595
+ passwordInput.value = '';
596
+ passwordInput.focus();
597
+ return;
598
+ }
599
+
600
+ if (msg.type === 'session_expired') {
601
+ showSessionExpired();
602
+ return;
603
+ }
604
+
605
+ if (msg.type === 'cleanup_complete') {
606
+ showCleanupComplete();
607
+ return;
608
+ }
609
+ } catch {
610
+ // Not JSON, treat as terminal data
611
+ }
612
+ }
613
+
614
+ // Write terminal data
615
+ terminal.write(data);
616
+
617
+ // Check for idle patterns (for notifications)
618
+ checkForIdlePatterns(data);
619
+ };
620
+
621
+ ws.onclose = (event) => {
622
+ console.log('WebSocket closed:', event.code, event.reason);
623
+ setStatus('disconnected', 'Disconnected');
624
+ authenticated = false;
625
+ isLocked = false;
626
+ hideAuth();
627
+ updateMobileToggleVisibility();
628
+
629
+ if (lockCheckTimer) clearInterval(lockCheckTimer);
630
+
631
+ if (event.code !== 1000) {
632
+ // Notify user of unexpected disconnect
633
+ showNotification('vigo', 'Connection lost. Attempting to reconnect...');
634
+ scheduleReconnect();
635
+ } else {
636
+ showOverlay(true, 'Session ended.', true);
637
+ }
638
+ };
639
+
640
+ ws.onerror = (error) => {
641
+ console.error('WebSocket error:', error);
642
+ };
643
+ }
644
+
645
+ function scheduleReconnect() {
646
+ if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
647
+ showOverlay(true, 'Connection failed. Please check your network.', true);
648
+ return;
649
+ }
650
+
651
+ reconnectAttempts++;
652
+ const delay = RECONNECT_DELAY * Math.min(reconnectAttempts, 5);
653
+
654
+ showOverlay(true, `Reconnecting in ${delay / 1000}s... (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
655
+
656
+ clearTimeout(reconnectTimer);
657
+ reconnectTimer = setTimeout(() => {
658
+ showOverlay(true, 'Reconnecting...');
659
+ connect();
660
+ }, delay);
661
+ }
662
+
663
+ function sendResize() {
664
+ if (ws && ws.readyState === WebSocket.OPEN && fitAddon && authenticated && !isLocked) {
665
+ const dimensions = fitAddon.proposeDimensions();
666
+ if (dimensions) {
667
+ ws.send(JSON.stringify({
668
+ type: 'resize',
669
+ cols: dimensions.cols,
670
+ rows: dimensions.rows
671
+ }));
672
+ }
673
+ }
674
+ }
675
+
676
+ // Handle terminal input
677
+ terminal.onData((data) => {
678
+ if (ws && ws.readyState === WebSocket.OPEN && authenticated && !isLocked) {
679
+ updateActivity();
680
+ ws.send(data);
681
+ }
682
+ });
683
+
684
+ // Handle window resize
685
+ let resizeTimer = null;
686
+ window.addEventListener('resize', () => {
687
+ clearTimeout(resizeTimer);
688
+ resizeTimer = setTimeout(() => {
689
+ if (fitAddon) {
690
+ fitAddon.fit();
691
+ }
692
+ sendResize();
693
+ updateMobileToggleVisibility();
694
+ }, 100);
695
+ });
696
+
697
+ // Handle terminal resize
698
+ terminal.onResize(({ cols, rows }) => {
699
+ if (ws && ws.readyState === WebSocket.OPEN && authenticated && !isLocked) {
700
+ ws.send(JSON.stringify({ type: 'resize', cols, rows }));
701
+ }
702
+ });
703
+
704
+ // Reconnect button handler
705
+ reconnectBtn.addEventListener('click', () => {
706
+ reconnectAttempts = 0;
707
+ showOverlay(true, 'Connecting...');
708
+ connect();
709
+ });
710
+
711
+ // Handle visibility change - reconnect when tab becomes visible
712
+ document.addEventListener('visibilitychange', () => {
713
+ if (document.visibilityState === 'visible') {
714
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
715
+ reconnectAttempts = 0;
716
+ connect();
717
+ } else if (authenticated && !isLocked) {
718
+ // Check if we should be locked
719
+ const inactiveMinutes = (Date.now() - lastActivityTime) / 1000 / 60;
720
+ if (lockTimeout > 0 && inactiveMinutes >= lockTimeout) {
721
+ lockSession();
722
+ }
723
+ }
724
+ }
725
+ });
726
+
727
+ // Mobile keyboard handling
728
+ if (terminal.textarea) {
729
+ terminal.textarea.addEventListener('focus', () => {
730
+ setTimeout(() => {
731
+ terminalEl.scrollIntoView({ behavior: 'smooth', block: 'end' });
732
+ }, 300);
733
+ });
734
+ }
735
+
736
+ // Initial connection
737
+ connect();
738
+
739
+ // Expose for debugging
740
+ window.vigoTerminal = {
741
+ terminal,
742
+ fitAddon,
743
+ reconnect: () => {
744
+ reconnectAttempts = 0;
745
+ if (ws) ws.close();
746
+ connect();
747
+ },
748
+ lock: lockSession,
749
+ getActivity: () => ({
750
+ lastActivityTime,
751
+ inactiveMinutes: (Date.now() - lastActivityTime) / 1000 / 60,
752
+ lockTimeout,
753
+ exitTimeout,
754
+ isLocked,
755
+ authenticated
756
+ })
757
+ };
758
+ }
759
+ })();