@hmduc16031996/claude-mb-bridge 2.3.6 → 2.4.1

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/public/app.js CHANGED
@@ -1,15 +1,20 @@
1
- // Claude Code Remote - Bridged Client
2
- // This script adapts the premium UI from the reference repo to the simple bridge server.
1
+ // Claude Code Remote - xterm.js Client
3
2
 
3
+ // Touch Scroll Manager for natural momentum scrolling on mobile
4
4
  class TouchScrollManager {
5
5
  constructor(terminal, container) {
6
6
  this.terminal = terminal;
7
7
  this.container = container;
8
- this.TIME_CONSTANT = 325;
9
- this.VELOCITY_THRESHOLD = 10;
8
+
9
+ // Physics constants
10
+ this.TIME_CONSTANT = 325; // ms - controls deceleration rate
11
+ this.VELOCITY_THRESHOLD = 10; // px/s - minimum velocity to trigger momentum
12
+ // LINE_HEIGHT = fontSize (14) * lineHeight (1.2) = 16.8, rounded to 17
10
13
  this.LINE_HEIGHT = 17;
11
- this.TAP_THRESHOLD = 10;
12
- this.TAP_DURATION = 300;
14
+ this.TAP_THRESHOLD = 10; // px - max movement to still count as a tap
15
+ this.TAP_DURATION = 300; // ms - max duration to still count as a tap
16
+
17
+ // State
13
18
  this.tracking = false;
14
19
  this.animating = false;
15
20
  this.velocity = 0;
@@ -17,30 +22,58 @@ class TouchScrollManager {
17
22
  this.lastTime = 0;
18
23
  this.accumulatedScroll = 0;
19
24
 
20
- const isTouchDevice = window.matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window;
21
- if (isTouchDevice) this.bindEvents();
25
+ // Activate on any touch-capable device (not just coarse pointer)
26
+ // Some tablets/devices report 'fine' pointer but still support touch
27
+ const isTouchDevice = window.matchMedia('(pointer: coarse)').matches ||
28
+ 'ontouchstart' in window ||
29
+ navigator.maxTouchPoints > 0;
30
+
31
+ if (isTouchDevice) {
32
+ this.bindEvents();
33
+ }
22
34
  }
23
35
 
24
36
  bindEvents() {
37
+ // Create an invisible overlay to capture touch events
38
+ // Uses CSS class .touch-scroll-overlay for z-index: 9999
39
+ // See styles.css for full documentation on why z-index must stay high
25
40
  this.overlay = document.createElement('div');
26
41
  this.overlay.className = 'touch-scroll-overlay';
42
+ this.overlay.dataset.purpose = 'touch-scroll'; // For debugging in DevTools
27
43
  this.container.style.position = 'relative';
44
+ this.container.classList.add('touch-scroll-active'); // Enable touch-specific CSS
28
45
  this.container.appendChild(this.overlay);
29
46
 
30
47
  this.overlay.addEventListener('touchstart', (e) => this.onTouchStart(e), { passive: false });
31
48
  this.overlay.addEventListener('touchmove', (e) => this.onTouchMove(e), { passive: false });
32
49
  this.overlay.addEventListener('touchend', (e) => this.onTouchEnd(e), { passive: true });
33
- this.overlay.addEventListener('click', () => this.terminal.focus());
50
+ this.overlay.addEventListener('touchcancel', () => {
51
+ this.tracking = false;
52
+ });
53
+
54
+ // Fallback: if the browser synthesizes a click from a tap, use it to focus the terminal.
55
+ // This catches cases where touchend didn't trigger the keyboard (e.g. some Android browsers).
56
+ this.overlay.addEventListener('click', (e) => {
57
+ const elem = document.elementFromPoint(e.clientX, e.clientY);
58
+ if (elem && elem.closest('button, a, input, select, textarea, [role="button"]')) {
59
+ return; // Let interactive elements handle their own clicks
60
+ }
61
+ this.terminal.focus();
62
+ });
34
63
  }
35
64
 
36
65
  onTouchStart(e) {
66
+ // Stop any ongoing momentum animation
37
67
  this.animating = false;
68
+
38
69
  if (e.touches.length !== 1) return;
70
+
39
71
  this.tracking = true;
40
72
  this.velocity = 0;
41
73
  this.accumulatedScroll = 0;
42
- this.totalMovement = 0;
74
+ this.totalMovement = 0; // Track total movement to detect taps
43
75
  this.startY = e.touches[0].clientY;
76
+ this.startX = e.touches[0].clientX;
44
77
  this.lastY = this.startY;
45
78
  this.lastTime = Date.now();
46
79
  this.startTime = this.lastTime;
@@ -48,230 +81,2248 @@ class TouchScrollManager {
48
81
 
49
82
  onTouchMove(e) {
50
83
  if (!this.tracking || e.touches.length !== 1) return;
84
+
51
85
  const currentY = e.touches[0].clientY;
52
86
  const currentTime = Date.now();
53
87
  const deltaY = this.lastY - currentY;
54
88
  const deltaTime = currentTime - this.lastTime;
89
+
90
+ // Track total movement to distinguish taps from swipes
55
91
  this.totalMovement += Math.abs(deltaY);
56
- if (this.totalMovement >= this.TAP_THRESHOLD) e.preventDefault();
92
+
93
+ // Only prevent default (page bounce) once we've confirmed this is a scroll gesture.
94
+ // Calling preventDefault() too early breaks the user activation chain on iOS/Android,
95
+ // which prevents the virtual keyboard from appearing on terminal.focus().
96
+ if (this.totalMovement >= this.TAP_THRESHOLD) {
97
+ e.preventDefault();
98
+ }
99
+
57
100
  if (deltaTime > 0) {
58
- const instantVelocity = (deltaY / deltaTime) * 1000;
101
+ // Exponential smoothing for velocity
102
+ const instantVelocity = (deltaY / deltaTime) * 1000; // px/s
59
103
  this.velocity = 0.8 * instantVelocity + 0.2 * this.velocity;
60
104
  }
105
+
106
+ // Accumulate scroll and apply when we have enough for a line
61
107
  this.accumulatedScroll += deltaY;
62
108
  const linesToScroll = Math.trunc(this.accumulatedScroll / this.LINE_HEIGHT);
109
+
63
110
  if (linesToScroll !== 0) {
64
111
  this.terminal.scrollLines(linesToScroll);
65
112
  this.accumulatedScroll -= linesToScroll * this.LINE_HEIGHT;
66
113
  }
114
+
67
115
  this.lastY = currentY;
68
116
  this.lastTime = currentTime;
69
117
  }
70
118
 
71
- onTouchEnd() {
119
+ onTouchEnd(e) {
72
120
  if (!this.tracking) return;
73
121
  this.tracking = false;
122
+
74
123
  const duration = Date.now() - this.startTime;
124
+
125
+ // If minimal movement and short duration, treat as tap and focus terminal
75
126
  if (this.totalMovement < this.TAP_THRESHOLD && duration < this.TAP_DURATION) {
127
+ // Hide overlay briefly to find what's under the tap
128
+ this.overlay.style.pointerEvents = 'none';
129
+ const elem = document.elementFromPoint(this.startX, this.startY);
130
+ this.overlay.style.pointerEvents = '';
131
+
132
+ // If tapped element is an interactive control (button, link, input), activate it
133
+ if (elem && elem.closest('button, a, input, select, textarea, [role="button"]')) {
134
+ elem.closest('button, a, input, select, textarea, [role="button"]').click();
135
+ return;
136
+ }
137
+
138
+ // Otherwise focus the terminal via xterm API so the mobile keyboard appears.
139
+ // IMPORTANT: terminal.focus() must be called synchronously within the touch
140
+ // event handler for iOS/Android to show the virtual keyboard.
76
141
  this.terminal.focus();
77
142
  return;
78
143
  }
79
- if (Math.abs(this.velocity) > this.VELOCITY_THRESHOLD) this.startMomentum();
144
+
145
+ // Start momentum animation if velocity exceeds threshold
146
+ if (Math.abs(this.velocity) > this.VELOCITY_THRESHOLD) {
147
+ this.startMomentum();
148
+ }
80
149
  }
81
150
 
82
151
  startMomentum() {
83
152
  this.animating = true;
84
153
  const startTime = Date.now();
85
154
  const startVelocity = this.velocity;
155
+ let accumulatedDistance = 0;
86
156
  let scrolledLines = 0;
157
+
87
158
  const animate = () => {
88
159
  if (!this.animating) return;
160
+
89
161
  const elapsed = Date.now() - startTime;
162
+ // Exponential decay: v(t) = v0 * e^(-t/τ)
90
163
  const currentVelocity = startVelocity * Math.exp(-elapsed / this.TIME_CONSTANT);
164
+
165
+ // Stop when velocity is too low
91
166
  if (Math.abs(currentVelocity) < this.VELOCITY_THRESHOLD) {
92
167
  this.animating = false;
93
168
  return;
94
169
  }
170
+
171
+ // Calculate total distance scrolled using integral of velocity
172
+ // ∫v0*e^(-t/τ)dt = -v0*τ*e^(-t/τ) + v0*τ
95
173
  const totalDistance = startVelocity * this.TIME_CONSTANT * (1 - Math.exp(-elapsed / this.TIME_CONSTANT)) / 1000;
174
+
175
+ // Convert to lines and scroll the delta
96
176
  const totalLines = Math.trunc(totalDistance / this.LINE_HEIGHT);
97
177
  const linesToScroll = totalLines - scrolledLines;
178
+
98
179
  if (linesToScroll !== 0) {
99
180
  this.terminal.scrollLines(linesToScroll);
100
181
  scrolledLines = totalLines;
101
182
  }
183
+
102
184
  requestAnimationFrame(animate);
103
185
  };
186
+
104
187
  requestAnimationFrame(animate);
105
188
  }
106
189
  }
107
190
 
108
- class ClaudeBridge {
191
+ // Notification Manager for input-required alerts
192
+ class NotificationManager {
193
+ constructor(app) {
194
+ this.app = app;
195
+ this.enabled = false;
196
+ this.registration = null;
197
+ this.lastNotificationTime = new Map();
198
+ this.debounceTimers = new Map();
199
+ this.DEBOUNCE_MS = 500;
200
+ this.COOLDOWN_MS = 5000;
201
+
202
+ this.loadSettings();
203
+ this.registerServiceWorker();
204
+ this.listenForServiceWorkerMessages();
205
+ }
206
+
207
+ async registerServiceWorker() {
208
+ if ('serviceWorker' in navigator) {
209
+ try {
210
+ this.registration = await navigator.serviceWorker.register('/sw.js');
211
+ console.log('Service worker registered');
212
+ } catch (error) {
213
+ console.warn('Service worker registration failed:', error);
214
+ }
215
+ }
216
+ }
217
+
218
+ listenForServiceWorkerMessages() {
219
+ navigator.serviceWorker?.addEventListener('message', (event) => {
220
+ if (event.data.type === 'switch-session') {
221
+ this.app.attachSession(event.data.sessionId);
222
+ }
223
+ });
224
+ }
225
+
226
+ loadSettings() {
227
+ try {
228
+ const settings = JSON.parse(localStorage.getItem('ccr-settings') || '{}');
229
+ this.enabled = settings.notificationsEnabled || false;
230
+ } catch {
231
+ this.enabled = false;
232
+ }
233
+ }
234
+
235
+ saveSettings() {
236
+ localStorage.setItem('ccr-settings', JSON.stringify({
237
+ notificationsEnabled: this.enabled
238
+ }));
239
+ }
240
+
241
+ // Apply preferences received from server (persists across different URLs)
242
+ applyFromServer(preferences) {
243
+ if (preferences && typeof preferences.notificationsEnabled === 'boolean') {
244
+ this.enabled = preferences.notificationsEnabled;
245
+ this.saveSettings(); // Also save locally as cache
246
+
247
+ // Auto-prompt for permission if server says enabled but browser hasn't granted yet
248
+ // This handles the case of new tunnel URLs where permission needs re-granting
249
+ if (this.enabled && Notification.permission === 'default') {
250
+ this.requestPermission().then(granted => {
251
+ if (!granted) {
252
+ // User denied on this origin - disable to avoid repeated prompts
253
+ this.enabled = false;
254
+ this.saveSettings();
255
+ }
256
+ });
257
+ }
258
+ }
259
+ }
260
+
261
+ // Sync preferences to server
262
+ syncToServer() {
263
+ if (this.app?.ws?.readyState === WebSocket.OPEN) {
264
+ this.app.sendControl({
265
+ type: 'preferences:set',
266
+ preferences: { notificationsEnabled: this.enabled }
267
+ });
268
+ }
269
+ }
270
+
271
+ async requestPermission() {
272
+ if (!('Notification' in window)) return false;
273
+ if (Notification.permission === 'granted') return true;
274
+ if (Notification.permission === 'denied') return false;
275
+
276
+ const result = await Notification.requestPermission();
277
+ return result === 'granted';
278
+ }
279
+
280
+ async enable() {
281
+ const granted = await this.requestPermission();
282
+ if (granted) {
283
+ this.enabled = true;
284
+ this.saveSettings();
285
+ this.syncToServer();
286
+ }
287
+ return granted;
288
+ }
289
+
290
+ disable() {
291
+ this.enabled = false;
292
+ this.saveSettings();
293
+ this.syncToServer();
294
+ }
295
+
296
+ isActiveSession(sessionId) {
297
+ return this.app.currentSessionId === sessionId;
298
+ }
299
+
300
+ shouldNotify(sessionId) {
301
+ if (!this.enabled) return false;
302
+ if (Notification.permission !== 'granted') return false;
303
+
304
+ const now = Date.now();
305
+ const lastTime = this.lastNotificationTime.get(sessionId) || 0;
306
+
307
+ // Cooldown check
308
+ if (now - lastTime < this.COOLDOWN_MS) return false;
309
+
310
+ // Active session + focused check
311
+ if (this.isActiveSession(sessionId) && document.hasFocus()) return false;
312
+
313
+ return true;
314
+ }
315
+
316
+ notify(sessionId, sessionName, preview) {
317
+ // Cancel existing debounce timer
318
+ clearTimeout(this.debounceTimers.get(sessionId));
319
+
320
+ // Set new debounce timer
321
+ this.debounceTimers.set(sessionId, setTimeout(() => {
322
+ if (this.shouldNotify(sessionId)) {
323
+ this.showNotification(sessionId, sessionName, preview);
324
+ this.lastNotificationTime.set(sessionId, Date.now());
325
+ }
326
+ }, this.DEBOUNCE_MS));
327
+ }
328
+
329
+ showNotification(sessionId, sessionName, preview) {
330
+ const title = `Input needed: ${sessionName}`;
331
+ const body = preview.length > 100 ? preview.slice(0, 100) + '...' : preview;
332
+
333
+ if (this.registration?.active) {
334
+ this.registration.active.postMessage({
335
+ type: 'show-notification',
336
+ title,
337
+ body,
338
+ sessionId,
339
+ tag: `input-${sessionId}`
340
+ });
341
+ }
342
+ }
343
+ }
344
+
345
+ // Activity status spinner frames (same as claude-glasses)
346
+ const SPINNER_FRAMES = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
347
+
348
+ class ClaudeRemote {
109
349
  constructor() {
110
350
  this.ws = null;
111
351
  this.terminal = null;
112
352
  this.fitAddon = null;
113
- this.ctrlActive = false;
114
- this.shiftActive = false;
353
+ this.serializeAddon = null;
354
+ this.currentSessionId = null;
355
+ this.sessions = [];
356
+ this.externalSessions = [];
357
+ this.sessionCache = new Map(); // Cache terminal content per session for instant switching
358
+ this.reconnectInterval = null;
359
+ this.spinnerFrame = 0;
360
+
361
+ // Schedules state
362
+ this.schedules = [];
363
+ this.schedulesBadgeCount = 0;
364
+ this.schedulesLastViewed = 0;
365
+ this.expandedScheduleRuns = new Map(); // scheduleId -> RunLog[]
366
+
367
+ // Check URL for token first, then localStorage
368
+ const urlParams = new URLSearchParams(window.location.search);
369
+ const urlToken = urlParams.get('token');
370
+ this.token = urlToken || localStorage.getItem('authToken') || '';
371
+
372
+ // Clean token from URL for security
373
+ if (urlToken) {
374
+ window.history.replaceState({}, '', window.location.pathname);
375
+ }
115
376
 
116
377
  this.initElements();
117
- this.initTerminal();
118
378
  this.bindEvents();
379
+ this.initTerminal();
119
380
 
120
- // Connect immediately
121
- this.connect();
381
+ // Initialize notification manager
382
+ this.notificationManager = new NotificationManager(this);
383
+ this.updateNotifyToggleState();
384
+
385
+ // Start spinner animation for busy indicators (100ms = smooth animation)
386
+ setInterval(() => {
387
+ this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
388
+ this.updateSpinnerFrames();
389
+ }, 100);
390
+
391
+ // Auto-connect if we have a token
392
+ if (urlToken) {
393
+ this.elements.tokenInput.value = this.token;
394
+ setTimeout(() => this.connect(), 100);
395
+ } else if (this.token) {
396
+ this.elements.tokenInput.value = this.token;
397
+ }
122
398
  }
123
399
 
124
400
  initElements() {
125
401
  this.elements = {
402
+ // Screens
403
+ authScreen: document.getElementById('auth-screen'),
126
404
  mainScreen: document.getElementById('main-screen'),
127
- terminalContainer: document.getElementById('terminal-container'),
128
- mobileKeys: document.getElementById('mobile-keys'),
405
+ previewScreen: document.getElementById('preview-screen'),
406
+
407
+ // Auth
408
+ tokenInput: document.getElementById('token-input'),
409
+ connectBtn: document.getElementById('connect-btn'),
410
+ authError: document.getElementById('auth-error'),
411
+
412
+ // Main
129
413
  header: document.getElementById('header'),
414
+ sessionSelect: document.getElementById('session-select'),
415
+ sessionTabs: document.getElementById('session-tabs'),
416
+ newSessionBtn: document.getElementById('new-session-btn'),
417
+ closeSessionBtn: document.getElementById('close-session-btn'),
418
+ previewBtn: document.getElementById('preview-btn'),
419
+ attachBtn: document.getElementById('attach-btn'),
420
+ imageInput: document.getElementById('image-input'),
130
421
  toggleHeaderBtn: document.getElementById('toggle-header-btn'),
131
422
  toggleHeaderBtnDesktop: document.getElementById('toggle-header-btn-desktop'),
132
423
  expandHeaderBtn: document.getElementById('expand-header-btn'),
424
+ terminalContainer: document.getElementById('terminal-container'),
425
+
426
+ // Preview
427
+ backBtn: document.getElementById('back-btn'),
428
+ portSelect: document.getElementById('port-select'),
429
+ portInput: document.getElementById('port-input'),
430
+ goPortBtn: document.getElementById('go-port-btn'),
431
+ refreshPreviewBtn: document.getElementById('refresh-preview-btn'),
432
+ previewFrame: document.getElementById('preview-frame'),
433
+ addressInput: document.getElementById('address-input'),
434
+ addressGoBtn: document.getElementById('address-go-btn'),
435
+
436
+ // Modal
437
+ newSessionModal: document.getElementById('new-session-modal'),
438
+ cwdInput: document.getElementById('cwd-input'),
439
+ cwdSuggestions: document.getElementById('cwd-suggestions'),
440
+ cancelSessionBtn: document.getElementById('cancel-session-btn'),
441
+ createSessionBtn: document.getElementById('create-session-btn'),
442
+
443
+ // Reconnect
444
+ reconnectIndicator: document.getElementById('reconnect-indicator'),
445
+
446
+ // Mobile keys
447
+ mobileKeys: document.getElementById('mobile-keys'),
448
+
449
+ // Scroll to bottom
133
450
  scrollBottomBtn: document.getElementById('scroll-bottom-btn'),
451
+
452
+ // Settings
453
+ settingsBtn: document.getElementById('settings-btn'),
454
+ settingsModal: document.getElementById('settings-modal'),
455
+ closeSettingsBtn: document.getElementById('close-settings-btn'),
456
+ notifyToggle: document.getElementById('notify-toggle'),
457
+
458
+ // Schedules
459
+ schedulesScreen: document.getElementById('schedules-screen'),
460
+ schedulesBackBtn: document.getElementById('schedules-back-btn'),
461
+ schedulesHeaderBadge: document.getElementById('schedules-header-badge'),
462
+ schedulesList: document.getElementById('schedules-list'),
463
+ schedulesEmpty: document.getElementById('schedules-empty'),
464
+ newScheduleBtn: document.getElementById('new-schedule-btn'),
465
+ // New schedule modal
466
+ newScheduleModal: document.getElementById('new-schedule-modal'),
467
+ scheduleNameInput: document.getElementById('schedule-name-input'),
468
+ schedulePromptInput: document.getElementById('schedule-prompt-input'),
469
+ scheduleCwdInput: document.getElementById('schedule-cwd-input'),
470
+ scheduleCwdSuggestions: document.getElementById('schedule-cwd-suggestions'),
471
+ scheduleTextInput: document.getElementById('schedule-text-input'),
472
+ cancelScheduleBtn: document.getElementById('cancel-schedule-btn'),
473
+ createScheduleBtn: document.getElementById('create-schedule-btn'),
474
+ // Run log modal
475
+ runLogModal: document.getElementById('run-log-modal'),
476
+ runLogTitle: document.getElementById('run-log-title'),
477
+ runLogPre: document.getElementById('run-log-pre'),
478
+ closeRunLogBtn: document.getElementById('close-run-log-btn'),
134
479
  };
480
+
481
+ // Mobile keys state
482
+ this.ctrlActive = false;
483
+ this.shiftActive = false;
484
+ this.lastViewportHeight = window.visualViewport?.height || window.innerHeight;
485
+
486
+ // Autocomplete state
487
+ this.selectedSuggestionIndex = -1;
488
+ this.suggestions = [];
489
+ this.debounceTimer = null;
490
+
491
+ // Schedule autocomplete state (separate from session cwd)
492
+ this.scheduleSelectedIndex = -1;
493
+ this.scheduleSuggestions = [];
494
+ this.scheduleDebounceTimer = null;
135
495
  }
136
496
 
137
497
  initTerminal() {
498
+ // Create terminal with mobile-friendly settings
138
499
  this.terminal = new Terminal({
139
500
  cursorBlink: true,
501
+ cursorStyle: 'bar',
502
+ cursorWidth: 2,
140
503
  fontSize: 14,
141
- fontFamily: '"JetBrains Mono", monospace',
504
+ fontFamily: '"JetBrains Mono", "SF Mono", Menlo, Monaco, "Courier New", monospace, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"',
505
+ fontWeight: '400',
506
+ fontWeightBold: '600',
507
+ letterSpacing: 0,
508
+ lineHeight: 1.2,
142
509
  theme: {
143
- background: '#262624',
510
+ background: '#0d1117',
144
511
  foreground: '#f0f6fc',
145
512
  cursor: '#f0a500',
513
+ cursorAccent: '#0d1117',
514
+ selectionBackground: 'rgba(240, 165, 0, 0.25)',
515
+ selectionForeground: '#f0f6fc',
516
+ black: '#484f58',
517
+ red: '#ff7b72',
518
+ green: '#3fb950',
519
+ yellow: '#d29922',
520
+ blue: '#58a6ff',
521
+ magenta: '#bc8cff',
522
+ cyan: '#39c5cf',
523
+ white: '#b1bac4',
524
+ brightBlack: '#6e7681',
525
+ brightRed: '#ffa198',
526
+ brightGreen: '#56d364',
527
+ brightYellow: '#e3b341',
528
+ brightBlue: '#79c0ff',
529
+ brightMagenta: '#d2a8ff',
530
+ brightCyan: '#56d4dd',
531
+ brightWhite: '#f0f6fc',
146
532
  },
533
+ scrollback: 5000,
147
534
  allowTransparency: true,
148
535
  convertEol: true,
149
536
  });
150
537
 
538
+ // Add fit addon for responsive sizing
151
539
  this.fitAddon = new FitAddon.FitAddon();
152
540
  this.terminal.loadAddon(this.fitAddon);
541
+
542
+ // Add web links addon for clickable URLs
543
+ const webLinksAddon = new WebLinksAddon.WebLinksAddon();
544
+ this.terminal.loadAddon(webLinksAddon);
545
+
546
+ // Add serialize addon for instant tab switching
547
+ this.serializeAddon = new SerializeAddon.SerializeAddon();
548
+ this.terminal.loadAddon(this.serializeAddon);
549
+
550
+ // Open terminal in container
153
551
  this.terminal.open(this.elements.terminalContainer);
154
-
155
- this.terminal.onData(data => {
156
- if (this.ws?.readyState === WebSocket.OPEN) {
157
- let finalData = data;
158
- if (this.shiftActive && data.length === 1) {
159
- const char = data.charCodeAt(0);
160
- if (char >= 97 && char <= 122) finalData = String.fromCharCode(char - 32);
161
- this.setShiftActive(false);
552
+
553
+ // Handle macOS keyboard shortcuts (Cmd+Backspace, Cmd+Left, etc.)
554
+ this.terminal.attachCustomKeyEventHandler((e) => {
555
+ // Only handle keydown events to prevent double-firing
556
+ if (e.type !== 'keydown') {
557
+ return true;
558
+ }
559
+
560
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.currentSessionId) {
561
+ return true;
562
+ }
563
+
564
+ const isMac = navigator.platform.includes('Mac');
565
+ const cmdKey = isMac ? e.metaKey : e.ctrlKey;
566
+ const optKey = e.altKey;
567
+
568
+ // Handle Escape key explicitly (xterm.js doesn't always pass it through)
569
+ if (e.key === 'Escape') {
570
+ e.preventDefault();
571
+ this.ws.send('\x1b');
572
+ return false;
573
+ }
574
+
575
+ // Handle Shift+Enter to insert newline instead of sending
576
+ if (e.key === 'Enter' && e.shiftKey && !cmdKey && !optKey) {
577
+ e.preventDefault();
578
+ this.ws.send('\n');
579
+ return false;
580
+ }
581
+
582
+ if (cmdKey && !e.shiftKey) {
583
+ switch (e.key) {
584
+ case 'Backspace':
585
+ // Cmd+Backspace: Clear line to left (Ctrl+U)
586
+ e.preventDefault();
587
+ this.ws.send('\x15');
588
+ return false;
589
+ case 'ArrowLeft':
590
+ // Cmd+Left: Beginning of line (Ctrl+A)
591
+ e.preventDefault();
592
+ this.ws.send('\x01');
593
+ return false;
594
+ case 'ArrowRight':
595
+ // Cmd+Right: End of line (Ctrl+E)
596
+ e.preventDefault();
597
+ this.ws.send('\x05');
598
+ return false;
599
+ case 'k':
600
+ // Cmd+K: Clear terminal
601
+ e.preventDefault();
602
+ this.terminal.clear();
603
+ return false;
604
+ }
605
+ }
606
+
607
+ if (optKey && !cmdKey && !e.shiftKey) {
608
+ switch (e.key) {
609
+ case 'Backspace':
610
+ // Option+Backspace: Delete word (Ctrl+W)
611
+ e.preventDefault();
612
+ this.ws.send('\x17');
613
+ return false;
614
+ case 'ArrowLeft':
615
+ // Option+Left: Move word left (ESC+b)
616
+ e.preventDefault();
617
+ this.ws.send('\x1bb');
618
+ return false;
619
+ case 'ArrowRight':
620
+ // Option+Right: Move word right (ESC+f)
621
+ e.preventDefault();
622
+ this.ws.send('\x1bf');
623
+ return false;
162
624
  }
163
- if (this.ctrlActive && data.length === 1) {
164
- const char = data.charCodeAt(0);
165
- if (char >= 65 && char <= 90) finalData = String.fromCharCode(char - 64);
166
- else if (char >= 97 && char <= 122) finalData = String.fromCharCode(char - 96);
625
+ }
626
+
627
+ return true;
628
+ });
629
+
630
+ // Handle terminal input -> send to server
631
+ this.terminal.onData((data) => {
632
+ if (this.ws && this.ws.readyState === WebSocket.OPEN && this.currentSessionId) {
633
+ // Apply modifiers if active
634
+ if (data.length === 1) {
635
+ const char = data.charCodeAt(0);
636
+
637
+ // Apply Shift modifier (convert lowercase to uppercase)
638
+ if (this.shiftActive && char >= 97 && char <= 122) {
639
+ data = String.fromCharCode(char - 32);
640
+ this.setShiftActive(false);
641
+ }
642
+
643
+ // Apply Ctrl modifier (Ctrl+A = 0x01, Ctrl+Z = 0x1A)
644
+ if (this.ctrlActive) {
645
+ if (char >= 65 && char <= 90) { // A-Z
646
+ data = String.fromCharCode(char - 64);
647
+ } else if (char >= 97 && char <= 122) { // a-z
648
+ data = String.fromCharCode(char - 96);
649
+ }
167
650
  this.setCtrlActive(false);
651
+ }
652
+
653
+ // Auto-dismiss keyboard on Enter (mobile only) - delay to avoid race conditions
654
+ if ((char === 13 || char === 10) && this.elements.mobileKeys.classList.contains('visible')) {
655
+ setTimeout(() => this.terminal.blur(), 50);
656
+ }
168
657
  }
169
- this.ws.send(finalData);
658
+ this.ws.send(data); // Send as text
170
659
  }
171
660
  });
172
661
 
662
+ // Handle resize -> notify server
173
663
  this.terminal.onResize(({ cols, rows }) => {
174
- if (this.ws?.readyState === WebSocket.OPEN) {
175
- this.ws.send(JSON.stringify({ type: 'resize', cols, rows }));
664
+ if (this.ws && this.ws.readyState === WebSocket.OPEN && this.currentSessionId) {
665
+ this.sendControl({ type: 'resize', cols, rows });
176
666
  }
177
667
  });
178
668
 
179
- window.addEventListener('resize', () => {
180
- this.fitAddon.fit();
181
- });
669
+ // Fit terminal on window resize
670
+ window.addEventListener('resize', () => this.fitTerminal());
182
671
 
183
- this.terminal.onScroll((e) => {
184
- const buffer = this.terminal.buffer.active;
185
- const isAtBottom = buffer.viewportY >= buffer.baseY;
186
- this.elements.scrollBottomBtn.classList.toggle('hidden', isAtBottom);
187
- });
672
+ // Use ResizeObserver for container changes
673
+ const resizeObserver = new ResizeObserver(() => this.fitTerminal());
674
+ resizeObserver.observe(this.elements.terminalContainer);
675
+
676
+ // Track scroll position for scroll-to-bottom button
677
+ this.terminal.onScroll(() => this.updateScrollButton());
678
+ // Also check when new content is written
679
+ this.terminal.onWriteParsed(() => this.updateScrollButton());
680
+
681
+ // Initialize touch scroll manager for natural mobile scrolling
682
+ this.touchScrollManager = new TouchScrollManager(
683
+ this.terminal,
684
+ this.elements.terminalContainer
685
+ );
686
+ }
188
687
 
189
- new TouchScrollManager(this.terminal, this.elements.terminalContainer);
688
+ fitTerminal() {
689
+ if (this.fitAddon && this.elements.mainScreen.classList.contains('active')) {
690
+ try {
691
+ this.fitAddon.fit();
692
+ } catch (e) {
693
+ // Ignore fit errors during transitions
694
+ }
695
+ }
190
696
  }
191
697
 
192
698
  bindEvents() {
193
- document.querySelectorAll('.mobile-key').forEach(btn => {
194
- btn.addEventListener('click', (e) => {
195
- const key = btn.dataset.key;
196
- switch (key) {
197
- case 'escape': this.terminal.focus(); this.ws.send('\x1b'); break;
198
- case 'ctrl': this.setCtrlActive(!this.ctrlActive); break;
199
- case 'shift': this.setShiftActive(!this.shiftActive); break;
200
- case 'tab': this.ws.send('\t'); break;
201
- case 'up': this.ws.send('\x1b[A'); break;
202
- case 'down': this.ws.send('\x1b[B'); break;
203
- case 'slash': this.ws.send('/'); break;
699
+ // Auth
700
+ this.elements.connectBtn.addEventListener('click', () => this.connect());
701
+ this.elements.tokenInput.addEventListener('keydown', (e) => {
702
+ if (e.key === 'Enter') this.connect();
703
+ });
704
+
705
+ // Main
706
+ this.elements.sessionSelect.addEventListener('change', (e) => {
707
+ const value = e.target.value;
708
+ if (!value) return;
709
+
710
+ // Check if it's the schedules option
711
+ if (value === 'schedules') {
712
+ this.showSchedules();
713
+ // Reset dropdown
714
+ e.target.value = this.currentSessionId || '';
715
+ return;
716
+ }
717
+
718
+ // Check if it's an external session
719
+ if (value.startsWith('external:')) {
720
+ const pid = parseInt(value.replace('external:', ''), 10);
721
+ const external = this.externalSessions.find(s => s.pid === pid);
722
+ if (external) {
723
+ this.adoptExternalSession(external.pid, external.cwd);
724
+ // Reset dropdown
725
+ e.target.value = this.currentSessionId || '';
204
726
  }
205
- });
727
+ } else {
728
+ this.attachSession(value);
729
+ }
730
+ });
731
+ this.elements.newSessionBtn.addEventListener('click', () => this.showNewSessionModal());
732
+ this.elements.closeSessionBtn?.addEventListener('click', () => this.closeCurrentSession());
733
+ this.elements.previewBtn.addEventListener('click', () => this.showPreview());
734
+ this.elements.attachBtn.addEventListener('click', () => this.elements.imageInput.click());
735
+ this.elements.imageInput.addEventListener('change', (e) => {
736
+ if (e.target.files[0]) {
737
+ this.handleImageAttachment(e.target.files[0]);
738
+ e.target.value = ''; // Reset for same file selection
739
+ }
740
+ });
741
+ this.elements.toggleHeaderBtn.addEventListener('click', () => this.toggleHeader(true));
742
+ this.elements.toggleHeaderBtnDesktop?.addEventListener('click', () => this.toggleHeader(true));
743
+ this.elements.expandHeaderBtn.addEventListener('click', () => this.toggleHeader(false));
744
+
745
+ // Preview
746
+ this.elements.backBtn.addEventListener('click', () => this.hidePreview());
747
+ this.elements.portSelect.addEventListener('change', (e) => {
748
+ if (e.target.value) {
749
+ this.elements.portInput.value = e.target.value;
750
+ this.elements.addressInput.value = '/';
751
+ this.loadPreview(e.target.value, '/');
752
+ }
753
+ });
754
+ this.elements.goPortBtn.addEventListener('click', () => this.loadCustomPort());
755
+ this.elements.portInput.addEventListener('keydown', (e) => {
756
+ if (e.key === 'Enter') this.loadCustomPort();
757
+ });
758
+ this.elements.refreshPreviewBtn.addEventListener('click', () => this.refreshPreview());
759
+ this.elements.addressGoBtn.addEventListener('click', () => this.navigateToAddress());
760
+ this.elements.addressInput.addEventListener('keydown', (e) => {
761
+ if (e.key === 'Enter') this.navigateToAddress();
206
762
  });
207
-
208
- // Header Toggle
209
- const handleToggleHeader = () => {
210
- const isCollapsed = this.elements.header.classList.toggle('collapsed');
211
- this.elements.expandHeaderBtn.classList.toggle('hidden', !isCollapsed);
212
- this.elements.toggleHeaderBtn.setAttribute('aria-expanded', !isCollapsed);
213
- this.elements.toggleHeaderBtnDesktop.setAttribute('aria-expanded', !isCollapsed);
214
- setTimeout(() => this.fitAddon.fit(), 200);
215
- };
216
763
 
217
- this.elements.toggleHeaderBtn.addEventListener('click', handleToggleHeader);
218
- this.elements.toggleHeaderBtnDesktop.addEventListener('click', handleToggleHeader);
219
- this.elements.expandHeaderBtn.addEventListener('click', handleToggleHeader);
764
+ // Modal
765
+ this.elements.cancelSessionBtn.addEventListener('click', () => this.hideNewSessionModal());
766
+ this.elements.createSessionBtn.addEventListener('click', () => this.createSession());
220
767
 
221
- // Scroll to Bottom
222
- this.elements.scrollBottomBtn.addEventListener('click', () => {
223
- this.terminal.scrollToBottom();
224
- this.elements.scrollBottomBtn.classList.add('hidden');
768
+ // Autocomplete
769
+ this.elements.cwdInput.addEventListener('input', () => this.onCwdInput());
770
+ this.elements.cwdInput.addEventListener('keydown', (e) => this.onCwdKeydown(e));
771
+ this.elements.cwdInput.addEventListener('blur', () => {
772
+ // Delay to allow click on suggestion
773
+ setTimeout(() => this.hideSuggestions(), 150);
225
774
  });
226
775
 
227
- // Stubs for other buttons
228
- ['new-session-btn', 'attach-btn', 'preview-btn', 'settings-btn'].forEach(id => {
229
- const btn = document.getElementById(id);
230
- if (btn) {
231
- btn.addEventListener('click', () => {
232
- console.log(`Button ${id} clicked (not implemented in bridge)`);
233
- });
234
- }
776
+ // Scroll to bottom
777
+ this.elements.scrollBottomBtn.addEventListener('click', () => this.scrollToBottom());
778
+
779
+ // Mobile keys
780
+ this.initMobileKeys();
781
+
782
+ // Settings
783
+ this.elements.settingsBtn.addEventListener('click', () => this.showSettings());
784
+ this.elements.closeSettingsBtn.addEventListener('click', () => this.hideSettings());
785
+ this.elements.notifyToggle.addEventListener('click', () => this.toggleNotifications());
786
+
787
+ // Paste handling for images
788
+ document.addEventListener('paste', (e) => this.handlePaste(e));
789
+
790
+ // Schedules
791
+ this.elements.schedulesBackBtn.addEventListener('click', () => this.hideSchedules());
792
+ this.elements.newScheduleBtn.addEventListener('click', () => this.showNewScheduleModal());
793
+ this.elements.cancelScheduleBtn.addEventListener('click', () => this.hideNewScheduleModal());
794
+ this.elements.createScheduleBtn.addEventListener('click', () => this.createSchedule());
795
+ this.elements.closeRunLogBtn.addEventListener('click', () => this.hideRunLogModal());
796
+
797
+ // Schedule cwd autocomplete
798
+ this.elements.scheduleCwdInput.addEventListener('input', () => this.onScheduleCwdInput());
799
+ this.elements.scheduleCwdInput.addEventListener('keydown', (e) => this.onScheduleCwdKeydown(e));
800
+ this.elements.scheduleCwdInput.addEventListener('blur', () => {
801
+ setTimeout(() => this.hideScheduleSuggestions(), 150);
235
802
  });
236
803
  }
237
804
 
238
- setCtrlActive(active) {
239
- this.ctrlActive = active;
240
- document.querySelector('[data-key="ctrl"]').classList.toggle('active', active);
805
+ showScreen(screenId) {
806
+ document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
807
+ document.getElementById(screenId).classList.add('active');
808
+
809
+ // Fit terminal when showing main screen
810
+ if (screenId === 'main-screen') {
811
+ setTimeout(() => this.fitTerminal(), 50);
812
+ }
241
813
  }
242
814
 
243
- setShiftActive(active) {
244
- this.shiftActive = active;
245
- document.querySelector('[data-key="shift"]').classList.toggle('active', active);
815
+ toggleHeader(collapse) {
816
+ const isCollapsed = collapse !== undefined
817
+ ? (collapse ? this.elements.header.classList.add('collapsed') || true : this.elements.header.classList.remove('collapsed') || false)
818
+ : this.elements.header.classList.toggle('collapsed');
819
+
820
+ const collapsed = this.elements.header.classList.contains('collapsed');
821
+ this.elements.toggleHeaderBtn.setAttribute('aria-expanded', !collapsed);
822
+ this.elements.toggleHeaderBtnDesktop?.setAttribute('aria-expanded', !collapsed);
823
+ this.elements.expandHeaderBtn.classList.toggle('hidden', !collapsed);
824
+ setTimeout(() => this.fitTerminal(), 300);
825
+ }
826
+
827
+ showSettings() {
828
+ this.elements.settingsModal.classList.remove('hidden');
829
+ this.updateNotifyToggleState();
830
+ }
831
+
832
+ hideSettings() {
833
+ this.elements.settingsModal.classList.add('hidden');
834
+ }
835
+
836
+ updateNotifyToggleState() {
837
+ const enabled = this.notificationManager?.enabled || false;
838
+ this.elements.notifyToggle.setAttribute('aria-checked', enabled);
839
+ }
840
+
841
+ async toggleNotifications() {
842
+ if (this.notificationManager.enabled) {
843
+ this.notificationManager.disable();
844
+ } else {
845
+ const success = await this.notificationManager.enable();
846
+ if (!success && Notification.permission === 'denied') {
847
+ alert('Notifications are blocked. Please enable them in your browser settings.');
848
+ }
849
+ }
850
+ this.updateNotifyToggleState();
246
851
  }
247
852
 
248
853
  connect() {
249
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
250
- this.ws = new WebSocket(`${protocol}//${window.location.host}`);
854
+ const token = this.elements.tokenInput.value.trim();
855
+ if (!token) {
856
+ this.elements.authError.textContent = 'Please enter a token';
857
+ return;
858
+ }
859
+
860
+ this.token = token;
861
+ this.elements.authError.textContent = '';
862
+ this.elements.connectBtn.disabled = true;
863
+ this.elements.connectBtn.classList.add('loading');
864
+
865
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
866
+ const wsUrl = `${wsProtocol}//${window.location.host}`;
867
+ this.ws = new WebSocket(wsUrl);
251
868
 
252
869
  this.ws.onopen = () => {
253
- console.log('✅ Connected to terminal');
254
- this.elements.mainScreen.classList.add('active');
255
- this.elements.mobileKeys.classList.remove('hidden');
256
- setTimeout(() => this.fitAddon.fit(), 100);
257
- this.terminal.focus();
870
+ // Send auth as binary (control message)
871
+ this.sendControl({ type: 'auth', token });
258
872
  };
259
873
 
260
- this.ws.onmessage = (e) => this.terminal.write(e.data);
261
-
262
- this.ws.onclose = () => {
263
- console.log('❌ Connection closed');
264
- // Show offline state in terminal
265
- this.terminal.write('\r\n\x1b[31m❌ Connection closed\x1b[0m\r\n');
874
+ this.ws.onmessage = (event) => {
875
+ // Check if binary (control) or text (terminal output)
876
+ if (event.data instanceof Blob) {
877
+ // Binary = control message
878
+ event.data.text().then(text => {
879
+ const message = JSON.parse(text);
880
+ this.handleControlMessage(message);
881
+ });
882
+ } else if (event.data instanceof ArrayBuffer) {
883
+ // Binary = control message
884
+ const text = new TextDecoder().decode(event.data);
885
+ const message = JSON.parse(text);
886
+ this.handleControlMessage(message);
887
+ } else {
888
+ // Text = terminal output -> write to xterm
889
+ this.terminal.write(event.data);
890
+ }
266
891
  };
267
892
 
268
893
  this.ws.onerror = () => {
269
- console.error('⚠️ Connection error');
894
+ this.elements.authError.textContent = 'Connection failed';
895
+ this.elements.connectBtn.disabled = false;
896
+ this.elements.connectBtn.classList.remove('loading');
897
+ };
898
+
899
+ this.ws.onclose = () => {
900
+ this.elements.connectBtn.disabled = false;
901
+ this.elements.connectBtn.classList.remove('loading');
902
+ if (this.elements.mainScreen.classList.contains('active')) {
903
+ this.startAutoReconnect();
904
+ }
270
905
  };
271
906
  }
272
- }
273
907
 
274
- // Initialize on load
275
- window.addEventListener('DOMContentLoaded', () => {
276
- new ClaudeBridge();
277
- });
908
+ startAutoReconnect() {
909
+ if (this.reconnectInterval) return;
910
+ this.elements.reconnectIndicator.classList.remove('hidden');
911
+ this.reconnectInterval = setInterval(() => this.attemptReconnect(), 3000);
912
+ // Try immediately first
913
+ this.attemptReconnect();
914
+ }
915
+
916
+ stopAutoReconnect() {
917
+ if (this.reconnectInterval) {
918
+ clearInterval(this.reconnectInterval);
919
+ this.reconnectInterval = null;
920
+ }
921
+ this.elements.reconnectIndicator.classList.add('hidden');
922
+ }
923
+
924
+ attemptReconnect() {
925
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
926
+ this.stopAutoReconnect();
927
+ return;
928
+ }
929
+ this.connect();
930
+ }
931
+
932
+ sendControl(message) {
933
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
934
+ // Send as binary (ArrayBuffer)
935
+ const data = new TextEncoder().encode(JSON.stringify(message));
936
+ this.ws.send(data);
937
+ }
938
+ }
939
+
940
+ handleControlMessage(message) {
941
+ switch (message.type) {
942
+ case 'auth:success':
943
+ localStorage.setItem('authToken', this.token);
944
+ this.stopAutoReconnect();
945
+ this.showScreen('main-screen');
946
+ // Apply server-side preferences (persists across different tunnel URLs)
947
+ if (message.preferences) {
948
+ this.notificationManager.applyFromServer(message.preferences);
949
+ this.updateNotifyToggleState();
950
+ }
951
+ this.sendControl({ type: 'session:list' });
952
+ this.sendControl({ type: 'session:discover' });
953
+ this.sendControl({ type: 'schedule:list' });
954
+ this.loadPorts();
955
+ this.fitTerminal();
956
+ // Re-attach to previous session if we have one
957
+ if (this.currentSessionId) {
958
+ this.sendControl({ type: 'session:attach', sessionId: this.currentSessionId });
959
+ }
960
+ break;
961
+
962
+ case 'auth:failed':
963
+ this.elements.authError.textContent = message.error || 'Authentication failed';
964
+ this.elements.connectBtn.disabled = false;
965
+ this.elements.connectBtn.classList.remove('loading');
966
+ break;
967
+
968
+ case 'session:list':
969
+ this.updateSessionList(message.sessions);
970
+ // Auto-attach to first session if not already attached
971
+ if (!this.currentSessionId && message.sessions.length > 0) {
972
+ this.attachSession(message.sessions[0].id);
973
+ }
974
+ break;
975
+
976
+ case 'session:discovered':
977
+ this.updateExternalSessions(message.sessions || []);
978
+ break;
979
+
980
+ case 'session:created':
981
+ case 'session:attached': {
982
+ const isNewSession = message.type === 'session:created';
983
+ const hadCache = this.sessionCache.has(message.session.id) && this.sessionCache.get(message.session.id)?.content;
984
+
985
+ this.currentSessionId = message.session.id;
986
+
987
+ // Only show connection message for new sessions or sessions without cache
988
+ if (isNewSession || !hadCache) {
989
+ this.terminal.clear();
990
+ this.terminal.writeln(`\x1b[32mConnected to session: ${message.session.id}\x1b[0m`);
991
+ this.terminal.writeln(`\x1b[90mWorking directory: ${message.session.cwd}\x1b[0m\r\n`);
992
+ }
993
+ if (message.isAdopted) {
994
+ this.terminal.writeln(`\x1b[36m✓ External session adopted successfully\x1b[0m\r\n`);
995
+ }
996
+ this.sendControl({ type: 'session:list' });
997
+ // Refresh external sessions after adoption
998
+ if (message.isAdopted) {
999
+ this.sendControl({ type: 'session:discover' });
1000
+ }
1001
+ // Send initial size
1002
+ const { cols, rows } = this.terminal;
1003
+ this.sendControl({ type: 'resize', cols, rows });
1004
+ // Focus terminal
1005
+ this.terminal.focus();
1006
+ break;
1007
+ }
1008
+
1009
+ case 'session:exit':
1010
+ this.terminal.writeln(`\r\n\x1b[33mSession exited with code ${message.exitCode}\x1b[0m`);
1011
+ // Auto-remove the session after it exits
1012
+ if (message.sessionId) {
1013
+ this.removeSession(message.sessionId);
1014
+ }
1015
+ break;
1016
+
1017
+ case 'session:destroyed':
1018
+ if (message.sessionId) {
1019
+ this.removeSession(message.sessionId);
1020
+ }
1021
+ break;
1022
+
1023
+ case 'image:uploaded':
1024
+ // Insert file path into terminal (simulating paste)
1025
+ if (message.path) {
1026
+ this.terminal.paste(message.path);
1027
+ }
1028
+ break;
1029
+
1030
+ case 'session:input_required':
1031
+ // Trigger notification when session needs input
1032
+ this.notificationManager.notify(
1033
+ message.sessionId,
1034
+ message.sessionName,
1035
+ message.preview
1036
+ );
1037
+ break;
1038
+
1039
+ case 'session:status':
1040
+ // Update activity status for sessions without full re-render
1041
+ this.updateSessionStatus(message.sessions, message.externalSessions);
1042
+ break;
1043
+
1044
+ case 'schedule:list':
1045
+ this.schedules = message.schedules || [];
1046
+ this.renderSchedules();
1047
+ break;
1048
+
1049
+ case 'schedule:runs':
1050
+ if (message.scheduleId) {
1051
+ this.expandedScheduleRuns.set(message.scheduleId, message.runs || []);
1052
+ this.renderSchedules();
1053
+ }
1054
+ break;
1055
+
1056
+ case 'schedule:log':
1057
+ if (message.content) {
1058
+ this.showRunLogModal(message.content);
1059
+ }
1060
+ break;
1061
+
1062
+ case 'schedule:run_complete':
1063
+ this.schedulesBadgeCount++;
1064
+ this.updateScheduleBadge();
1065
+ // Trigger push notification
1066
+ if (this.notificationManager?.enabled && Notification.permission === 'granted') {
1067
+ const status = message.exitCode === 0 ? 'completed' : 'failed';
1068
+ if (this.notificationManager.registration?.active) {
1069
+ this.notificationManager.registration.active.postMessage({
1070
+ type: 'show-notification',
1071
+ title: `Schedule ${status}: ${message.name}`,
1072
+ body: `Exit code: ${message.exitCode}`,
1073
+ sessionId: null,
1074
+ tag: `schedule-${message.scheduleId}`
1075
+ });
1076
+ }
1077
+ }
1078
+ // Refresh schedule list to update lastRun
1079
+ this.sendControl({ type: 'schedule:list' });
1080
+ break;
1081
+
1082
+ case 'schedule:updated':
1083
+ this.resetScheduleCreateBtn();
1084
+ this.hideNewScheduleModal();
1085
+ // Refresh the full list
1086
+ this.sendControl({ type: 'schedule:list' });
1087
+ break;
1088
+
1089
+ case 'schedule:create_error':
1090
+ this.resetScheduleCreateBtn();
1091
+ alert(message.error || 'Failed to create schedule');
1092
+ break;
1093
+
1094
+ case 'error':
1095
+ this.terminal.writeln(`\r\n\x1b[31mError: ${message.error}\x1b[0m`);
1096
+ break;
1097
+ }
1098
+ }
1099
+
1100
+ handleImageAttachment(file) {
1101
+ if (!file || !file.type.startsWith('image/')) return;
1102
+
1103
+ const reader = new FileReader();
1104
+ reader.onload = () => {
1105
+ const base64 = reader.result.split(',')[1]; // Remove data URL prefix
1106
+ this.sendControl({
1107
+ type: 'image:upload',
1108
+ data: base64,
1109
+ filename: file.name,
1110
+ mimeType: file.type
1111
+ });
1112
+ };
1113
+ reader.readAsDataURL(file);
1114
+ }
1115
+
1116
+ handlePaste(e) {
1117
+ // Only handle paste when connected and on main screen
1118
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.elements.mainScreen.classList.contains('active')) {
1119
+ return;
1120
+ }
1121
+
1122
+ // Check if clipboard contains image data
1123
+ const items = e.clipboardData?.items;
1124
+ if (!items) return;
1125
+
1126
+ for (let i = 0; i < items.length; i++) {
1127
+ const item = items[i];
1128
+
1129
+ // Check if it's an image
1130
+ if (item.type.startsWith('image/')) {
1131
+ e.preventDefault();
1132
+
1133
+ const file = item.getAsFile();
1134
+ if (file) {
1135
+ // Generate a filename with timestamp
1136
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1137
+ const ext = item.type.split('/')[1];
1138
+ const filename = `pasted-image-${timestamp}.${ext}`;
1139
+
1140
+ // Create a new file with the generated name
1141
+ const namedFile = new File([file], filename, { type: item.type });
1142
+ this.handleImageAttachment(namedFile);
1143
+ }
1144
+ break;
1145
+ }
1146
+ }
1147
+ }
1148
+
1149
+ updateExternalSessions(externalSessions) {
1150
+ this.externalSessions = externalSessions;
1151
+ // Re-render the session list to include external sessions
1152
+ this.updateSessionList(this.sessions);
1153
+ }
1154
+
1155
+ updateSessionList(sessions) {
1156
+ this.sessions = sessions;
1157
+ const select = this.elements.sessionSelect;
1158
+ const tabs = this.elements.sessionTabs;
1159
+ const currentValue = select.value;
1160
+
1161
+ // Helper to extract folder name from path (handles trailing slashes)
1162
+ const getFolderName = (cwd) => {
1163
+ const parts = cwd.split('/').filter(Boolean);
1164
+ return parts[parts.length - 1] || cwd;
1165
+ };
1166
+
1167
+ // Count folder name occurrences to detect duplicates
1168
+ const folderCounts = new Map();
1169
+ for (const session of sessions) {
1170
+ const dirName = getFolderName(session.cwd);
1171
+ folderCounts.set(dirName, (folderCounts.get(dirName) || 0) + 1);
1172
+ }
1173
+
1174
+ // Helper to get display name - just folder, or "ID folder" if duplicate
1175
+ const getDisplayName = (session) => {
1176
+ const dirName = getFolderName(session.cwd);
1177
+ if (folderCounts.get(dirName) > 1) {
1178
+ return `${session.id.slice(0, 3)} ${dirName}`;
1179
+ }
1180
+ return dirName;
1181
+ };
1182
+
1183
+ // Update dropdown (mobile) - use static indicators (no animation to avoid flashing)
1184
+ select.innerHTML = '<option value="">Select session...</option>';
1185
+ for (const session of sessions) {
1186
+ const option = document.createElement('option');
1187
+ option.value = session.id;
1188
+ const indicator = session.activityStatus === 'busy' ? '⏳' : '○';
1189
+ option.textContent = `${indicator} ${getDisplayName(session)}`;
1190
+ option.dataset.status = session.activityStatus || 'unknown';
1191
+ select.appendChild(option);
1192
+ }
1193
+
1194
+ // Add Schedules option to dropdown
1195
+ const scheduleSep = document.createElement('option');
1196
+ scheduleSep.disabled = true;
1197
+ scheduleSep.textContent = '────────────';
1198
+ select.appendChild(scheduleSep);
1199
+
1200
+ const scheduleOpt = document.createElement('option');
1201
+ scheduleOpt.value = 'schedules';
1202
+ const badgeText = this.schedulesBadgeCount > 0 ? ` (${this.schedulesBadgeCount})` : '';
1203
+ scheduleOpt.textContent = `⏰ Schedules${badgeText}`;
1204
+ select.appendChild(scheduleOpt);
1205
+
1206
+ // Add external sessions to dropdown
1207
+ if (this.externalSessions.length > 0) {
1208
+ const separator = document.createElement('option');
1209
+ separator.disabled = true;
1210
+ separator.textContent = '── External Sessions ──';
1211
+ select.appendChild(separator);
1212
+
1213
+ for (const external of this.externalSessions) {
1214
+ const option = document.createElement('option');
1215
+ option.value = `external:${external.pid}`;
1216
+ const indicator = external.activityStatus === 'busy' ? '⏳' : '○';
1217
+ option.textContent = `${indicator} ${getFolderName(external.cwd)}`;
1218
+ option.dataset.status = external.activityStatus || 'unknown';
1219
+ select.appendChild(option);
1220
+ }
1221
+ }
1222
+
1223
+ // Update tabs (desktop)
1224
+ if (sessions.length === 0 && this.externalSessions.length === 0) {
1225
+ tabs.innerHTML = '<span class="session-tab-empty">No sessions</span>';
1226
+ } else {
1227
+ let tabsHtml = sessions.map(session => {
1228
+ const isActive = session.id === this.currentSessionId;
1229
+ const indicator = session.activityStatus === 'busy'
1230
+ ? SPINNER_FRAMES[this.spinnerFrame]
1231
+ : '●';
1232
+ const statusClass = session.activityStatus || 'unknown';
1233
+ return `<button class="session-tab" role="tab" aria-selected="${isActive}" data-session-id="${session.id}">
1234
+ <span class="activity-indicator" data-status="${statusClass}">${indicator}</span>
1235
+ <span class="session-tab-name">${getDisplayName(session)}</span>
1236
+ <span class="session-tab-close" data-close-session="${session.id}" title="Close session">&times;</span>
1237
+ </button>`;
1238
+ }).join('');
1239
+
1240
+ // Add external sessions button
1241
+ if (this.externalSessions.length > 0) {
1242
+ const dropdownHtml = this.externalSessions.map(external => {
1243
+ const folderName = getFolderName(external.cwd);
1244
+ const indicator = external.activityStatus === 'busy'
1245
+ ? SPINNER_FRAMES[this.spinnerFrame]
1246
+ : '●';
1247
+ const statusClass = external.activityStatus || 'unknown';
1248
+ return `<div class="external-session-item" data-external-pid="${external.pid}" data-external-cwd="${external.cwd}">
1249
+ <span class="activity-indicator" data-status="${statusClass}">${indicator}</span>
1250
+ <div class="external-session-info">
1251
+ <div class="external-session-name">${folderName}</div>
1252
+ <div class="external-session-path">${external.cwd}</div>
1253
+ </div>
1254
+ <div class="external-session-pid">PID ${external.pid}</div>
1255
+ </div>`;
1256
+ }).join('');
1257
+
1258
+ tabsHtml += `<div style="position: relative;">
1259
+ <button class="external-sessions-btn" id="external-sessions-btn" title="External Claude sessions">
1260
+ <span>📍 External</span>
1261
+ <span class="external-sessions-badge">${this.externalSessions.length}</span>
1262
+ </button>
1263
+ <div class="external-sessions-dropdown hidden" id="external-sessions-dropdown">
1264
+ ${dropdownHtml}
1265
+ </div>
1266
+ </div>`;
1267
+ }
1268
+
1269
+ // Add Schedules tab button
1270
+ const badgeHtml = this.schedulesBadgeCount > 0
1271
+ ? `<span class="schedule-tab-badge">${this.schedulesBadgeCount}</span>`
1272
+ : '';
1273
+ tabsHtml += `<button class="schedule-tab-btn" id="schedules-tab-btn" aria-selected="false">
1274
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1275
+ <circle cx="12" cy="12" r="10"/>
1276
+ <polyline points="12 6 12 12 16 14"/>
1277
+ </svg>
1278
+ <span>Schedules</span>
1279
+ ${badgeHtml}
1280
+ </button>`;
1281
+
1282
+ tabs.innerHTML = tabsHtml;
1283
+
1284
+ // Add Schedules tab handler
1285
+ const schedulesTabBtn = document.getElementById('schedules-tab-btn');
1286
+ if (schedulesTabBtn) {
1287
+ schedulesTabBtn.addEventListener('click', () => this.showSchedules());
1288
+ }
1289
+
1290
+ // Add click handlers for regular sessions
1291
+ tabs.querySelectorAll('.session-tab').forEach(tab => {
1292
+ tab.addEventListener('click', (e) => {
1293
+ // Don't switch session if clicking close button
1294
+ if (e.target.classList.contains('session-tab-close')) return;
1295
+ const sessionId = tab.dataset.sessionId;
1296
+ if (sessionId) this.attachSession(sessionId);
1297
+ });
1298
+ });
1299
+
1300
+ // Add close button handlers
1301
+ tabs.querySelectorAll('.session-tab-close').forEach(btn => {
1302
+ btn.addEventListener('click', (e) => {
1303
+ e.stopPropagation();
1304
+ const sessionId = btn.dataset.closeSession;
1305
+ if (sessionId) this.closeSession(sessionId);
1306
+ });
1307
+ });
1308
+
1309
+ // Handle external sessions dropdown
1310
+ if (this.externalSessions.length > 0) {
1311
+ const externalBtn = document.getElementById('external-sessions-btn');
1312
+ const externalDropdown = document.getElementById('external-sessions-dropdown');
1313
+
1314
+ if (externalBtn && externalDropdown) {
1315
+ // Toggle dropdown
1316
+ externalBtn.addEventListener('click', (e) => {
1317
+ e.stopPropagation();
1318
+ const isVisible = externalDropdown.classList.contains('visible');
1319
+
1320
+ if (isVisible) {
1321
+ externalDropdown.classList.remove('visible');
1322
+ externalDropdown.classList.add('hidden');
1323
+ } else {
1324
+ externalDropdown.classList.remove('hidden');
1325
+ externalDropdown.classList.add('visible');
1326
+
1327
+ // Close dropdown when clicking outside (only add when opening)
1328
+ setTimeout(() => {
1329
+ const closeDropdown = (e) => {
1330
+ if (!externalDropdown.contains(e.target) && !externalBtn.contains(e.target)) {
1331
+ externalDropdown.classList.remove('visible');
1332
+ externalDropdown.classList.add('hidden');
1333
+ document.removeEventListener('click', closeDropdown);
1334
+ }
1335
+ };
1336
+ document.addEventListener('click', closeDropdown);
1337
+ }, 0);
1338
+ }
1339
+ });
1340
+
1341
+ // Handle external session adoption
1342
+ externalDropdown.querySelectorAll('.external-session-item').forEach(item => {
1343
+ item.addEventListener('click', (e) => {
1344
+ e.stopPropagation();
1345
+ const pid = parseInt(item.dataset.externalPid, 10);
1346
+ const cwd = item.dataset.externalCwd;
1347
+ if (pid && cwd) {
1348
+ this.adoptExternalSession(pid, cwd);
1349
+ externalDropdown.classList.remove('visible');
1350
+ externalDropdown.classList.add('hidden');
1351
+ }
1352
+ });
1353
+ });
1354
+ }
1355
+ }
1356
+ }
1357
+
1358
+ // Restore selection
1359
+ if (this.currentSessionId) {
1360
+ select.value = this.currentSessionId;
1361
+ } else if (currentValue) {
1362
+ select.value = currentValue;
1363
+ }
1364
+ }
1365
+
1366
+ attachSession(sessionId) {
1367
+ // Save current session's terminal state before switching
1368
+ if (this.currentSessionId && this.serializeAddon) {
1369
+ try {
1370
+ this.sessionCache.set(this.currentSessionId, {
1371
+ content: this.serializeAddon.serialize(),
1372
+ scrollY: this.terminal.buffer.active.viewportY,
1373
+ });
1374
+ } catch (e) {
1375
+ // Ignore serialization errors
1376
+ }
1377
+ }
1378
+
1379
+ // Clear terminal
1380
+ this.terminal.clear();
1381
+
1382
+ // Check if we have cached content for the new session - restore instantly
1383
+ const cached = this.sessionCache.get(sessionId);
1384
+ if (cached) {
1385
+ // Use write callback to restore scroll after content is fully rendered
1386
+ this.terminal.write(cached.content, () => {
1387
+ this.terminal.scrollToLine(cached.scrollY);
1388
+ });
1389
+ }
1390
+
1391
+ // Update tab selection immediately for responsive feel
1392
+ this.updateTabSelection(sessionId);
1393
+
1394
+ // Send attach request - server will send history if we don't have cache
1395
+ // or just start streaming new output if we do
1396
+ this.sendControl({ type: 'session:attach', sessionId, hasCache: !!cached });
1397
+ }
1398
+
1399
+ adoptExternalSession(pid, cwd) {
1400
+ const folderName = cwd.split('/').filter(Boolean).pop() || cwd;
1401
+ if (confirm(`Adopt external Claude session in "${folderName}"?\n\nThis will kill the external process (PID ${pid}) and resume it here with --continue flag.`)) {
1402
+ this.terminal.clear();
1403
+ this.terminal.writeln('\x1b[90mAdopting external session...\x1b[0m');
1404
+ this.sendControl({ type: 'session:adopt', pid, cwd });
1405
+ }
1406
+ }
1407
+
1408
+ closeSession(sessionId) {
1409
+ this.sendControl({ type: 'session:destroy', sessionId });
1410
+ }
1411
+
1412
+ closeCurrentSession() {
1413
+ if (this.currentSessionId) {
1414
+ this.closeSession(this.currentSessionId);
1415
+ }
1416
+ }
1417
+
1418
+ removeSession(sessionId) {
1419
+ // Remove from local sessions list
1420
+ this.sessions = this.sessions.filter(s => s.id !== sessionId);
1421
+ this.updateSessionList(this.sessions);
1422
+
1423
+ // Clean up cached content
1424
+ this.sessionCache.delete(sessionId);
1425
+
1426
+ // If we were attached to this session, clear the terminal
1427
+ if (this.currentSessionId === sessionId) {
1428
+ this.currentSessionId = null;
1429
+ this.terminal.clear();
1430
+ this.terminal.writeln('\x1b[33mSession closed.\x1b[0m');
1431
+
1432
+ // Auto-attach to another session if available
1433
+ if (this.sessions.length > 0) {
1434
+ this.attachSession(this.sessions[0].id);
1435
+ }
1436
+ }
1437
+ }
1438
+
1439
+ updateTabSelection(sessionId) {
1440
+ // Update dropdown
1441
+ this.elements.sessionSelect.value = sessionId || '';
1442
+
1443
+ // Update tabs
1444
+ this.elements.sessionTabs.querySelectorAll('.session-tab').forEach(tab => {
1445
+ const isSelected = tab.dataset.sessionId === sessionId;
1446
+ tab.setAttribute('aria-selected', isSelected);
1447
+ });
1448
+ }
1449
+
1450
+ showNewSessionModal() {
1451
+ this.elements.newSessionModal.classList.remove('hidden');
1452
+ this.elements.cwdInput.focus();
1453
+ }
1454
+
1455
+ hideNewSessionModal() {
1456
+ this.elements.newSessionModal.classList.add('hidden');
1457
+ this.elements.cwdInput.value = '';
1458
+ }
1459
+
1460
+ createSession() {
1461
+ const cwd = this.elements.cwdInput.value.trim() || undefined;
1462
+ this.terminal.clear();
1463
+ this.sendControl({ type: 'session:create', cwd });
1464
+ this.hideNewSessionModal();
1465
+ }
1466
+
1467
+ async loadPorts() {
1468
+ try {
1469
+ const response = await fetch('/api/ports', {
1470
+ headers: { Authorization: `Bearer ${this.token}` },
1471
+ });
1472
+ const ports = await response.json();
1473
+
1474
+ const select = this.elements.portSelect;
1475
+ select.innerHTML = '<option value="">Select port...</option>';
1476
+
1477
+ for (const port of ports) {
1478
+ const option = document.createElement('option');
1479
+ option.value = port.port;
1480
+ option.textContent = `${port.port} - ${port.process}`;
1481
+ select.appendChild(option);
1482
+ }
1483
+ } catch (err) {
1484
+ console.error('Failed to load ports:', err);
1485
+ }
1486
+ }
1487
+
1488
+ showPreview() {
1489
+ this.showScreen('preview-screen');
1490
+ this.loadPorts();
1491
+ }
1492
+
1493
+ hidePreview() {
1494
+ this.showScreen('main-screen');
1495
+ this.elements.previewFrame.src = 'about:blank';
1496
+ this.elements.addressInput.value = '';
1497
+ }
1498
+
1499
+ loadPreview(port, path = '/') {
1500
+ if (!port) return;
1501
+ // Normalize the path
1502
+ if (!path.startsWith('/')) {
1503
+ path = '/' + path;
1504
+ }
1505
+ // Store the current port for address bar navigation
1506
+ this.currentPreviewPort = port;
1507
+ // Update the address bar
1508
+ this.elements.addressInput.value = path;
1509
+ // Build the URL with the path
1510
+ const encodedPath = path === '/' ? '/' : path;
1511
+ this.elements.previewFrame.src = `/preview/${port}${encodedPath}?token=${encodeURIComponent(this.token)}`;
1512
+ }
1513
+
1514
+ loadCustomPort() {
1515
+ const port = this.elements.portInput.value.trim();
1516
+ if (port && port > 0 && port <= 65535) {
1517
+ this.elements.addressInput.value = '/';
1518
+ this.loadPreview(port, '/');
1519
+ }
1520
+ }
1521
+
1522
+ navigateToAddress() {
1523
+ const port = this.currentPreviewPort || this.elements.portInput.value || this.elements.portSelect.value;
1524
+ const path = this.elements.addressInput.value.trim() || '/';
1525
+ if (port) {
1526
+ this.loadPreview(port, path);
1527
+ }
1528
+ }
1529
+
1530
+ refreshPreview() {
1531
+ const port = this.currentPreviewPort || this.elements.portInput.value || this.elements.portSelect.value;
1532
+ const path = this.elements.addressInput.value.trim() || '/';
1533
+ if (port) {
1534
+ this.loadPreview(port, path);
1535
+ }
1536
+ }
1537
+
1538
+ // Autocomplete methods
1539
+ onCwdInput() {
1540
+ clearTimeout(this.debounceTimer);
1541
+ this.debounceTimer = setTimeout(() => this.fetchSuggestions(), 150);
1542
+ }
1543
+
1544
+ async fetchSuggestions() {
1545
+ const value = this.elements.cwdInput.value;
1546
+ if (!value) {
1547
+ this.hideSuggestions();
1548
+ return;
1549
+ }
1550
+
1551
+ try {
1552
+ const response = await fetch(`/api/dirs?path=${encodeURIComponent(value)}`, {
1553
+ headers: { Authorization: `Bearer ${this.token}` },
1554
+ });
1555
+ this.suggestions = await response.json();
1556
+ this.selectedSuggestionIndex = -1;
1557
+ this.renderSuggestions();
1558
+ } catch (err) {
1559
+ console.error('Failed to fetch suggestions:', err);
1560
+ this.hideSuggestions();
1561
+ }
1562
+ }
1563
+
1564
+ renderSuggestions() {
1565
+ const ul = this.elements.cwdSuggestions;
1566
+
1567
+ if (this.suggestions.length === 0) {
1568
+ this.hideSuggestions();
1569
+ return;
1570
+ }
1571
+
1572
+ ul.innerHTML = this.suggestions.map((s, i) => `
1573
+ <li role="option" data-index="${i}" class="${i === this.selectedSuggestionIndex ? 'selected' : ''}">
1574
+ <span class="dir-name">${s.name}/</span>
1575
+ </li>
1576
+ `).join('');
1577
+
1578
+ // Add click handlers - use mousedown to prevent blur from firing
1579
+ ul.querySelectorAll('li').forEach(li => {
1580
+ li.addEventListener('mousedown', (e) => {
1581
+ e.preventDefault(); // Prevent blur on input
1582
+ const index = parseInt(li.dataset.index, 10);
1583
+ this.selectSuggestion(index);
1584
+ });
1585
+ });
1586
+
1587
+ ul.classList.remove('hidden');
1588
+ }
1589
+
1590
+ hideSuggestions() {
1591
+ this.elements.cwdSuggestions.classList.add('hidden');
1592
+ this.suggestions = [];
1593
+ this.selectedSuggestionIndex = -1;
1594
+ }
1595
+
1596
+ selectSuggestion(index) {
1597
+ if (index >= 0 && index < this.suggestions.length) {
1598
+ this.elements.cwdInput.value = this.suggestions[index].path;
1599
+ this.hideSuggestions();
1600
+ // Refocus input and trigger another fetch for subdirectories
1601
+ this.elements.cwdInput.focus();
1602
+ this.fetchSuggestions();
1603
+ }
1604
+ }
1605
+
1606
+ onCwdKeydown(e) {
1607
+ // Handle Enter even when no suggestions
1608
+ if (e.key === 'Enter' && this.suggestions.length === 0) {
1609
+ this.createSession();
1610
+ return;
1611
+ }
1612
+
1613
+ if (this.suggestions.length === 0) return;
1614
+
1615
+ switch (e.key) {
1616
+ case 'ArrowDown':
1617
+ e.preventDefault();
1618
+ this.selectedSuggestionIndex = Math.min(
1619
+ this.selectedSuggestionIndex + 1,
1620
+ this.suggestions.length - 1
1621
+ );
1622
+ this.renderSuggestions();
1623
+ break;
1624
+
1625
+ case 'ArrowUp':
1626
+ e.preventDefault();
1627
+ this.selectedSuggestionIndex = Math.max(this.selectedSuggestionIndex - 1, -1);
1628
+ this.renderSuggestions();
1629
+ break;
1630
+
1631
+ case 'Tab':
1632
+ if (this.selectedSuggestionIndex >= 0) {
1633
+ e.preventDefault();
1634
+ this.selectSuggestion(this.selectedSuggestionIndex);
1635
+ } else if (this.suggestions.length > 0) {
1636
+ e.preventDefault();
1637
+ this.selectSuggestion(0);
1638
+ }
1639
+ break;
1640
+
1641
+ case 'Enter':
1642
+ if (this.selectedSuggestionIndex >= 0) {
1643
+ e.preventDefault();
1644
+ this.selectSuggestion(this.selectedSuggestionIndex);
1645
+ } else {
1646
+ // No suggestion selected - submit the form
1647
+ this.hideSuggestions();
1648
+ this.createSession();
1649
+ }
1650
+ break;
1651
+
1652
+ case 'Escape':
1653
+ this.hideSuggestions();
1654
+ break;
1655
+ }
1656
+ }
1657
+
1658
+ // Mobile keys methods
1659
+ initMobileKeys() {
1660
+ // Only init on touch devices
1661
+ if (window.matchMedia('(pointer: fine)').matches) return;
1662
+
1663
+ // Handle mobile key button clicks
1664
+ this.elements.mobileKeys.addEventListener('click', (e) => {
1665
+ const btn = e.target.closest('.mobile-key');
1666
+ if (!btn) return;
1667
+
1668
+ const key = btn.dataset.key;
1669
+ this.handleMobileKey(key);
1670
+
1671
+ // Keep terminal focused
1672
+ this.terminal.focus();
1673
+ });
1674
+
1675
+ // Add touch handlers for visual feedback (iOS doesn't always trigger :active)
1676
+ this.elements.mobileKeys.addEventListener('touchstart', (e) => {
1677
+ const btn = e.target.closest('.mobile-key');
1678
+ if (btn) btn.classList.add('pressed');
1679
+ }, { passive: true });
1680
+
1681
+ this.elements.mobileKeys.addEventListener('touchend', () => {
1682
+ this.elements.mobileKeys.querySelectorAll('.mobile-key.pressed').forEach(btn => {
1683
+ btn.classList.remove('pressed');
1684
+ });
1685
+ }, { passive: true });
1686
+
1687
+ this.elements.mobileKeys.addEventListener('touchcancel', () => {
1688
+ this.elements.mobileKeys.querySelectorAll('.mobile-key.pressed').forEach(btn => {
1689
+ btn.classList.remove('pressed');
1690
+ });
1691
+ }, { passive: true });
1692
+
1693
+ // Detect keyboard visibility using visualViewport API
1694
+ if (window.visualViewport) {
1695
+ window.visualViewport.addEventListener('resize', () => this.onViewportChange());
1696
+ window.visualViewport.addEventListener('scroll', () => this.onViewportChange());
1697
+ }
1698
+ }
1699
+
1700
+ onViewportChange() {
1701
+ const viewport = window.visualViewport;
1702
+ const heightDiff = window.innerHeight - viewport.height;
1703
+
1704
+ // If viewport is significantly smaller than window, keyboard is likely open
1705
+ // Using 150px threshold to account for keyboard
1706
+ const keyboardOpen = heightDiff > 150;
1707
+
1708
+ if (keyboardOpen && this.elements.mainScreen.classList.contains('active')) {
1709
+ // Position toolbar just above the keyboard
1710
+ // Account for visual viewport offset when page is scrolled
1711
+ const keyboardHeight = window.innerHeight - viewport.height - viewport.offsetTop;
1712
+ this.elements.mobileKeys.style.bottom = `${Math.max(0, keyboardHeight)}px`;
1713
+ this.showMobileKeys();
1714
+ } else {
1715
+ this.hideMobileKeys();
1716
+ }
1717
+ }
1718
+
1719
+ showMobileKeys() {
1720
+ this.elements.mobileKeys.classList.remove('hidden');
1721
+ this.elements.mobileKeys.classList.add('visible');
1722
+ this.elements.mainScreen.classList.add('mobile-keys-visible');
1723
+ this.fitTerminal();
1724
+ }
1725
+
1726
+ hideMobileKeys() {
1727
+ this.elements.mobileKeys.classList.remove('visible');
1728
+ this.elements.mainScreen.classList.remove('mobile-keys-visible');
1729
+ // Reset modifier states when hiding
1730
+ this.setCtrlActive(false);
1731
+ this.setShiftActive(false);
1732
+ this.fitTerminal();
1733
+ // Hide after animation
1734
+ setTimeout(() => {
1735
+ if (!this.elements.mobileKeys.classList.contains('visible')) {
1736
+ this.elements.mobileKeys.classList.add('hidden');
1737
+ }
1738
+ }, 300);
1739
+ }
1740
+
1741
+ handleMobileKey(key) {
1742
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.currentSessionId) return;
1743
+
1744
+ switch (key) {
1745
+ case 'escape':
1746
+ this.ws.send('\x1b'); // ESC
1747
+ break;
1748
+ case 'ctrl':
1749
+ this.setCtrlActive(!this.ctrlActive);
1750
+ return; // Don't send anything, just toggle state
1751
+ case 'shift':
1752
+ this.setShiftActive(!this.shiftActive);
1753
+ return; // Don't send anything, just toggle state
1754
+ case 'tab':
1755
+ this.ws.send(this.shiftActive ? '\x1b[Z' : '\t'); // Shift+Tab or Tab
1756
+ break;
1757
+ case 'slash':
1758
+ this.ws.send('/');
1759
+ break;
1760
+ case 'up':
1761
+ this.ws.send('\x1b[A'); // Arrow up
1762
+ break;
1763
+ case 'down':
1764
+ this.ws.send('\x1b[B'); // Arrow down
1765
+ break;
1766
+ }
1767
+
1768
+ // Clear modifiers after sending a key (except when toggling modifiers)
1769
+ if (key !== 'ctrl' && key !== 'shift') {
1770
+ if (this.ctrlActive) this.setCtrlActive(false);
1771
+ if (this.shiftActive) this.setShiftActive(false);
1772
+ }
1773
+ }
1774
+
1775
+ setCtrlActive(active) {
1776
+ this.ctrlActive = active;
1777
+ const ctrlBtn = this.elements.mobileKeys.querySelector('[data-key="ctrl"]');
1778
+ if (ctrlBtn) {
1779
+ ctrlBtn.setAttribute('aria-pressed', active);
1780
+ }
1781
+ }
1782
+
1783
+ setShiftActive(active) {
1784
+ this.shiftActive = active;
1785
+ const shiftBtn = this.elements.mobileKeys.querySelector('[data-key="shift"]');
1786
+ if (shiftBtn) {
1787
+ shiftBtn.setAttribute('aria-pressed', active);
1788
+ }
1789
+ }
1790
+
1791
+ // Scroll button methods
1792
+ updateScrollButton() {
1793
+ if (!this.terminal) return;
1794
+
1795
+ const buffer = this.terminal.buffer.active;
1796
+ const totalLines = buffer.baseY + this.terminal.rows;
1797
+ const currentScroll = buffer.viewportY;
1798
+ const maxScroll = buffer.baseY;
1799
+
1800
+ // Show button if not at bottom (with small threshold for rounding)
1801
+ const isAtBottom = currentScroll >= maxScroll - 1;
1802
+
1803
+ if (isAtBottom) {
1804
+ this.elements.scrollBottomBtn.classList.add('hidden');
1805
+ } else {
1806
+ this.elements.scrollBottomBtn.classList.remove('hidden');
1807
+ }
1808
+ }
1809
+
1810
+ scrollToBottom() {
1811
+ if (this.terminal) {
1812
+ this.terminal.scrollToBottom();
1813
+ this.elements.scrollBottomBtn.classList.add('hidden');
1814
+ }
1815
+ }
1816
+
1817
+ // Update session activity status without full re-render
1818
+ updateSessionStatus(sessions, externalSessions) {
1819
+ let statusChanged = false;
1820
+
1821
+ // Update stored session data
1822
+ for (const session of sessions) {
1823
+ const existing = this.sessions.find(s => s.id === session.id);
1824
+ if (existing && existing.activityStatus !== session.activityStatus) {
1825
+ existing.activityStatus = session.activityStatus;
1826
+ statusChanged = true;
1827
+ }
1828
+ }
1829
+ // Update external sessions data
1830
+ if (externalSessions) {
1831
+ for (const external of externalSessions) {
1832
+ const existing = this.externalSessions.find(s => s.pid === external.pid);
1833
+ if (existing && existing.activityStatus !== external.activityStatus) {
1834
+ existing.activityStatus = external.activityStatus;
1835
+ statusChanged = true;
1836
+ }
1837
+ }
1838
+ }
1839
+
1840
+ // Update status indicators in the DOM
1841
+ this.updateActivityIndicators();
1842
+
1843
+ // Update dropdown options only when status actually changes
1844
+ if (statusChanged) {
1845
+ this.updateDropdownStatus();
1846
+ }
1847
+ }
1848
+
1849
+ // Update dropdown option indicators (called only on status change, not animation)
1850
+ updateDropdownStatus() {
1851
+ const options = this.elements.sessionSelect.querySelectorAll('option');
1852
+ options.forEach(option => {
1853
+ const sessionId = option.value;
1854
+ if (!sessionId) return;
1855
+
1856
+ if (sessionId.startsWith('external:')) {
1857
+ const pid = parseInt(sessionId.replace('external:', ''), 10);
1858
+ const external = this.externalSessions.find(s => s.pid === pid);
1859
+ if (external) {
1860
+ const indicator = external.activityStatus === 'busy' ? '⏳' : '○';
1861
+ const folderName = external.cwd.split('/').filter(Boolean).pop() || external.cwd;
1862
+ option.textContent = `${indicator} ${folderName}`;
1863
+ option.dataset.status = external.activityStatus || 'unknown';
1864
+ }
1865
+ } else {
1866
+ const session = this.sessions.find(s => s.id === sessionId);
1867
+ if (session) {
1868
+ const indicator = session.activityStatus === 'busy' ? '⏳' : '○';
1869
+ option.textContent = `${indicator} ${this.getDisplayName(session)}`;
1870
+ option.dataset.status = session.activityStatus || 'unknown';
1871
+ }
1872
+ }
1873
+ });
1874
+ }
1875
+
1876
+ // Update all activity status indicators in tabs and dropdown
1877
+ updateActivityIndicators() {
1878
+ // Update tabs
1879
+ this.elements.sessionTabs.querySelectorAll('.session-tab').forEach(tab => {
1880
+ const sessionId = tab.dataset.sessionId;
1881
+ const session = this.sessions.find(s => s.id === sessionId);
1882
+ const indicator = tab.querySelector('.activity-indicator');
1883
+ if (indicator && session) {
1884
+ indicator.dataset.status = session.activityStatus || 'unknown';
1885
+ if (session.activityStatus === 'busy') {
1886
+ indicator.textContent = SPINNER_FRAMES[this.spinnerFrame];
1887
+ } else {
1888
+ indicator.textContent = '●';
1889
+ }
1890
+ }
1891
+ });
1892
+
1893
+ // Note: We don't animate dropdown <option> elements - they use static indicators
1894
+ // set during updateSessionList(). Animating them causes flashing on mobile.
1895
+
1896
+ // Update external session items
1897
+ const externalItems = document.querySelectorAll('.external-session-item');
1898
+ externalItems.forEach(item => {
1899
+ const cwd = item.dataset.externalCwd;
1900
+ const external = this.externalSessions.find(s => s.cwd === cwd);
1901
+ const indicator = item.querySelector('.activity-indicator');
1902
+ if (indicator && external) {
1903
+ indicator.dataset.status = external.activityStatus || 'unknown';
1904
+ if (external.activityStatus === 'busy') {
1905
+ indicator.textContent = SPINNER_FRAMES[this.spinnerFrame];
1906
+ } else {
1907
+ indicator.textContent = '●';
1908
+ }
1909
+ }
1910
+ });
1911
+ }
1912
+
1913
+ // Update spinner frames for busy sessions
1914
+ updateSpinnerFrames() {
1915
+ // Only update if there are busy sessions
1916
+ const hasBusy = this.sessions.some(s => s.activityStatus === 'busy') ||
1917
+ this.externalSessions.some(s => s.activityStatus === 'busy');
1918
+ if (hasBusy) {
1919
+ this.updateActivityIndicators();
1920
+ }
1921
+ }
1922
+
1923
+ // ===== SCHEDULE METHODS =====
1924
+
1925
+ showSchedules() {
1926
+ this.showScreen('schedules-screen');
1927
+ this.schedulesBadgeCount = 0;
1928
+ this.schedulesLastViewed = Date.now();
1929
+ this.updateScheduleBadge();
1930
+ this.sendControl({ type: 'schedule:list' });
1931
+ }
1932
+
1933
+ hideSchedules() {
1934
+ this.showScreen('main-screen');
1935
+ }
1936
+
1937
+ renderSchedules() {
1938
+ const list = this.elements.schedulesList;
1939
+
1940
+ if (this.schedules.length === 0) {
1941
+ this.elements.schedulesEmpty.style.display = '';
1942
+ // Remove all cards but keep empty state
1943
+ list.querySelectorAll('.schedule-card').forEach(c => c.remove());
1944
+ return;
1945
+ }
1946
+
1947
+ this.elements.schedulesEmpty.style.display = 'none';
1948
+
1949
+ // Build cards HTML
1950
+ let html = '';
1951
+ for (const schedule of this.schedules) {
1952
+ const lastRunHtml = schedule.lastRun
1953
+ ? `<div class="schedule-last-run">
1954
+ <span class="run-status ${schedule.lastRun.exitCode === 0 ? 'success' : 'failure'}"></span>
1955
+ <span>${schedule.lastRun.exitCode === 0 ? 'Success' : 'Failed'} - ${this.formatRunTimestamp(schedule.lastRun.timestamp)}</span>
1956
+ </div>`
1957
+ : `<div class="schedule-last-run"><span class="run-status pending"></span><span>No runs yet</span></div>`;
1958
+
1959
+ const runsData = this.expandedScheduleRuns.get(schedule.id);
1960
+ let runsHtml = '';
1961
+ if (runsData) {
1962
+ if (runsData.length === 0) {
1963
+ runsHtml = `<div class="schedule-runs-list"><div class="run-item"><span style="color:var(--text-muted)">No runs recorded</span></div></div>`;
1964
+ } else {
1965
+ runsHtml = `<div class="schedule-runs-list">${runsData.map(run => {
1966
+ const statusClass = run.exitCode === 0 ? 'success' : (run.exitCode === null ? 'running' : 'failure');
1967
+ const duration = run.durationMs ? this.formatDuration(run.durationMs) : '...';
1968
+ return `<div class="run-item" data-schedule-id="${schedule.id}" data-run-timestamp="${run.timestamp}">
1969
+ <span class="run-item-status ${statusClass}"></span>
1970
+ <span class="run-item-time">${this.formatRunTimestamp(run.timestamp)}</span>
1971
+ <span class="run-item-duration">${duration}</span>
1972
+ </div>`;
1973
+ }).join('')}</div>`;
1974
+ }
1975
+ }
1976
+
1977
+ const toggleLabel = runsData ? 'Hide runs' : 'View runs';
1978
+
1979
+ html += `<div class="schedule-card" data-schedule-id="${schedule.id}">
1980
+ <div class="schedule-card-header">
1981
+ <span class="schedule-card-name">${this.escapeHtml(schedule.name)}</span>
1982
+ <div class="schedule-card-actions">
1983
+ <button class="schedule-trigger-btn" data-trigger-id="${schedule.id}" title="Run now">
1984
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none">
1985
+ <polygon points="6 3 20 12 6 21 6 3"/>
1986
+ </svg>
1987
+ </button>
1988
+ <button class="toggle schedule-toggle" role="switch" aria-checked="${schedule.enabled}" data-toggle-id="${schedule.id}">
1989
+ <span class="toggle-track"></span>
1990
+ <span class="toggle-thumb"></span>
1991
+ </button>
1992
+ <button class="schedule-delete-btn" data-delete-id="${schedule.id}" title="Delete schedule">
1993
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1994
+ <polyline points="3 6 5 6 21 6"/>
1995
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
1996
+ </svg>
1997
+ </button>
1998
+ </div>
1999
+ </div>
2000
+ <div class="schedule-card-meta">
2001
+ <div class="schedule-meta-row">
2002
+ <span class="schedule-meta-label">Prompt</span>
2003
+ <span class="schedule-meta-value prompt-value">${this.escapeHtml(schedule.prompt)}</span>
2004
+ </div>
2005
+ <div class="schedule-meta-row">
2006
+ <span class="schedule-meta-label">Dir</span>
2007
+ <span class="schedule-meta-value">${this.escapeHtml(schedule.cwd)}</span>
2008
+ </div>
2009
+ <div class="schedule-meta-row">
2010
+ <span class="schedule-meta-label">Schedule</span>
2011
+ <span class="schedule-meta-value">${this.escapeHtml(schedule.presetLabel)} <span style="color:var(--text-muted)">(${this.escapeHtml(schedule.cronExpression)})</span></span>
2012
+ </div>
2013
+ <div class="schedule-meta-row">
2014
+ <span class="schedule-meta-label">Last Run</span>
2015
+ ${lastRunHtml}
2016
+ </div>
2017
+ </div>
2018
+ <button class="schedule-runs-toggle" data-runs-id="${schedule.id}">${toggleLabel}</button>
2019
+ ${runsHtml}
2020
+ </div>`;
2021
+ }
2022
+
2023
+ // Remove existing cards, keep empty state element
2024
+ list.querySelectorAll('.schedule-card').forEach(c => c.remove());
2025
+ list.insertAdjacentHTML('beforeend', html);
2026
+
2027
+ // Bind event handlers
2028
+ list.querySelectorAll('.schedule-toggle').forEach(btn => {
2029
+ btn.addEventListener('click', () => {
2030
+ const id = btn.dataset.toggleId;
2031
+ const currentEnabled = btn.getAttribute('aria-checked') === 'true';
2032
+ this.toggleSchedule(id, !currentEnabled);
2033
+ });
2034
+ });
2035
+
2036
+ list.querySelectorAll('.schedule-trigger-btn').forEach(btn => {
2037
+ btn.addEventListener('click', () => {
2038
+ const id = btn.dataset.triggerId;
2039
+ this.triggerSchedule(id);
2040
+ });
2041
+ });
2042
+
2043
+ list.querySelectorAll('.schedule-delete-btn').forEach(btn => {
2044
+ btn.addEventListener('click', () => {
2045
+ const id = btn.dataset.deleteId;
2046
+ this.deleteSchedule(id);
2047
+ });
2048
+ });
2049
+
2050
+ list.querySelectorAll('.schedule-runs-toggle').forEach(btn => {
2051
+ btn.addEventListener('click', () => {
2052
+ const id = btn.dataset.runsId;
2053
+ this.viewRuns(id);
2054
+ });
2055
+ });
2056
+
2057
+ list.querySelectorAll('.run-item').forEach(item => {
2058
+ item.addEventListener('click', () => {
2059
+ const scheduleId = item.dataset.scheduleId;
2060
+ const timestamp = item.dataset.runTimestamp;
2061
+ if (scheduleId && timestamp) {
2062
+ this.viewRunLog(scheduleId, timestamp);
2063
+ }
2064
+ });
2065
+ });
2066
+ }
2067
+
2068
+ updateScheduleBadge() {
2069
+ const headerBadge = this.elements.schedulesHeaderBadge;
2070
+ if (this.schedulesBadgeCount > 0) {
2071
+ headerBadge.textContent = this.schedulesBadgeCount;
2072
+ headerBadge.classList.remove('hidden');
2073
+ } else {
2074
+ headerBadge.classList.add('hidden');
2075
+ }
2076
+ // Re-render session list to update tab badge
2077
+ this.updateSessionList(this.sessions);
2078
+ }
2079
+
2080
+ showNewScheduleModal() {
2081
+ this.elements.newScheduleModal.classList.remove('hidden');
2082
+ this.elements.scheduleNameInput.focus();
2083
+ }
2084
+
2085
+ hideNewScheduleModal() {
2086
+ this.elements.newScheduleModal.classList.add('hidden');
2087
+ this.elements.scheduleNameInput.value = '';
2088
+ this.elements.schedulePromptInput.value = '';
2089
+ this.elements.scheduleCwdInput.value = '';
2090
+ this.elements.scheduleTextInput.value = '';
2091
+ this.resetScheduleCreateBtn();
2092
+ }
2093
+
2094
+ resetScheduleCreateBtn() {
2095
+ const createBtn = document.getElementById('create-schedule-btn');
2096
+ const cancelBtn = document.getElementById('cancel-schedule-btn');
2097
+ if (createBtn) {
2098
+ createBtn.textContent = 'Create';
2099
+ createBtn.disabled = false;
2100
+ }
2101
+ if (cancelBtn) cancelBtn.disabled = false;
2102
+ }
2103
+
2104
+ createSchedule() {
2105
+ const name = this.elements.scheduleNameInput.value.trim();
2106
+ const prompt = this.elements.schedulePromptInput.value.trim();
2107
+ const cwd = this.elements.scheduleCwdInput.value.trim();
2108
+ const scheduleText = this.elements.scheduleTextInput.value.trim();
2109
+
2110
+ if (!name || !prompt || !cwd || !scheduleText) {
2111
+ return; // form validation - all fields required
2112
+ }
2113
+
2114
+ // Show loading state
2115
+ const createBtn = document.getElementById('create-schedule-btn');
2116
+ const cancelBtn = document.getElementById('cancel-schedule-btn');
2117
+ createBtn.textContent = 'Creating...';
2118
+ createBtn.disabled = true;
2119
+ cancelBtn.disabled = true;
2120
+
2121
+ this.sendControl({ type: 'schedule:create', name, prompt, cwd, scheduleText });
2122
+ }
2123
+
2124
+ toggleSchedule(id, enabled) {
2125
+ this.sendControl({ type: 'schedule:update', scheduleId: id, enabled });
2126
+ }
2127
+
2128
+ triggerSchedule(id) {
2129
+ this.sendControl({ type: 'schedule:trigger', scheduleId: id });
2130
+ }
2131
+
2132
+ deleteSchedule(id) {
2133
+ const schedule = this.schedules.find(s => s.id === id);
2134
+ const name = schedule ? schedule.name : id;
2135
+ if (confirm(`Delete schedule "${name}"?`)) {
2136
+ this.expandedScheduleRuns.delete(id);
2137
+ this.sendControl({ type: 'schedule:delete', scheduleId: id });
2138
+ }
2139
+ }
2140
+
2141
+ viewRuns(scheduleId) {
2142
+ if (this.expandedScheduleRuns.has(scheduleId)) {
2143
+ // Collapse
2144
+ this.expandedScheduleRuns.delete(scheduleId);
2145
+ this.renderSchedules();
2146
+ } else {
2147
+ // Expand - request runs from server
2148
+ this.sendControl({ type: 'schedule:runs', scheduleId });
2149
+ }
2150
+ }
2151
+
2152
+ viewRunLog(scheduleId, timestamp) {
2153
+ this.sendControl({ type: 'schedule:log', scheduleId, timestamp });
2154
+ }
2155
+
2156
+ showRunLogModal(content) {
2157
+ this.elements.runLogPre.textContent = content;
2158
+ this.elements.runLogModal.classList.remove('hidden');
2159
+ }
2160
+
2161
+ hideRunLogModal() {
2162
+ this.elements.runLogModal.classList.add('hidden');
2163
+ this.elements.runLogPre.textContent = '';
2164
+ }
2165
+
2166
+ // Schedule CWD autocomplete
2167
+ onScheduleCwdInput() {
2168
+ clearTimeout(this.scheduleDebounceTimer);
2169
+ this.scheduleDebounceTimer = setTimeout(() => this.fetchScheduleSuggestions(), 150);
2170
+ }
2171
+
2172
+ async fetchScheduleSuggestions() {
2173
+ const value = this.elements.scheduleCwdInput.value;
2174
+ if (!value) {
2175
+ this.hideScheduleSuggestions();
2176
+ return;
2177
+ }
2178
+
2179
+ try {
2180
+ const response = await fetch(`/api/dirs?path=${encodeURIComponent(value)}`, {
2181
+ headers: { Authorization: `Bearer ${this.token}` },
2182
+ });
2183
+ this.scheduleSuggestions = await response.json();
2184
+ this.scheduleSelectedIndex = -1;
2185
+ this.renderScheduleSuggestions();
2186
+ } catch (err) {
2187
+ this.hideScheduleSuggestions();
2188
+ }
2189
+ }
2190
+
2191
+ renderScheduleSuggestions() {
2192
+ const ul = this.elements.scheduleCwdSuggestions;
2193
+
2194
+ if (this.scheduleSuggestions.length === 0) {
2195
+ this.hideScheduleSuggestions();
2196
+ return;
2197
+ }
2198
+
2199
+ ul.innerHTML = this.scheduleSuggestions.map((s, i) => `
2200
+ <li role="option" data-index="${i}" class="${i === this.scheduleSelectedIndex ? 'selected' : ''}">
2201
+ <span class="dir-name">${s.name}/</span>
2202
+ </li>
2203
+ `).join('');
2204
+
2205
+ ul.querySelectorAll('li').forEach(li => {
2206
+ li.addEventListener('mousedown', (e) => {
2207
+ e.preventDefault();
2208
+ const index = parseInt(li.dataset.index, 10);
2209
+ this.selectScheduleSuggestion(index);
2210
+ });
2211
+ });
2212
+
2213
+ ul.classList.remove('hidden');
2214
+ }
2215
+
2216
+ hideScheduleSuggestions() {
2217
+ this.elements.scheduleCwdSuggestions.classList.add('hidden');
2218
+ this.scheduleSuggestions = [];
2219
+ this.scheduleSelectedIndex = -1;
2220
+ }
2221
+
2222
+ selectScheduleSuggestion(index) {
2223
+ if (index >= 0 && index < this.scheduleSuggestions.length) {
2224
+ this.elements.scheduleCwdInput.value = this.scheduleSuggestions[index].path;
2225
+ this.hideScheduleSuggestions();
2226
+ this.elements.scheduleCwdInput.focus();
2227
+ this.fetchScheduleSuggestions();
2228
+ }
2229
+ }
2230
+
2231
+ onScheduleCwdKeydown(e) {
2232
+ if (this.scheduleSuggestions.length === 0) return;
2233
+
2234
+ switch (e.key) {
2235
+ case 'ArrowDown':
2236
+ e.preventDefault();
2237
+ this.scheduleSelectedIndex = Math.min(
2238
+ this.scheduleSelectedIndex + 1,
2239
+ this.scheduleSuggestions.length - 1
2240
+ );
2241
+ this.renderScheduleSuggestions();
2242
+ break;
2243
+ case 'ArrowUp':
2244
+ e.preventDefault();
2245
+ this.scheduleSelectedIndex = Math.max(this.scheduleSelectedIndex - 1, -1);
2246
+ this.renderScheduleSuggestions();
2247
+ break;
2248
+ case 'Tab':
2249
+ if (this.scheduleSelectedIndex >= 0) {
2250
+ e.preventDefault();
2251
+ this.selectScheduleSuggestion(this.scheduleSelectedIndex);
2252
+ } else if (this.scheduleSuggestions.length > 0) {
2253
+ e.preventDefault();
2254
+ this.selectScheduleSuggestion(0);
2255
+ }
2256
+ break;
2257
+ case 'Enter':
2258
+ if (this.scheduleSelectedIndex >= 0) {
2259
+ e.preventDefault();
2260
+ this.selectScheduleSuggestion(this.scheduleSelectedIndex);
2261
+ }
2262
+ break;
2263
+ case 'Escape':
2264
+ this.hideScheduleSuggestions();
2265
+ break;
2266
+ }
2267
+ }
2268
+
2269
+ // Utility: format run timestamp for display
2270
+ formatRunTimestamp(ts) {
2271
+ if (!ts) return 'Unknown';
2272
+ // timestamps may have colons replaced with dashes
2273
+ const normalized = ts.replace(/T(\d{2})-(\d{2})-(\d{2})/, 'T$1:$2:$3');
2274
+ try {
2275
+ const date = new Date(normalized);
2276
+ if (isNaN(date.getTime())) return ts;
2277
+ return date.toLocaleString(undefined, {
2278
+ month: 'short', day: 'numeric',
2279
+ hour: '2-digit', minute: '2-digit'
2280
+ });
2281
+ } catch {
2282
+ return ts;
2283
+ }
2284
+ }
2285
+
2286
+ // Utility: format duration in ms to human readable
2287
+ formatDuration(ms) {
2288
+ if (ms < 1000) return `${ms}ms`;
2289
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
2290
+ const mins = Math.floor(ms / 60000);
2291
+ const secs = Math.floor((ms % 60000) / 1000);
2292
+ return `${mins}m ${secs}s`;
2293
+ }
2294
+
2295
+ // Utility: escape HTML
2296
+ escapeHtml(str) {
2297
+ const div = document.createElement('div');
2298
+ div.textContent = str;
2299
+ return div.innerHTML;
2300
+ }
2301
+
2302
+ // Helper to get display name for a session
2303
+ getDisplayName(session) {
2304
+ const getFolderName = (cwd) => {
2305
+ const parts = cwd.split('/').filter(Boolean);
2306
+ return parts[parts.length - 1] || cwd;
2307
+ };
2308
+
2309
+ const folderCounts = new Map();
2310
+ for (const s of this.sessions) {
2311
+ const dirName = getFolderName(s.cwd);
2312
+ folderCounts.set(dirName, (folderCounts.get(dirName) || 0) + 1);
2313
+ }
2314
+
2315
+ const dirName = getFolderName(session.cwd);
2316
+ if (folderCounts.get(dirName) > 1) {
2317
+ return `${session.id.slice(0, 3)} ${dirName}`;
2318
+ }
2319
+ return dirName;
2320
+ }
2321
+ }
2322
+
2323
+ // Initialize app when DOM is ready
2324
+ if (document.readyState === 'loading') {
2325
+ document.addEventListener('DOMContentLoaded', () => new ClaudeRemote());
2326
+ } else {
2327
+ new ClaudeRemote();
2328
+ }