claude-code-runner 0.1.0 → 0.1.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 ADDED
@@ -0,0 +1,1115 @@
1
+ /* eslint-disable no-control-regex */
2
+ /* global Terminal, FitAddon, WebLinksAddon, io */
3
+
4
+ // Terminal and Socket.IO setup
5
+ let term;
6
+ let socket;
7
+ let fitAddon;
8
+ let webLinksAddon;
9
+ let containerId;
10
+
11
+ // Input detection state
12
+ let isWaitingForInput = false;
13
+ let lastOutputTime = Date.now();
14
+ let lastNotificationTime = 0;
15
+ let idleTimer = null;
16
+ let isWaitingForLoadingAnimation = false;
17
+ const seenLoadingChars = new Set();
18
+ let originalPageTitle = '';
19
+ const IDLE_THRESHOLD = 1500; // 1.5 seconds of no output means waiting for input
20
+ const NOTIFICATION_COOLDOWN = 2000; // 2 seconds between notifications
21
+
22
+ // Claude's loading animation characters (unique characters only)
23
+ const LOADING_CHARS = ['✢', '✶', '✻', '✽', '✻', '✢', '·'];
24
+ const UNIQUE_LOADING_CHARS = new Set(LOADING_CHARS);
25
+
26
+ // Create notification sound using Web Audio API
27
+ let audioContext;
28
+ let notificationSound;
29
+
30
+ function initializeAudio() {
31
+ try {
32
+ if (window.AudioContext || window.webkitAudioContext) {
33
+ audioContext = new (window.AudioContext || window.webkitAudioContext)();
34
+ console.log('Audio context created:', audioContext.state);
35
+
36
+ // Create a simple notification beep
37
+ function createBeep(frequency, duration) {
38
+ try {
39
+ const oscillator = audioContext.createOscillator();
40
+ const gainNode = audioContext.createGain();
41
+
42
+ oscillator.connect(gainNode);
43
+ gainNode.connect(audioContext.destination);
44
+
45
+ oscillator.frequency.value = frequency;
46
+ oscillator.type = 'sine';
47
+
48
+ gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
49
+ gainNode.gain.exponentialRampToValueAtTime(
50
+ 0.01,
51
+ audioContext.currentTime + duration,
52
+ );
53
+
54
+ oscillator.start(audioContext.currentTime);
55
+ oscillator.stop(audioContext.currentTime + duration);
56
+
57
+ return true;
58
+ }
59
+ catch (error) {
60
+ console.error('Error creating beep:', error);
61
+ return false;
62
+ }
63
+ }
64
+
65
+ notificationSound = () => {
66
+ console.log(
67
+ 'Playing notification sound, audio context state:',
68
+ audioContext.state,
69
+ );
70
+
71
+ // Try Web Audio API first
72
+ try {
73
+ const beep1 = createBeep(800, 0.1);
74
+ setTimeout(() => createBeep(1000, 0.1), 100);
75
+ setTimeout(() => createBeep(1200, 0.15), 200);
76
+ return beep1;
77
+ }
78
+ catch (error) {
79
+ console.error('Web Audio API failed, trying fallback:', error);
80
+
81
+ // Fallback to HTML audio element
82
+ const audioElement = document.getElementById('notification-sound');
83
+ if (audioElement) {
84
+ audioElement.currentTime = 0;
85
+ audioElement
86
+ .play()
87
+ .catch(e => console.error('Fallback audio failed:', e));
88
+ }
89
+ return false;
90
+ }
91
+ };
92
+ }
93
+ else {
94
+ // No Web Audio API support, use fallback only
95
+ console.log('Web Audio API not supported, using fallback audio');
96
+ notificationSound = () => {
97
+ const audioElement = document.getElementById('notification-sound');
98
+ if (audioElement) {
99
+ audioElement.currentTime = 0;
100
+ audioElement
101
+ .play()
102
+ .catch(e => console.error('Fallback audio failed:', e));
103
+ }
104
+ };
105
+ }
106
+
107
+ console.log('Audio initialized successfully');
108
+ }
109
+ catch (error) {
110
+ console.error('Failed to initialize audio:', error);
111
+
112
+ // Last resort fallback
113
+ notificationSound = () => {
114
+ console.log('Audio not available');
115
+ };
116
+ }
117
+ }
118
+
119
+ // Idle detection functions
120
+ function resetIdleTimer() {
121
+ // Clear any existing timer
122
+ if (idleTimer) {
123
+ clearTimeout(idleTimer);
124
+ idleTimer = null;
125
+ }
126
+
127
+ // Reset waiting state only if we're not waiting for loading animation
128
+ if (!isWaitingForLoadingAnimation) {
129
+ isWaitingForInput = false;
130
+ }
131
+
132
+ // Update last output time
133
+ lastOutputTime = Date.now();
134
+
135
+ // Only start a new timer if we've seen the loading animation or not waiting for it
136
+ if (
137
+ !isWaitingForLoadingAnimation
138
+ || seenLoadingChars.size === UNIQUE_LOADING_CHARS.size
139
+ ) {
140
+ idleTimer = setTimeout(() => {
141
+ onIdleDetected();
142
+ }, IDLE_THRESHOLD);
143
+ }
144
+ }
145
+
146
+ function onIdleDetected() {
147
+ console.log('[IDLE] Idle detected. State:', {
148
+ isWaitingForInput,
149
+ isWaitingForLoadingAnimation,
150
+ seenLoadingCharsCount: seenLoadingChars.size,
151
+ requiredCharsCount: UNIQUE_LOADING_CHARS.size,
152
+ });
153
+
154
+ // Claude has stopped outputting for 1.5 seconds - likely waiting for input
155
+ // But only trigger if we're not waiting for loading animation or have seen all chars
156
+ if (
157
+ !isWaitingForInput
158
+ && (!isWaitingForLoadingAnimation
159
+ || seenLoadingChars.size === UNIQUE_LOADING_CHARS.size)
160
+ ) {
161
+ isWaitingForInput = true;
162
+ console.log('[IDLE] ✓ Triggering input needed notification');
163
+
164
+ // Check cooldown to avoid spamming notifications
165
+ const now = Date.now();
166
+ if (now - lastNotificationTime > NOTIFICATION_COOLDOWN) {
167
+ lastNotificationTime = now;
168
+
169
+ // Check if sound is enabled
170
+ const soundEnabled = document.getElementById('soundEnabled').checked;
171
+
172
+ // Play notification sound if enabled
173
+ if (soundEnabled && notificationSound) {
174
+ try {
175
+ // Resume audio context if suspended (browser requirement)
176
+ if (audioContext && audioContext.state === 'suspended') {
177
+ audioContext.resume();
178
+ }
179
+ notificationSound();
180
+ }
181
+ catch (error) {
182
+ console.error('Failed to play notification sound:', error);
183
+ }
184
+ }
185
+
186
+ // Show permanent visual notification
187
+ document.body.classList.add('input-needed');
188
+
189
+ // Update status bar
190
+ updateStatus('connected', '⚠️ Waiting for input');
191
+
192
+ // Update page title
193
+ if (!originalPageTitle) {
194
+ originalPageTitle = document.title;
195
+ }
196
+ document.title = `⚠️ Input needed - ${originalPageTitle}`;
197
+
198
+ // Trigger file sync
199
+ if (socket && containerId) {
200
+ console.log('[SYNC] Triggering file sync due to input needed...');
201
+ console.log('[SYNC] Container ID:', containerId);
202
+ console.log('[SYNC] Socket connected:', socket.connected);
203
+ console.log('[SYNC] Socket ID:', socket.id);
204
+
205
+ // Test the socket connection first
206
+ socket.emit('test-sync', { message: 'testing sync connection' });
207
+
208
+ // Emit the actual event and log it
209
+ socket.emit('input-needed', { containerId });
210
+ console.log('[SYNC] Event emitted successfully');
211
+
212
+ // Set a timeout to check if we get a response
213
+ setTimeout(() => {
214
+ console.log('[SYNC] 5 seconds passed, checking if sync completed...');
215
+ }, 5000);
216
+ }
217
+ else {
218
+ console.log(
219
+ '[SYNC] Cannot trigger sync - socket:',
220
+ !!socket,
221
+ 'containerId:',
222
+ !!containerId,
223
+ );
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ // Check if output contains loading characters
230
+ function checkForLoadingChars(text) {
231
+ // Strip ANSI escape sequences to get plain text
232
+ // This regex handles color codes, cursor movements, and other escape sequences
233
+ const stripAnsi = str =>
234
+ str.replace(
235
+ /[\x1B\x9B][[()#;?]*(?:\d{1,4}(?:;\d{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
236
+ '',
237
+ );
238
+ const plainText = stripAnsi(text);
239
+
240
+ const foundChars = [];
241
+ // Check both the original text and stripped text
242
+ const textsToCheck = [text, plainText];
243
+
244
+ for (const textToCheck of textsToCheck) {
245
+ for (const char of textToCheck) {
246
+ if (LOADING_CHARS.includes(char)) {
247
+ seenLoadingChars.add(char);
248
+ foundChars.push(char);
249
+ }
250
+ }
251
+ }
252
+
253
+ if (foundChars.length > 0) {
254
+ console.log(
255
+ `[LOADING] Found loading chars: ${foundChars.join(', ')} | Total seen: ${Array.from(seenLoadingChars).join(', ')} (${seenLoadingChars.size}/${UNIQUE_LOADING_CHARS.size})`,
256
+ );
257
+
258
+ // Debug: show hex values if we're missing chars
259
+ if (seenLoadingChars.size < UNIQUE_LOADING_CHARS.size && text.length < 50) {
260
+ const hexView = Array.from(text)
261
+ .map(c => `${c}(${c.charCodeAt(0).toString(16)})`)
262
+ .join(' ');
263
+ console.log(`[LOADING] Hex view: ${hexView}`);
264
+ }
265
+ }
266
+
267
+ // If we've seen all unique loading chars, we can stop waiting
268
+ if (
269
+ seenLoadingChars.size === UNIQUE_LOADING_CHARS.size
270
+ && isWaitingForLoadingAnimation
271
+ ) {
272
+ console.log(
273
+ '[LOADING] ✓ Seen all loading characters, Claude has started processing',
274
+ );
275
+ isWaitingForLoadingAnimation = false;
276
+ // Reset the idle timer now that we know Claude is processing
277
+ resetIdleTimer();
278
+ }
279
+ }
280
+
281
+ // Get container ID from URL only
282
+ const urlParams = new URLSearchParams(window.location.search);
283
+ containerId = urlParams.get('container');
284
+
285
+ // Initialize the terminal
286
+ function initTerminal() {
287
+ term = new Terminal({
288
+ cursorBlink: true,
289
+ fontSize: 14,
290
+ fontFamily: 'Consolas, "Courier New", monospace',
291
+ theme: {
292
+ background: '#1e1e1e',
293
+ foreground: '#d4d4d4',
294
+ cursor: '#d4d4d4',
295
+ black: '#000000',
296
+ red: '#cd3131',
297
+ green: '#0dbc79',
298
+ yellow: '#e5e510',
299
+ blue: '#2472c8',
300
+ magenta: '#bc3fbc',
301
+ cyan: '#11a8cd',
302
+ white: '#e5e5e5',
303
+ brightBlack: '#666666',
304
+ brightRed: '#f14c4c',
305
+ brightGreen: '#23d18b',
306
+ brightYellow: '#f5f543',
307
+ brightBlue: '#3b8eea',
308
+ brightMagenta: '#d670d6',
309
+ brightCyan: '#29b8db',
310
+ brightWhite: '#e5e5e5',
311
+ },
312
+ allowProposedApi: true,
313
+ });
314
+
315
+ // Load addons
316
+ fitAddon = new FitAddon.FitAddon();
317
+ webLinksAddon = new WebLinksAddon.WebLinksAddon();
318
+
319
+ term.loadAddon(fitAddon);
320
+ term.loadAddon(webLinksAddon);
321
+
322
+ // Open terminal in the DOM
323
+ term.open(document.getElementById('terminal'));
324
+
325
+ // Fit terminal to container
326
+ fitAddon.fit();
327
+
328
+ // Handle window resize
329
+ window.addEventListener('resize', () => {
330
+ fitAddon.fit();
331
+ if (socket && socket.connected) {
332
+ socket.emit('resize', {
333
+ cols: term.cols,
334
+ rows: term.rows,
335
+ });
336
+ }
337
+ });
338
+
339
+ // Handle terminal input
340
+ term.onData((data) => {
341
+ if (socket && socket.connected) {
342
+ socket.emit('input', data);
343
+
344
+ // Cancel idle timer when user provides input
345
+ if (idleTimer) {
346
+ clearTimeout(idleTimer);
347
+ idleTimer = null;
348
+ }
349
+
350
+ // When user provides input, start waiting for loading animation
351
+ if (isWaitingForInput) {
352
+ isWaitingForInput = false;
353
+ isWaitingForLoadingAnimation = true;
354
+ seenLoadingChars.clear(); // Clear seen loading chars
355
+ console.log(
356
+ '[STATE] User provided input, waiting for loading animation...',
357
+ );
358
+ console.log(
359
+ '[STATE] Need to see these chars:',
360
+ Array.from(UNIQUE_LOADING_CHARS).join(', '),
361
+ );
362
+
363
+ // Clear the input-needed visual state
364
+ document.body.classList.remove('input-needed');
365
+
366
+ // Reset title
367
+ if (originalPageTitle) {
368
+ document.title = originalPageTitle;
369
+ }
370
+
371
+ // Update status
372
+ updateStatus(
373
+ 'connected',
374
+ `Connected to ${containerId.substring(0, 12)}`,
375
+ );
376
+ }
377
+ }
378
+ });
379
+
380
+ // Show welcome message
381
+ term.writeln('\x1B[1;32mWelcome to Claude Code Runner Terminal\x1B[0m');
382
+ term.writeln('\x1B[90mConnecting to container...\x1B[0m');
383
+ term.writeln('');
384
+
385
+ // Auto-focus the terminal
386
+ term.focus();
387
+ }
388
+
389
+ // Initialize Socket.IO connection
390
+ function initSocket() {
391
+ socket = io();
392
+ window.socket = socket; // Make it globally accessible for debugging
393
+
394
+ socket.on('connect', () => {
395
+ console.log('Connected to server');
396
+ updateStatus('connecting', 'Attaching to container...');
397
+
398
+ // Hide loading spinner
399
+ document.getElementById('loading').style.display = 'none';
400
+
401
+ // Only use container ID from URL, never from cache
402
+ const urlParams = new URLSearchParams(window.location.search);
403
+ const currentContainerId = urlParams.get('container');
404
+
405
+ if (currentContainerId) {
406
+ containerId = currentContainerId;
407
+ socket.emit('attach', {
408
+ containerId: currentContainerId,
409
+ cols: term.cols,
410
+ rows: term.rows,
411
+ });
412
+ }
413
+ else {
414
+ // No container ID in URL, fetch available containers
415
+ fetchContainerList();
416
+ }
417
+ });
418
+
419
+ socket.on('attached', (data) => {
420
+ console.log('Attached to container:', data.containerId);
421
+ containerId = data.containerId;
422
+ updateStatus(
423
+ 'connected',
424
+ `Connected to ${data.containerId.substring(0, 12)}`,
425
+ );
426
+
427
+ // Don't clear terminal on attach - preserve existing content
428
+
429
+ // Send initial resize
430
+ socket.emit('resize', {
431
+ cols: term.cols,
432
+ rows: term.rows,
433
+ });
434
+
435
+ // Start idle detection
436
+ resetIdleTimer();
437
+
438
+ // Focus terminal when attached
439
+ if (term) {
440
+ term.focus();
441
+ }
442
+
443
+ // Fetch git info for this container
444
+ fetchGitInfo();
445
+ });
446
+
447
+ socket.on('output', (data) => {
448
+ // Convert ArrayBuffer to Uint8Array if needed
449
+ if (data instanceof ArrayBuffer) {
450
+ data = new Uint8Array(data);
451
+ }
452
+ term.write(data);
453
+
454
+ // Convert to string to check for loading characters
455
+ const decoder = new TextDecoder('utf-8');
456
+ const text = decoder.decode(data);
457
+
458
+ // Check for loading characters if we're waiting for them
459
+ if (isWaitingForLoadingAnimation) {
460
+ checkForLoadingChars(text);
461
+ }
462
+ else if (text.length > 0) {
463
+ // Check if loading chars are present in either raw or stripped text
464
+ const stripAnsi = str =>
465
+ str.replace(
466
+ /[\x1B\x9B][[()#;?]*(?:\d{1,4}(?:;\d{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
467
+ '',
468
+ );
469
+ const plainText = stripAnsi(text);
470
+
471
+ const foundInRaw = LOADING_CHARS.filter(char => text.includes(char));
472
+ const foundInPlain = LOADING_CHARS.filter(char =>
473
+ plainText.includes(char),
474
+ );
475
+
476
+ if (foundInRaw.length > 0 || foundInPlain.length > 0) {
477
+ console.log('[DEBUG] Loading chars present but not tracking:', {
478
+ raw: foundInRaw.join(', '),
479
+ plain: foundInPlain.join(', '),
480
+ hasAnsi: text !== plainText,
481
+ });
482
+ }
483
+ }
484
+
485
+ // Reset idle timer on any output
486
+ resetIdleTimer();
487
+ });
488
+
489
+ socket.on('disconnect', () => {
490
+ updateStatus('error', 'Disconnected from server');
491
+ term.writeln(
492
+ '\r\n\x1B[1;31mServer connection lost. Click "Reconnect" to retry.\x1B[0m',
493
+ );
494
+
495
+ // Clear idle timer on disconnect
496
+ if (idleTimer) {
497
+ clearTimeout(idleTimer);
498
+ idleTimer = null;
499
+ }
500
+
501
+ // Clear input-needed state
502
+ document.body.classList.remove('input-needed');
503
+ if (originalPageTitle) {
504
+ document.title = originalPageTitle;
505
+ }
506
+ });
507
+
508
+ socket.on('container-disconnected', () => {
509
+ updateStatus('error', 'Container disconnected');
510
+ term.writeln(
511
+ '\r\n\x1B[1;31mContainer connection lost. Click "Reconnect" to retry.\x1B[0m',
512
+ );
513
+
514
+ // Clear idle timer on disconnect
515
+ if (idleTimer) {
516
+ clearTimeout(idleTimer);
517
+ idleTimer = null;
518
+ }
519
+
520
+ // Clear input-needed state
521
+ document.body.classList.remove('input-needed');
522
+ if (originalPageTitle) {
523
+ document.title = originalPageTitle;
524
+ }
525
+ });
526
+
527
+ socket.on('sync-complete', (data) => {
528
+ console.log('[SYNC] Sync completed:', data);
529
+ console.log('[SYNC] Has changes:', data.hasChanges);
530
+ console.log('[SYNC] Summary:', data.summary);
531
+ console.log('[SYNC] Diff data:', data.diffData);
532
+
533
+ if (data.hasChanges) {
534
+ // Keep showing container ID in status
535
+ updateStatus('connected', `Connected to ${containerId.substring(0, 12)}`);
536
+ updateChangesTab(data);
537
+
538
+ // Update file count badge with total changed files
539
+ const totalFiles = calculateTotalChangedFiles(data);
540
+ updateChangesTabBadge(totalFiles);
541
+ }
542
+ else {
543
+ updateStatus('connected', `Connected to ${containerId.substring(0, 12)}`);
544
+ clearChangesTab();
545
+ updateChangesTabBadge(0);
546
+ }
547
+ });
548
+
549
+ socket.on('sync-error', (error) => {
550
+ console.error('[SYNC] Sync error:', error);
551
+ updateStatus('error', `Sync failed: ${error.message}`);
552
+ });
553
+
554
+ // Add general error handler
555
+ socket.on('error', (error) => {
556
+ console.error('[SOCKET] Socket error:', error);
557
+ });
558
+
559
+ // Add disconnect handler with debug
560
+ socket.on('disconnect', (reason) => {
561
+ console.log('[SOCKET] Disconnected:', reason);
562
+ });
563
+
564
+ // Container error handler (keeping this for backward compatibility)
565
+ socket.on('container-error', (error) => {
566
+ console.error('[CONTAINER] Container error:', error);
567
+ updateStatus('error', `Error: ${error.message}`);
568
+ if (term && term.writeln) {
569
+ term.writeln(`\r\n\x1B[1;31mError: ${error.message}\x1B[0m`);
570
+ }
571
+
572
+ // If container not found, try to get a new one
573
+ if (error.message && error.message.includes('no such container')) {
574
+ containerId = null;
575
+
576
+ // Try to fetch available containers
577
+ setTimeout(() => {
578
+ fetchContainerList();
579
+ }, 1000);
580
+ }
581
+ });
582
+ }
583
+
584
+ // Fetch available containers
585
+ async function fetchContainerList() {
586
+ try {
587
+ const response = await fetch('/api/containers');
588
+ const containers = await response.json();
589
+
590
+ if (containers.length > 0) {
591
+ // Use the first container
592
+ containerId = containers[0].Id;
593
+ socket.emit('attach', {
594
+ containerId,
595
+ cols: term.cols,
596
+ rows: term.rows,
597
+ });
598
+ }
599
+ else {
600
+ updateStatus('error', 'No containers found');
601
+ term.writeln('\x1B[1;31mNo Claude Code Runner containers found.\x1B[0m');
602
+ term.writeln('\x1B[90mPlease start a container first.\x1B[0m');
603
+ }
604
+ }
605
+ catch (error) {
606
+ console.error('Failed to fetch containers:', error);
607
+ updateStatus('error', 'Failed to fetch containers');
608
+ }
609
+ }
610
+
611
+ // Update connection status
612
+ function updateStatus(status, text) {
613
+ const indicator = document.getElementById('status-indicator');
614
+ const statusText = document.getElementById('status-text');
615
+
616
+ indicator.className = `status-indicator ${status}`;
617
+ statusText.textContent = text;
618
+ }
619
+
620
+ // Control functions
621
+ function clearTerminal() {
622
+ term.clear();
623
+ }
624
+
625
+ function reconnect() {
626
+ if (socket && containerId) {
627
+ // Don't clear terminal - preserve existing content
628
+ term.writeln('\r\n\x1B[90mReconnecting...\x1B[0m');
629
+
630
+ // Just emit attach again without disconnecting
631
+ // This will reattach to the existing session
632
+ socket.emit('attach', {
633
+ containerId,
634
+ cols: term.cols,
635
+ rows: term.rows,
636
+ });
637
+ }
638
+ }
639
+
640
+ function copySelection() {
641
+ const selection = term.getSelection();
642
+ if (selection) {
643
+ navigator.clipboard
644
+ .writeText(selection)
645
+ .then(() => {
646
+ // Show temporary feedback
647
+ const originalText = document.getElementById('status-text').textContent;
648
+ updateStatus('connected', 'Copied to clipboard');
649
+ setTimeout(() => {
650
+ updateStatus('connected', originalText);
651
+ }, 2000);
652
+ })
653
+ .catch((err) => {
654
+ console.error('Failed to copy:', err);
655
+ });
656
+ }
657
+ }
658
+
659
+ // Git info functions
660
+ async function fetchGitInfo() {
661
+ try {
662
+ // Use container ID if available to get branch from shadow repo
663
+ const url = containerId
664
+ ? `/api/git/info?containerId=${containerId}`
665
+ : '/api/git/info';
666
+ const response = await fetch(url);
667
+ if (response.ok) {
668
+ const data = await response.json();
669
+ updateGitInfo(data);
670
+ }
671
+ else {
672
+ console.error('Failed to fetch git info:', response.statusText);
673
+ }
674
+ }
675
+ catch (error) {
676
+ console.error('Error fetching git info:', error);
677
+ }
678
+ }
679
+
680
+ function updateGitInfo(data) {
681
+ const gitInfoElement = document.getElementById('git-info');
682
+ const branchNameElement = document.getElementById('branch-name');
683
+ const prInfoElement = document.getElementById('pr-info');
684
+
685
+ if (data.currentBranch) {
686
+ // Clear existing content
687
+ branchNameElement.innerHTML = '';
688
+
689
+ if (data.branchUrl) {
690
+ // Create clickable branch link
691
+ const branchLink = document.createElement('a');
692
+ branchLink.href = data.branchUrl;
693
+ branchLink.target = '_blank';
694
+ branchLink.textContent = data.currentBranch;
695
+ branchLink.style.color = 'inherit';
696
+ branchLink.style.textDecoration = 'none';
697
+ branchLink.title = `View ${data.currentBranch} branch on GitHub`;
698
+ branchLink.addEventListener('mouseenter', () => {
699
+ branchLink.style.textDecoration = 'underline';
700
+ });
701
+ branchLink.addEventListener('mouseleave', () => {
702
+ branchLink.style.textDecoration = 'none';
703
+ });
704
+ branchNameElement.appendChild(branchLink);
705
+ }
706
+ else {
707
+ // Fallback to plain text
708
+ branchNameElement.textContent = data.currentBranch;
709
+ }
710
+
711
+ gitInfoElement.style.display = 'inline-block';
712
+ }
713
+
714
+ // Clear existing PR info
715
+ prInfoElement.innerHTML = '';
716
+
717
+ if (data.prs && data.prs.length > 0) {
718
+ data.prs.forEach((pr) => {
719
+ const prBadge = document.createElement('a');
720
+ prBadge.className = 'pr-badge';
721
+ prBadge.href = pr.url;
722
+ prBadge.target = '_blank';
723
+ prBadge.title = pr.title;
724
+
725
+ // Set badge class based on state
726
+ if (pr.isDraft) {
727
+ prBadge.classList.add('draft');
728
+ prBadge.textContent = `Draft PR #${pr.number}`;
729
+ }
730
+ else if (pr.state === 'OPEN') {
731
+ prBadge.classList.add('open');
732
+ prBadge.textContent = `PR #${pr.number}`;
733
+ }
734
+ else if (pr.state === 'CLOSED') {
735
+ prBadge.classList.add('closed');
736
+ prBadge.textContent = `Closed PR #${pr.number}`;
737
+ }
738
+ else if (pr.state === 'MERGED') {
739
+ prBadge.classList.add('merged');
740
+ prBadge.textContent = `Merged PR #${pr.number}`;
741
+ }
742
+
743
+ prInfoElement.appendChild(prBadge);
744
+ });
745
+ }
746
+ }
747
+
748
+ // Initialize everything when DOM is ready
749
+ document.addEventListener('DOMContentLoaded', () => {
750
+ // Store original page title
751
+ originalPageTitle = document.title;
752
+
753
+ initTerminal();
754
+ initSocket();
755
+
756
+ // Fetch git info on load
757
+ fetchGitInfo();
758
+
759
+ // Refresh git info periodically
760
+ setInterval(fetchGitInfo, 30000); // Every 30 seconds
761
+
762
+ // Initialize audio on first user interaction (browser requirement)
763
+ document.addEventListener(
764
+ 'click',
765
+ function initAudioOnInteraction() {
766
+ if (!audioContext) {
767
+ initializeAudio();
768
+ }
769
+ // Remove listener after first interaction
770
+ document.removeEventListener('click', initAudioOnInteraction);
771
+ },
772
+ { once: true },
773
+ );
774
+
775
+ // Also try to initialize on keyboard interaction
776
+ document.addEventListener(
777
+ 'keydown',
778
+ function initAudioOnKeyboard() {
779
+ if (!audioContext) {
780
+ initializeAudio();
781
+ }
782
+ // Remove listener after first interaction
783
+ document.removeEventListener('keydown', initAudioOnKeyboard);
784
+ },
785
+ { once: true },
786
+ );
787
+
788
+ // Expose variables for testing with getters
789
+ Object.defineProperty(window, 'term', { get: () => term });
790
+ Object.defineProperty(window, 'isWaitingForInput', {
791
+ get: () => isWaitingForInput,
792
+ });
793
+ Object.defineProperty(window, 'isWaitingForLoadingAnimation', {
794
+ get: () => isWaitingForLoadingAnimation,
795
+ });
796
+ Object.defineProperty(window, 'seenLoadingChars', {
797
+ get: () => seenLoadingChars,
798
+ });
799
+ Object.defineProperty(window, 'lastOutputTime', {
800
+ get: () => lastOutputTime,
801
+ });
802
+ Object.defineProperty(window, 'lastNotificationTime', {
803
+ get: () => lastNotificationTime,
804
+ });
805
+ Object.defineProperty(window, 'audioContext', { get: () => audioContext });
806
+ Object.defineProperty(window, 'notificationSound', {
807
+ get: () => notificationSound,
808
+ set: (value) => {
809
+ notificationSound = value;
810
+ },
811
+ });
812
+ });
813
+
814
+ // Calculate total changed files from sync data
815
+ function calculateTotalChangedFiles(syncData) {
816
+ if (!syncData.diffData || !syncData.diffData.status)
817
+ return 0;
818
+
819
+ // Count unique files from git status
820
+ const statusLines = syncData.diffData.status
821
+ .split('\n')
822
+ .filter(line => line.trim());
823
+ const uniqueFiles = new Set();
824
+
825
+ statusLines.forEach((line) => {
826
+ if (line.trim()) {
827
+ const filename = line.substring(3).trim();
828
+ if (filename) {
829
+ uniqueFiles.add(filename);
830
+ }
831
+ }
832
+ });
833
+
834
+ return uniqueFiles.size;
835
+ }
836
+
837
+ // Update changes tab badge
838
+ function updateChangesTabBadge(fileCount) {
839
+ const changesTab = document.getElementById('changes-tab');
840
+ if (!changesTab)
841
+ return;
842
+
843
+ // Remove existing badge
844
+ const existingBadge = changesTab.querySelector('.file-count-badge');
845
+ if (existingBadge) {
846
+ existingBadge.remove();
847
+ }
848
+
849
+ // Add new badge if there are changes
850
+ if (fileCount > 0) {
851
+ const badge = document.createElement('span');
852
+ badge.className = 'file-count-badge';
853
+ badge.textContent = fileCount.toString();
854
+ changesTab.appendChild(badge);
855
+ }
856
+ }
857
+
858
+ // Tab system functions
859
+ function switchTab(tabName) {
860
+ // Remove active class from all tabs and content
861
+ document
862
+ .querySelectorAll('.tab')
863
+ .forEach(tab => tab.classList.remove('active'));
864
+ document
865
+ .querySelectorAll('.tab-content')
866
+ .forEach(content => content.classList.remove('active'));
867
+
868
+ // Add active class to selected tab and content
869
+ document.getElementById(`${tabName}-tab`).classList.add('active');
870
+ document.getElementById(`${tabName}-content`).classList.add('active');
871
+
872
+ // Tab switching handled by active class now
873
+
874
+ // Resize terminal if switching back to terminal tab
875
+ if (tabName === 'terminal' && term && term.fit) {
876
+ setTimeout(() => term.fit(), 100);
877
+ }
878
+ }
879
+
880
+ // Git workflow functions for tab system
881
+ function updateChangesTab(syncData) {
882
+ console.log('[UI] updateChangesTab called with:', syncData);
883
+
884
+ const container = document.getElementById('changes-container');
885
+
886
+ if (!container) {
887
+ console.error('[UI] changes-container not found!');
888
+ return;
889
+ }
890
+
891
+ // Clear existing content
892
+ container.innerHTML = '';
893
+
894
+ // Create changes content
895
+ const diffStats = syncData.diffData?.stats || {
896
+ additions: 0,
897
+ deletions: 0,
898
+ files: 0,
899
+ };
900
+ const statsText
901
+ = diffStats.files > 0
902
+ ? `${diffStats.files} file(s), +${diffStats.additions} -${diffStats.deletions}`
903
+ : 'No changes';
904
+
905
+ container.innerHTML = `
906
+ <div class="changes-summary">
907
+ <strong>Changes Summary:</strong> ${syncData.summary}
908
+ <div class="diff-stats">📊 ${statsText}</div>
909
+ </div>
910
+
911
+ <div class="diff-viewer">
912
+ ${formatDiffForDisplay(syncData.diffData)}
913
+ </div>
914
+
915
+ <div class="git-actions">
916
+ <h3>💾 Commit Changes</h3>
917
+ <textarea
918
+ id="commit-message"
919
+ placeholder="Enter commit message..."
920
+ rows="3"
921
+ >Update files from Claude
922
+
923
+ ${syncData.summary}</textarea>
924
+
925
+ <div style="margin-bottom: 15px;">
926
+ <button onclick="commitChanges('${syncData.containerId}')" class="btn btn-primary" id="commit-btn">
927
+ Commit Changes
928
+ </button>
929
+ </div>
930
+ </div>
931
+
932
+ <div class="git-actions" id="push-section" style="display: none;">
933
+ <h3>🚀 Push to Remote</h3>
934
+ <div class="branch-input">
935
+ <label for="branch-name">Branch name:</label>
936
+ <input type="text" id="branch-name" placeholder="claude-changes" value="claude-changes">
937
+ </div>
938
+ <div>
939
+ <button onclick="pushChanges('${syncData.containerId}')" class="btn btn-success" id="push-btn">
940
+ Push to Remote
941
+ </button>
942
+ </div>
943
+ </div>
944
+ `;
945
+
946
+ // Store sync data for later use
947
+ window.currentSyncData = syncData;
948
+ }
949
+
950
+ function clearChangesTab() {
951
+ const container = document.getElementById('changes-container');
952
+ const noChanges = document.getElementById('no-changes');
953
+
954
+ // Show empty state
955
+ noChanges.style.display = 'block';
956
+
957
+ // Clear changes content but keep the empty state
958
+ container.innerHTML = `
959
+ <div class="empty-state" id="no-changes">
960
+ <h3>No changes detected</h3>
961
+ <p>Claude hasn't made any changes yet. Changes will appear here automatically when Claude modifies files.</p>
962
+ </div>
963
+ `;
964
+
965
+ // Remove badge
966
+ updateChangesTabBadge(0);
967
+ }
968
+
969
+ function formatDiffForDisplay(diffData) {
970
+ if (!diffData)
971
+ return '<div class="diff-line context">No changes to display</div>';
972
+
973
+ const lines = [];
974
+
975
+ // Show file status
976
+ if (diffData.status) {
977
+ lines.push('<div class="diff-line header">📄 File Status:</div>');
978
+ diffData.status.split('\n').forEach((line) => {
979
+ if (line.trim()) {
980
+ const status = line.substring(0, 2);
981
+ const filename = line.substring(3);
982
+ let statusText = '';
983
+ if (status === '??')
984
+ statusText = 'New file';
985
+ else if (status === ' M' || status === 'M ' || status === 'MM')
986
+ statusText = 'Modified';
987
+ else if (status === ' D' || status === 'D ')
988
+ statusText = 'Deleted';
989
+ else if (status === 'A ' || status === 'AM')
990
+ statusText = 'Added';
991
+ else statusText = `Status: ${status}`;
992
+
993
+ lines.push(
994
+ `<div class="diff-line context"> ${statusText}: ${filename}</div>`,
995
+ );
996
+ }
997
+ });
998
+ lines.push('<div class="diff-line context"></div>');
999
+ }
1000
+
1001
+ // Show diff
1002
+ if (diffData.diff) {
1003
+ lines.push('<div class="diff-line header">📝 Changes:</div>');
1004
+ diffData.diff.split('\n').forEach((line) => {
1005
+ let className = 'context';
1006
+ if (line.startsWith('+'))
1007
+ className = 'added';
1008
+ else if (line.startsWith('-'))
1009
+ className = 'removed';
1010
+ else if (line.startsWith('@@'))
1011
+ className = 'header';
1012
+
1013
+ lines.push(
1014
+ `<div class="diff-line ${className}">${escapeHtml(line)}</div>`,
1015
+ );
1016
+ });
1017
+ }
1018
+
1019
+ // Show untracked files
1020
+ if (diffData.untrackedFiles && diffData.untrackedFiles.length > 0) {
1021
+ lines.push('<div class="diff-line context"></div>');
1022
+ lines.push('<div class="diff-line header">📁 New Files:</div>');
1023
+ diffData.untrackedFiles.forEach((filename) => {
1024
+ lines.push(`<div class="diff-line added">+ ${filename}</div>`);
1025
+ });
1026
+ }
1027
+
1028
+ return lines.join('');
1029
+ }
1030
+
1031
+ function escapeHtml(text) {
1032
+ const div = document.createElement('div');
1033
+ div.textContent = text;
1034
+ return div.innerHTML;
1035
+ }
1036
+
1037
+ function commitChanges(containerId) {
1038
+ const commitMessage = document.getElementById('commit-message').value.trim();
1039
+ if (!commitMessage) {
1040
+ alert('Please enter a commit message');
1041
+ return;
1042
+ }
1043
+
1044
+ const btn = document.getElementById('commit-btn');
1045
+ btn.disabled = true;
1046
+ btn.textContent = 'Committing...';
1047
+
1048
+ socket.emit('commit-changes', { containerId, commitMessage });
1049
+
1050
+ // Handle commit result
1051
+ socket.once('commit-success', () => {
1052
+ btn.textContent = '✓ Committed';
1053
+ btn.style.background = '#238636';
1054
+
1055
+ // Show push section
1056
+ document.getElementById('push-section').style.display = 'block';
1057
+
1058
+ updateStatus('connected', '✓ Changes committed successfully');
1059
+ });
1060
+
1061
+ socket.once('commit-error', (error) => {
1062
+ btn.disabled = false;
1063
+ btn.textContent = 'Commit Changes';
1064
+ alert(`Commit failed: ${error.message}`);
1065
+ updateStatus('error', `Commit failed: ${error.message}`);
1066
+ });
1067
+ }
1068
+
1069
+ function pushChanges(containerId) {
1070
+ const branchName
1071
+ = document.getElementById('branch-name').value.trim() || 'claude-changes';
1072
+
1073
+ const btn = document.getElementById('push-btn');
1074
+ btn.disabled = true;
1075
+ btn.textContent = 'Pushing...';
1076
+
1077
+ socket.emit('push-changes', { containerId, branchName });
1078
+
1079
+ // Handle push result
1080
+ socket.once('push-success', () => {
1081
+ btn.textContent = '✓ Pushed to GitHub';
1082
+ btn.style.background = '#238636';
1083
+ updateStatus('connected', `✓ Changes pushed to remote ${branchName}`);
1084
+
1085
+ // Clear the changes tab after successful push
1086
+ setTimeout(() => {
1087
+ clearChangesTab();
1088
+ }, 3000);
1089
+ });
1090
+
1091
+ socket.once('push-error', (error) => {
1092
+ btn.disabled = false;
1093
+ btn.textContent = 'Push to Remote';
1094
+ alert(`Push failed: ${error.message}`);
1095
+ updateStatus('error', `Push failed: ${error.message}`);
1096
+ });
1097
+ }
1098
+
1099
+ // Handle keyboard shortcuts
1100
+ document.addEventListener('keydown', (e) => {
1101
+ // Ctrl+Shift+C for copy
1102
+ if (e.ctrlKey && e.shiftKey && e.key === 'C') {
1103
+ e.preventDefault();
1104
+ copySelection();
1105
+ }
1106
+ // Ctrl+Shift+V for paste
1107
+ else if (e.ctrlKey && e.shiftKey && e.key === 'V') {
1108
+ e.preventDefault();
1109
+ navigator.clipboard.readText().then((text) => {
1110
+ if (socket && socket.connected) {
1111
+ socket.emit('input', text);
1112
+ }
1113
+ });
1114
+ }
1115
+ });