claude-code-runner 0.1.0 → 0.1.2

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,1134 @@
1
+ /* eslint-disable no-control-regex */
2
+ /* global Terminal, FitAddon, WebLinksAddon, io, I18n */
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', t('status.waitingForInput', '⚠️ Waiting for input'));
191
+
192
+ // Update page title
193
+ if (!originalPageTitle) {
194
+ originalPageTitle = t('app.title', document.title);
195
+ }
196
+ document.title = `⚠️ ${t('messages.inputNeeded', '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', t('status.noContainersFound', 'No containers found'));
601
+ term.writeln(`\x1B[1;31m${t('messages.noContainersFoundMessage', 'No Claude Code Runner containers found.')}\x1B[0m`);
602
+ term.writeln(`\x1B[90m${t('messages.startContainerFirst', 'Please start a container first.')}\x1B[0m`);
603
+ }
604
+ }
605
+ catch (error) {
606
+ console.error('Failed to fetch containers:', error);
607
+ updateStatus('error', t('status.failedToFetchContainers', '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
+ // Translate helper - returns translated text if I18n is ready, otherwise returns original
621
+ function t(key, fallback) {
622
+ if (typeof I18n !== 'undefined' && I18n.ready()) {
623
+ return I18n.t(key);
624
+ }
625
+ return fallback || key;
626
+ }
627
+
628
+ // Control functions
629
+ function clearTerminal() {
630
+ term.clear();
631
+ }
632
+
633
+ function reconnect() {
634
+ if (socket && containerId) {
635
+ // Don't clear terminal - preserve existing content
636
+ term.writeln(`\r\n\x1B[90m${t('messages.reconnecting', 'Reconnecting...')}\x1B[0m`);
637
+
638
+ // Just emit attach again without disconnecting
639
+ // This will reattach to the existing session
640
+ socket.emit('attach', {
641
+ containerId,
642
+ cols: term.cols,
643
+ rows: term.rows,
644
+ });
645
+ }
646
+ }
647
+
648
+ function copySelection() {
649
+ const selection = term.getSelection();
650
+ if (selection) {
651
+ navigator.clipboard
652
+ .writeText(selection)
653
+ .then(() => {
654
+ // Show temporary feedback
655
+ const originalText = document.getElementById('status-text').textContent;
656
+ updateStatus('connected', t('status.copiedToClipboard', 'Copied to clipboard'));
657
+ setTimeout(() => {
658
+ updateStatus('connected', originalText);
659
+ }, 2000);
660
+ })
661
+ .catch((err) => {
662
+ console.error('Failed to copy:', err);
663
+ });
664
+ }
665
+ }
666
+
667
+ // Git info functions
668
+ async function fetchGitInfo() {
669
+ try {
670
+ // Use container ID if available to get branch from shadow repo
671
+ const url = containerId
672
+ ? `/api/git/info?containerId=${containerId}`
673
+ : '/api/git/info';
674
+ const response = await fetch(url);
675
+ if (response.ok) {
676
+ const data = await response.json();
677
+ updateGitInfo(data);
678
+ }
679
+ else {
680
+ console.error('Failed to fetch git info:', response.statusText);
681
+ }
682
+ }
683
+ catch (error) {
684
+ console.error('Error fetching git info:', error);
685
+ }
686
+ }
687
+
688
+ function updateGitInfo(data) {
689
+ const gitInfoElement = document.getElementById('git-info');
690
+ const branchNameElement = document.getElementById('branch-name');
691
+ const prInfoElement = document.getElementById('pr-info');
692
+
693
+ if (data.currentBranch) {
694
+ // Clear existing content
695
+ branchNameElement.innerHTML = '';
696
+
697
+ if (data.branchUrl) {
698
+ // Create clickable branch link
699
+ const branchLink = document.createElement('a');
700
+ branchLink.href = data.branchUrl;
701
+ branchLink.target = '_blank';
702
+ branchLink.textContent = data.currentBranch;
703
+ branchLink.style.color = 'inherit';
704
+ branchLink.style.textDecoration = 'none';
705
+ branchLink.title = `View ${data.currentBranch} branch on GitHub`;
706
+ branchLink.addEventListener('mouseenter', () => {
707
+ branchLink.style.textDecoration = 'underline';
708
+ });
709
+ branchLink.addEventListener('mouseleave', () => {
710
+ branchLink.style.textDecoration = 'none';
711
+ });
712
+ branchNameElement.appendChild(branchLink);
713
+ }
714
+ else {
715
+ // Fallback to plain text
716
+ branchNameElement.textContent = data.currentBranch;
717
+ }
718
+
719
+ gitInfoElement.style.display = 'inline-block';
720
+ }
721
+
722
+ // Clear existing PR info
723
+ prInfoElement.innerHTML = '';
724
+
725
+ if (data.prs && data.prs.length > 0) {
726
+ data.prs.forEach((pr) => {
727
+ const prBadge = document.createElement('a');
728
+ prBadge.className = 'pr-badge';
729
+ prBadge.href = pr.url;
730
+ prBadge.target = '_blank';
731
+ prBadge.title = pr.title;
732
+
733
+ // Set badge class based on state
734
+ if (pr.isDraft) {
735
+ prBadge.classList.add('draft');
736
+ prBadge.textContent = `Draft PR #${pr.number}`;
737
+ }
738
+ else if (pr.state === 'OPEN') {
739
+ prBadge.classList.add('open');
740
+ prBadge.textContent = `PR #${pr.number}`;
741
+ }
742
+ else if (pr.state === 'CLOSED') {
743
+ prBadge.classList.add('closed');
744
+ prBadge.textContent = `Closed PR #${pr.number}`;
745
+ }
746
+ else if (pr.state === 'MERGED') {
747
+ prBadge.classList.add('merged');
748
+ prBadge.textContent = `Merged PR #${pr.number}`;
749
+ }
750
+
751
+ prInfoElement.appendChild(prBadge);
752
+ });
753
+ }
754
+ }
755
+
756
+ // Initialize everything when DOM is ready
757
+ document.addEventListener('DOMContentLoaded', () => {
758
+ // Store original page title
759
+ originalPageTitle = t('app.title', document.title);
760
+
761
+ window.addEventListener('languagechange', () => {
762
+ originalPageTitle = t('app.title', document.title);
763
+ if (document.body.classList.contains('input-needed')) {
764
+ document.title = `⚠️ ${t('messages.inputNeeded', 'Need Input')} - ${originalPageTitle}`;
765
+ }
766
+ });
767
+
768
+ initTerminal();
769
+ initSocket();
770
+
771
+ // Fetch git info on load
772
+ fetchGitInfo();
773
+
774
+ // Refresh git info periodically
775
+ setInterval(fetchGitInfo, 30000); // Every 30 seconds
776
+
777
+ // Initialize audio on first user interaction (browser requirement)
778
+ document.addEventListener(
779
+ 'click',
780
+ function initAudioOnInteraction() {
781
+ if (!audioContext) {
782
+ initializeAudio();
783
+ }
784
+ // Remove listener after first interaction
785
+ document.removeEventListener('click', initAudioOnInteraction);
786
+ },
787
+ { once: true },
788
+ );
789
+
790
+ // Also try to initialize on keyboard interaction
791
+ document.addEventListener(
792
+ 'keydown',
793
+ function initAudioOnKeyboard() {
794
+ if (!audioContext) {
795
+ initializeAudio();
796
+ }
797
+ // Remove listener after first interaction
798
+ document.removeEventListener('keydown', initAudioOnKeyboard);
799
+ },
800
+ { once: true },
801
+ );
802
+
803
+ // Expose variables for testing with getters
804
+ Object.defineProperty(window, 'term', { get: () => term });
805
+ Object.defineProperty(window, 'isWaitingForInput', {
806
+ get: () => isWaitingForInput,
807
+ });
808
+ Object.defineProperty(window, 'isWaitingForLoadingAnimation', {
809
+ get: () => isWaitingForLoadingAnimation,
810
+ });
811
+ Object.defineProperty(window, 'seenLoadingChars', {
812
+ get: () => seenLoadingChars,
813
+ });
814
+ Object.defineProperty(window, 'lastOutputTime', {
815
+ get: () => lastOutputTime,
816
+ });
817
+ Object.defineProperty(window, 'lastNotificationTime', {
818
+ get: () => lastNotificationTime,
819
+ });
820
+ Object.defineProperty(window, 'audioContext', { get: () => audioContext });
821
+ Object.defineProperty(window, 'notificationSound', {
822
+ get: () => notificationSound,
823
+ set: (value) => {
824
+ notificationSound = value;
825
+ },
826
+ });
827
+ });
828
+
829
+ // Calculate total changed files from sync data
830
+ function calculateTotalChangedFiles(syncData) {
831
+ if (!syncData.diffData || !syncData.diffData.status)
832
+ return 0;
833
+
834
+ // Count unique files from git status
835
+ const statusLines = syncData.diffData.status
836
+ .split('\n')
837
+ .filter(line => line.trim());
838
+ const uniqueFiles = new Set();
839
+
840
+ statusLines.forEach((line) => {
841
+ if (line.trim()) {
842
+ const filename = line.substring(3).trim();
843
+ if (filename) {
844
+ uniqueFiles.add(filename);
845
+ }
846
+ }
847
+ });
848
+
849
+ return uniqueFiles.size;
850
+ }
851
+
852
+ // Update changes tab badge
853
+ function updateChangesTabBadge(fileCount) {
854
+ const changesTab = document.getElementById('changes-tab');
855
+ if (!changesTab)
856
+ return;
857
+
858
+ // Remove existing badge
859
+ const existingBadge = changesTab.querySelector('.file-count-badge');
860
+ if (existingBadge) {
861
+ existingBadge.remove();
862
+ }
863
+
864
+ // Add new badge if there are changes
865
+ if (fileCount > 0) {
866
+ const badge = document.createElement('span');
867
+ badge.className = 'file-count-badge';
868
+ badge.textContent = fileCount.toString();
869
+ changesTab.appendChild(badge);
870
+ }
871
+ }
872
+
873
+ // Tab system functions
874
+ function switchTab(tabName) {
875
+ // Remove active class from all tabs and content
876
+ document
877
+ .querySelectorAll('.tab')
878
+ .forEach(tab => tab.classList.remove('active'));
879
+ document
880
+ .querySelectorAll('.tab-content')
881
+ .forEach(content => content.classList.remove('active'));
882
+
883
+ // Add active class to selected tab and content
884
+ document.getElementById(`${tabName}-tab`).classList.add('active');
885
+ document.getElementById(`${tabName}-content`).classList.add('active');
886
+
887
+ // Tab switching handled by active class now
888
+
889
+ // Resize terminal if switching back to terminal tab
890
+ if (tabName === 'terminal' && term && term.fit) {
891
+ setTimeout(() => term.fit(), 100);
892
+ }
893
+ }
894
+
895
+ // Git workflow functions for tab system
896
+ function updateChangesTab(syncData) {
897
+ console.log('[UI] updateChangesTab called with:', syncData);
898
+
899
+ const container = document.getElementById('changes-container');
900
+
901
+ if (!container) {
902
+ console.error('[UI] changes-container not found!');
903
+ return;
904
+ }
905
+
906
+ // Clear existing content
907
+ container.innerHTML = '';
908
+
909
+ // Create changes content
910
+ const diffStats = syncData.diffData?.stats || {
911
+ additions: 0,
912
+ deletions: 0,
913
+ files: 0,
914
+ };
915
+ const statsText
916
+ = diffStats.files > 0
917
+ ? `${diffStats.files} file(s), +${diffStats.additions} -${diffStats.deletions}`
918
+ : 'No changes';
919
+
920
+ container.innerHTML = `
921
+ <div class="changes-summary">
922
+ <strong>Changes Summary:</strong> ${syncData.summary}
923
+ <div class="diff-stats">📊 ${statsText}</div>
924
+ </div>
925
+
926
+ <div class="diff-viewer">
927
+ ${formatDiffForDisplay(syncData.diffData)}
928
+ </div>
929
+
930
+ <div class="git-actions">
931
+ <h3>💾 Commit Changes</h3>
932
+ <textarea
933
+ id="commit-message"
934
+ placeholder="Enter commit message..."
935
+ rows="3"
936
+ >Update files from Claude
937
+
938
+ ${syncData.summary}</textarea>
939
+
940
+ <div style="margin-bottom: 15px;">
941
+ <button onclick="commitChanges('${syncData.containerId}')" class="btn btn-primary" id="commit-btn">
942
+ Commit Changes
943
+ </button>
944
+ </div>
945
+ </div>
946
+
947
+ <div class="git-actions" id="push-section" style="display: none;">
948
+ <h3>🚀 Push to Remote</h3>
949
+ <div class="branch-input">
950
+ <label for="branch-name">Branch name:</label>
951
+ <input type="text" id="branch-name" placeholder="claude-changes" value="claude-changes">
952
+ </div>
953
+ <div>
954
+ <button onclick="pushChanges('${syncData.containerId}')" class="btn btn-success" id="push-btn">
955
+ Push to Remote
956
+ </button>
957
+ </div>
958
+ </div>
959
+ `;
960
+
961
+ // Store sync data for later use
962
+ window.currentSyncData = syncData;
963
+ }
964
+
965
+ function clearChangesTab() {
966
+ const container = document.getElementById('changes-container');
967
+ const noChanges = document.getElementById('no-changes');
968
+
969
+ // Show empty state
970
+ noChanges.style.display = 'block';
971
+
972
+ // Clear changes content but keep the empty state
973
+ container.innerHTML = `
974
+ <div class="empty-state" id="no-changes">
975
+ <h3 data-i18n="changes.noChangesTitle">${t('changes.noChangesTitle', 'No changes detected')}</h3>
976
+ <p data-i18n="changes.noChangesDescription">${t('changes.noChangesDescription', 'Claude hasn\'t made any changes yet. Changes will appear here automatically when Claude modifies files.')}</p>
977
+ </div>
978
+ `;
979
+
980
+ if (typeof I18n !== 'undefined' && I18n.ready()) {
981
+ I18n.updateDOM();
982
+ }
983
+
984
+ // Remove badge
985
+ updateChangesTabBadge(0);
986
+ }
987
+
988
+ function formatDiffForDisplay(diffData) {
989
+ if (!diffData)
990
+ return '<div class="diff-line context">No changes to display</div>';
991
+
992
+ const lines = [];
993
+
994
+ // Show file status
995
+ if (diffData.status) {
996
+ lines.push('<div class="diff-line header">📄 File Status:</div>');
997
+ diffData.status.split('\n').forEach((line) => {
998
+ if (line.trim()) {
999
+ const status = line.substring(0, 2);
1000
+ const filename = line.substring(3);
1001
+ let statusText = '';
1002
+ if (status === '??')
1003
+ statusText = 'New file';
1004
+ else if (status === ' M' || status === 'M ' || status === 'MM')
1005
+ statusText = 'Modified';
1006
+ else if (status === ' D' || status === 'D ')
1007
+ statusText = 'Deleted';
1008
+ else if (status === 'A ' || status === 'AM')
1009
+ statusText = 'Added';
1010
+ else statusText = `Status: ${status}`;
1011
+
1012
+ lines.push(
1013
+ `<div class="diff-line context"> ${statusText}: ${filename}</div>`,
1014
+ );
1015
+ }
1016
+ });
1017
+ lines.push('<div class="diff-line context"></div>');
1018
+ }
1019
+
1020
+ // Show diff
1021
+ if (diffData.diff) {
1022
+ lines.push('<div class="diff-line header">📝 Changes:</div>');
1023
+ diffData.diff.split('\n').forEach((line) => {
1024
+ let className = 'context';
1025
+ if (line.startsWith('+'))
1026
+ className = 'added';
1027
+ else if (line.startsWith('-'))
1028
+ className = 'removed';
1029
+ else if (line.startsWith('@@'))
1030
+ className = 'header';
1031
+
1032
+ lines.push(
1033
+ `<div class="diff-line ${className}">${escapeHtml(line)}</div>`,
1034
+ );
1035
+ });
1036
+ }
1037
+
1038
+ // Show untracked files
1039
+ if (diffData.untrackedFiles && diffData.untrackedFiles.length > 0) {
1040
+ lines.push('<div class="diff-line context"></div>');
1041
+ lines.push('<div class="diff-line header">📁 New Files:</div>');
1042
+ diffData.untrackedFiles.forEach((filename) => {
1043
+ lines.push(`<div class="diff-line added">+ ${filename}</div>`);
1044
+ });
1045
+ }
1046
+
1047
+ return lines.join('');
1048
+ }
1049
+
1050
+ function escapeHtml(text) {
1051
+ const div = document.createElement('div');
1052
+ div.textContent = text;
1053
+ return div.innerHTML;
1054
+ }
1055
+
1056
+ function commitChanges(containerId) {
1057
+ const commitMessage = document.getElementById('commit-message').value.trim();
1058
+ if (!commitMessage) {
1059
+ alert('Please enter a commit message');
1060
+ return;
1061
+ }
1062
+
1063
+ const btn = document.getElementById('commit-btn');
1064
+ btn.disabled = true;
1065
+ btn.textContent = 'Committing...';
1066
+
1067
+ socket.emit('commit-changes', { containerId, commitMessage });
1068
+
1069
+ // Handle commit result
1070
+ socket.once('commit-success', () => {
1071
+ btn.textContent = '✓ Committed';
1072
+ btn.style.background = '#238636';
1073
+
1074
+ // Show push section
1075
+ document.getElementById('push-section').style.display = 'block';
1076
+
1077
+ updateStatus('connected', '✓ Changes committed successfully');
1078
+ });
1079
+
1080
+ socket.once('commit-error', (error) => {
1081
+ btn.disabled = false;
1082
+ btn.textContent = 'Commit Changes';
1083
+ alert(`Commit failed: ${error.message}`);
1084
+ updateStatus('error', `Commit failed: ${error.message}`);
1085
+ });
1086
+ }
1087
+
1088
+ function pushChanges(containerId) {
1089
+ const branchName
1090
+ = document.getElementById('branch-name').value.trim() || 'claude-changes';
1091
+
1092
+ const btn = document.getElementById('push-btn');
1093
+ btn.disabled = true;
1094
+ btn.textContent = 'Pushing...';
1095
+
1096
+ socket.emit('push-changes', { containerId, branchName });
1097
+
1098
+ // Handle push result
1099
+ socket.once('push-success', () => {
1100
+ btn.textContent = '✓ Pushed to GitHub';
1101
+ btn.style.background = '#238636';
1102
+ updateStatus('connected', `✓ Changes pushed to remote ${branchName}`);
1103
+
1104
+ // Clear the changes tab after successful push
1105
+ setTimeout(() => {
1106
+ clearChangesTab();
1107
+ }, 3000);
1108
+ });
1109
+
1110
+ socket.once('push-error', (error) => {
1111
+ btn.disabled = false;
1112
+ btn.textContent = 'Push to Remote';
1113
+ alert(`Push failed: ${error.message}`);
1114
+ updateStatus('error', `Push failed: ${error.message}`);
1115
+ });
1116
+ }
1117
+
1118
+ // Handle keyboard shortcuts
1119
+ document.addEventListener('keydown', (e) => {
1120
+ // Ctrl+Shift+C for copy
1121
+ if (e.ctrlKey && e.shiftKey && e.key === 'C') {
1122
+ e.preventDefault();
1123
+ copySelection();
1124
+ }
1125
+ // Ctrl+Shift+V for paste
1126
+ else if (e.ctrlKey && e.shiftKey && e.key === 'V') {
1127
+ e.preventDefault();
1128
+ navigator.clipboard.readText().then((text) => {
1129
+ if (socket && socket.connected) {
1130
+ socket.emit('input', text);
1131
+ }
1132
+ });
1133
+ }
1134
+ });