claude-remote-cli 2.2.2 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js DELETED
@@ -1,1953 +0,0 @@
1
- (function () {
2
- 'use strict';
3
-
4
- // State
5
- var activeSessionId = null;
6
- var ws = null;
7
- var term = null;
8
- var fitAddon = null;
9
- var reconnectTimer = null;
10
- var reconnectAttempt = 0;
11
-
12
- var wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
13
-
14
- // DOM refs
15
- var pinGate = document.getElementById('pin-gate');
16
- var pinInput = document.getElementById('pin-input');
17
- var pinSubmit = document.getElementById('pin-submit');
18
- var pinError = document.getElementById('pin-error');
19
- var mainApp = document.getElementById('main-app');
20
- var sidebar = document.getElementById('sidebar');
21
- var sidebarToggle = document.getElementById('sidebar-toggle');
22
- var sessionList = document.getElementById('session-list');
23
- var newSessionBtn = document.getElementById('new-session-btn');
24
- var terminalContainer = document.getElementById('terminal-container');
25
- var noSessionMsg = document.getElementById('no-session-msg');
26
- var toolbar = document.getElementById('toolbar');
27
- var dialog = document.getElementById('new-session-dialog');
28
- var customPath = document.getElementById('custom-path-input');
29
- var dialogCancel = document.getElementById('dialog-cancel');
30
- var dialogStart = document.getElementById('dialog-start');
31
- var menuBtn = document.getElementById('menu-btn');
32
- var sidebarOverlay = document.getElementById('sidebar-overlay');
33
- var sessionTitle = document.getElementById('session-title');
34
- var sessionFilter = document.getElementById('session-filter');
35
- var sidebarRootFilter = document.getElementById('sidebar-root-filter');
36
- var sidebarRepoFilter = document.getElementById('sidebar-repo-filter');
37
- var dialogRootSelect = document.getElementById('dialog-root-select');
38
- var dialogRepoSelect = document.getElementById('dialog-repo-select');
39
- var dialogYolo = document.getElementById('dialog-yolo');
40
- var dialogBranchInput = document.getElementById('dialog-branch-input');
41
- var dialogBranchList = document.getElementById('dialog-branch-list');
42
- var dialogContinue = document.getElementById('dialog-continue');
43
- var dialogContinueField = document.getElementById('dialog-continue-field');
44
- var contextMenu = document.getElementById('context-menu');
45
- var ctxResumeYolo = document.getElementById('ctx-resume-yolo');
46
- var ctxDeleteWorktree = document.getElementById('ctx-delete-worktree');
47
- var deleteWtDialog = document.getElementById('delete-worktree-dialog');
48
- var deleteWtName = document.getElementById('delete-wt-name');
49
- var deleteWtCancel = document.getElementById('delete-wt-cancel');
50
- var deleteWtConfirm = document.getElementById('delete-wt-confirm');
51
- var updateToast = document.getElementById('update-toast');
52
- var updateToastText = document.getElementById('update-toast-text');
53
- var updateToastBtn = document.getElementById('update-toast-btn');
54
- var updateToastDismiss = document.getElementById('update-toast-dismiss');
55
- var imageToast = document.getElementById('image-toast');
56
- var imageToastText = document.getElementById('image-toast-text');
57
- var imageToastInsert = document.getElementById('image-toast-insert');
58
- var imageToastDismiss = document.getElementById('image-toast-dismiss');
59
- var imageFileInput = document.getElementById('image-file-input');
60
- var uploadImageBtn = document.getElementById('upload-image-btn');
61
- var terminalScrollbar = document.getElementById('terminal-scrollbar');
62
- var terminalScrollbarThumb = document.getElementById('terminal-scrollbar-thumb');
63
- var mobileInput = document.getElementById('mobile-input');
64
- var mobileHeader = document.getElementById('mobile-header');
65
- var sidebarTabs = document.querySelectorAll('.sidebar-tab');
66
- var tabReposCount = document.getElementById('tab-repos-count');
67
- var tabWorktreesCount = document.getElementById('tab-worktrees-count');
68
- var activeTab = 'repos';
69
- var isMobileDevice = 'ontouchstart' in window;
70
-
71
- // Context menu state
72
- var contextMenuTarget = null; // stores { worktreePath, repoPath, name }
73
- var longPressTimer = null;
74
- var longPressFired = false;
75
-
76
- function showContextMenu(x, y, wt) {
77
- contextMenuTarget = { worktreePath: wt.path, repoPath: wt.repoPath, name: wt.name };
78
- contextMenu.style.left = Math.min(x, window.innerWidth - 180) + 'px';
79
- contextMenu.style.top = Math.min(y, window.innerHeight - 60) + 'px';
80
- contextMenu.hidden = false;
81
- }
82
-
83
- function hideContextMenu() {
84
- contextMenu.hidden = true;
85
- contextMenuTarget = null;
86
- }
87
-
88
- document.addEventListener('click', function () {
89
- hideContextMenu();
90
- });
91
-
92
- document.addEventListener('keydown', function (e) {
93
- if (e.key === 'Escape') hideContextMenu();
94
- });
95
-
96
- // Session / worktree / repo state
97
- var cachedSessions = [];
98
- var cachedWorktrees = [];
99
- var allRepos = [];
100
- var allBranches = [];
101
- var cachedRepos = [];
102
- var attentionSessions = {};
103
-
104
- function loadBranches(repoPath) {
105
- allBranches = [];
106
- dialogBranchList.innerHTML = '';
107
- dialogBranchList.hidden = true;
108
- if (!repoPath) return;
109
-
110
- fetch('/branches?repo=' + encodeURIComponent(repoPath))
111
- .then(function (res) {
112
- if (!res.ok) return [];
113
- return res.json();
114
- })
115
- .then(function (data) {
116
- allBranches = data || [];
117
- })
118
- .catch(function () {
119
- allBranches = [];
120
- });
121
- }
122
-
123
- function filterBranches(query) {
124
- dialogBranchList.innerHTML = '';
125
- if (!query) {
126
- dialogBranchList.hidden = true;
127
- return;
128
- }
129
-
130
- var lower = query.toLowerCase();
131
- var matches = allBranches.filter(function (b) {
132
- return b.toLowerCase().indexOf(lower) !== -1;
133
- }).slice(0, 10);
134
-
135
- var exactMatch = allBranches.some(function (b) { return b === query; });
136
-
137
- if (!exactMatch) {
138
- var createLi = document.createElement('li');
139
- createLi.className = 'branch-create-new';
140
- createLi.textContent = 'Create new: ' + query;
141
- createLi.addEventListener('click', function () {
142
- dialogBranchInput.value = query;
143
- dialogBranchList.hidden = true;
144
- });
145
- dialogBranchList.appendChild(createLi);
146
- }
147
-
148
- matches.forEach(function (branch) {
149
- var li = document.createElement('li');
150
- li.textContent = branch;
151
- li.addEventListener('click', function () {
152
- dialogBranchInput.value = branch;
153
- dialogBranchList.hidden = true;
154
- });
155
- dialogBranchList.appendChild(li);
156
- });
157
-
158
- dialogBranchList.hidden = dialogBranchList.children.length === 0;
159
- }
160
-
161
- dialogBranchInput.addEventListener('input', function () {
162
- filterBranches(dialogBranchInput.value.trim());
163
- });
164
-
165
- dialogBranchInput.addEventListener('focus', function () {
166
- if (dialogBranchInput.value.trim()) {
167
- filterBranches(dialogBranchInput.value.trim());
168
- }
169
- });
170
-
171
- document.addEventListener('click', function (e) {
172
- if (!dialogBranchInput.contains(e.target) && !dialogBranchList.contains(e.target)) {
173
- dialogBranchList.hidden = true;
174
- }
175
- });
176
-
177
- // ── PIN Auth ────────────────────────────────────────────────────────────────
178
-
179
- function submitPin() {
180
- var pin = pinInput.value.trim();
181
- if (!pin) return;
182
-
183
- fetch('/auth', {
184
- method: 'POST',
185
- headers: { 'Content-Type': 'application/json' },
186
- body: JSON.stringify({ pin: pin }),
187
- })
188
- .then(function (res) {
189
- if (res.ok) {
190
- pinGate.hidden = true;
191
- mainApp.hidden = false;
192
- initApp();
193
- } else {
194
- return res.json().then(function (data) {
195
- showPinError(data.error || 'Incorrect PIN');
196
- });
197
- }
198
- })
199
- .catch(function () {
200
- showPinError('Connection error. Please try again.');
201
- });
202
- }
203
-
204
- function showPinError(msg) {
205
- pinError.textContent = msg;
206
- pinError.hidden = false;
207
- pinInput.value = '';
208
- pinInput.focus();
209
- }
210
-
211
- pinSubmit.addEventListener('click', submitPin);
212
- pinInput.addEventListener('keydown', function (e) {
213
- if (e.key === 'Enter') submitPin();
214
- });
215
-
216
- // ── App Init ────────────────────────────────────────────────────────────────
217
-
218
- function initApp() {
219
- initTerminal();
220
- loadRepos();
221
- refreshAll();
222
- connectEventSocket();
223
- checkForUpdates();
224
- }
225
-
226
- // ── Terminal ────────────────────────────────────────────────────────────────
227
-
228
- function initTerminal() {
229
- term = new Terminal({
230
- cursorBlink: true,
231
- fontSize: 14,
232
- fontFamily: 'Menlo, monospace',
233
- theme: {
234
- background: '#1e1e1e',
235
- foreground: '#d4d4d4',
236
- cursor: '#d4d4d4',
237
- },
238
- });
239
-
240
- fitAddon = new FitAddon.FitAddon();
241
- term.loadAddon(fitAddon);
242
- term.open(terminalContainer);
243
- fitAddon.fit();
244
-
245
- term.onScroll(updateScrollbar);
246
- term.onWriteParsed(updateScrollbar);
247
-
248
- var isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || '');
249
- term.attachCustomKeyEventHandler(function (e) {
250
- if (isMobileDevice) {
251
- return false;
252
- }
253
-
254
- if (!isMac && e.type === 'keydown' && e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey &&
255
- (e.key === 'v' || e.key === 'V')) {
256
- if (navigator.clipboard && navigator.clipboard.read) {
257
- navigator.clipboard.read().then(function (clipboardItems) {
258
- var imageBlob = null;
259
- var imageType = null;
260
-
261
- for (var i = 0; i < clipboardItems.length; i++) {
262
- var types = clipboardItems[i].types;
263
- for (var j = 0; j < types.length; j++) {
264
- if (types[j].indexOf('image/') === 0) {
265
- imageType = types[j];
266
- imageBlob = clipboardItems[i];
267
- break;
268
- }
269
- }
270
- if (imageBlob) break;
271
- }
272
-
273
- if (imageBlob) {
274
- imageBlob.getType(imageType).then(function (blob) {
275
- uploadImage(blob, imageType);
276
- });
277
- } else {
278
- navigator.clipboard.readText().then(function (text) {
279
- if (text) term.paste(text);
280
- });
281
- }
282
- }).catch(function () {
283
- if (navigator.clipboard.readText) {
284
- navigator.clipboard.readText().then(function (text) {
285
- if (text) term.paste(text);
286
- }).catch(function () {});
287
- }
288
- });
289
- return false;
290
- }
291
- }
292
-
293
- return true;
294
- });
295
-
296
- term.onData(function (data) {
297
- if (ws && ws.readyState === WebSocket.OPEN) {
298
- ws.send(data);
299
- }
300
- });
301
-
302
- var resizeObserver = new ResizeObserver(function () {
303
- fitAddon.fit();
304
- sendResize();
305
- updateScrollbar();
306
- });
307
- resizeObserver.observe(terminalContainer);
308
- }
309
-
310
- function sendResize() {
311
- if (ws && ws.readyState === WebSocket.OPEN && term) {
312
- ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
313
- }
314
- }
315
-
316
- // ── Terminal Scrollbar ──────────────────────────────────────────────────────
317
-
318
- var scrollbarDragging = false;
319
- var scrollbarDragStartY = 0;
320
- var scrollbarDragStartTop = 0;
321
-
322
- function updateScrollbar() {
323
- if (!term || !terminalScrollbar || terminalScrollbar.style.display === 'none') return;
324
- var buf = term.buffer.active;
325
- var totalLines = buf.baseY + term.rows;
326
- var viewportTop = buf.viewportY;
327
- var trackHeight = terminalScrollbar.clientHeight;
328
-
329
- if (totalLines <= term.rows) {
330
- terminalScrollbarThumb.style.display = 'none';
331
- return;
332
- }
333
-
334
- terminalScrollbarThumb.style.display = 'block';
335
- var thumbHeight = Math.max(isMobileDevice ? 44 : 20, (term.rows / totalLines) * trackHeight);
336
- var thumbTop = (viewportTop / (totalLines - term.rows)) * (trackHeight - thumbHeight);
337
-
338
- terminalScrollbarThumb.style.height = thumbHeight + 'px';
339
- terminalScrollbarThumb.style.top = thumbTop + 'px';
340
- }
341
-
342
- function scrollbarScrollToY(clientY) {
343
- var rect = terminalScrollbar.getBoundingClientRect();
344
- var buf = term.buffer.active;
345
- var totalLines = buf.baseY + term.rows;
346
- if (totalLines <= term.rows) return;
347
-
348
- var thumbHeight = Math.max(isMobileDevice ? 44 : 20, (term.rows / totalLines) * terminalScrollbar.clientHeight);
349
- var trackUsable = terminalScrollbar.clientHeight - thumbHeight;
350
- var relativeY = clientY - rect.top - thumbHeight / 2;
351
- var ratio = Math.max(0, Math.min(1, relativeY / trackUsable));
352
- var targetLine = Math.round(ratio * (totalLines - term.rows));
353
-
354
- term.scrollToLine(targetLine);
355
- }
356
-
357
- terminalScrollbarThumb.addEventListener('touchstart', function (e) {
358
- e.preventDefault();
359
- scrollbarDragging = true;
360
- scrollbarDragStartY = e.touches[0].clientY;
361
- scrollbarDragStartTop = parseInt(terminalScrollbarThumb.style.top, 10) || 0;
362
- });
363
-
364
- if (isMobileDevice) {
365
- document.addEventListener('touchmove', function (e) {
366
- if (!scrollbarDragging) return;
367
- e.preventDefault();
368
- var deltaY = e.touches[0].clientY - scrollbarDragStartY;
369
- var buf = term.buffer.active;
370
- var totalLines = buf.baseY + term.rows;
371
- if (totalLines <= term.rows) return;
372
-
373
- var thumbHeight = Math.max(isMobileDevice ? 44 : 20, (term.rows / totalLines) * terminalScrollbar.clientHeight);
374
- var trackUsable = terminalScrollbar.clientHeight - thumbHeight;
375
- var newTop = Math.max(0, Math.min(trackUsable, scrollbarDragStartTop + deltaY));
376
- var ratio = newTop / trackUsable;
377
- var targetLine = Math.round(ratio * (totalLines - term.rows));
378
-
379
- term.scrollToLine(targetLine);
380
- }, { passive: false });
381
-
382
- document.addEventListener('touchend', function () {
383
- scrollbarDragging = false;
384
- });
385
- }
386
-
387
- terminalScrollbar.addEventListener('click', function (e) {
388
- if (e.target === terminalScrollbarThumb) return;
389
- scrollbarScrollToY(e.clientY);
390
- });
391
-
392
- // ── WebSocket / Session Connection ──────────────────────────────────────────
393
-
394
- function connectToSession(sessionId) {
395
- if (reconnectTimer) {
396
- clearTimeout(reconnectTimer);
397
- reconnectTimer = null;
398
- }
399
- reconnectAttempt = 0;
400
-
401
- if (ws) {
402
- ws.onclose = null;
403
- ws.close();
404
- ws = null;
405
- }
406
-
407
- activeSessionId = sessionId;
408
- delete attentionSessions[sessionId];
409
- noSessionMsg.hidden = true;
410
- term.clear();
411
- if (isMobileDevice) {
412
- mobileInput.value = '';
413
- mobileInput.dispatchEvent(new Event('sessionchange'));
414
- mobileInput.focus();
415
- } else {
416
- term.focus();
417
- }
418
- closeSidebar();
419
- updateSessionTitle();
420
- highlightActiveSession();
421
-
422
- openPtyWebSocket(sessionId);
423
- }
424
-
425
- function openPtyWebSocket(sessionId) {
426
- var url = wsProtocol + '//' + location.host + '/ws/' + sessionId;
427
- var socket = new WebSocket(url);
428
-
429
- socket.onopen = function () {
430
- ws = socket;
431
- reconnectAttempt = 0;
432
- sendResize();
433
- };
434
-
435
- socket.onmessage = function (event) {
436
- term.write(event.data);
437
- };
438
-
439
- socket.onclose = function (event) {
440
- if (event.code === 1000) {
441
- term.write('\r\n[Session ended]\r\n');
442
- ws = null;
443
- return;
444
- }
445
-
446
- if (activeSessionId !== sessionId) return;
447
-
448
- ws = null;
449
- if (reconnectAttempt === 0) {
450
- term.write('\r\n[Reconnecting...]\r\n');
451
- }
452
- scheduleReconnect(sessionId);
453
- };
454
-
455
- socket.onerror = function () {};
456
- }
457
-
458
- var MAX_RECONNECT_ATTEMPTS = 30;
459
-
460
- function scheduleReconnect(sessionId) {
461
- if (reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
462
- term.write('\r\n[Gave up reconnecting after ' + MAX_RECONNECT_ATTEMPTS + ' attempts]\r\n');
463
- return;
464
- }
465
- var delay = Math.min(1000 * Math.pow(2, reconnectAttempt), 10000);
466
- reconnectAttempt++;
467
-
468
- reconnectTimer = setTimeout(function () {
469
- reconnectTimer = null;
470
- if (activeSessionId !== sessionId) return;
471
- fetch('/sessions').then(function (res) {
472
- return res.json();
473
- }).then(function (sessions) {
474
- var exists = sessions.some(function (s) { return s.id === sessionId; });
475
- if (!exists || activeSessionId !== sessionId) {
476
- term.write('\r\n[Session ended]\r\n');
477
- return;
478
- }
479
- term.clear();
480
- openPtyWebSocket(sessionId);
481
- }).catch(function () {
482
- if (activeSessionId === sessionId) {
483
- scheduleReconnect(sessionId);
484
- }
485
- });
486
- }, delay);
487
- }
488
-
489
- // ── Sessions & Worktrees ────────────────────────────────────────────────────
490
-
491
- var eventWs = null;
492
-
493
- function connectEventSocket() {
494
- if (eventWs) {
495
- eventWs.close();
496
- eventWs = null;
497
- }
498
-
499
- var url = wsProtocol + '//' + location.host + '/ws/events';
500
- eventWs = new WebSocket(url);
501
-
502
- eventWs.onmessage = function (event) {
503
- try {
504
- var msg = JSON.parse(event.data);
505
- if (msg.type === 'worktrees-changed') {
506
- loadRepos();
507
- refreshAll();
508
- } else if (msg.type === 'session-idle-changed') {
509
- if (msg.idle && msg.sessionId !== activeSessionId) {
510
- attentionSessions[msg.sessionId] = true;
511
- }
512
- if (!msg.idle) {
513
- delete attentionSessions[msg.sessionId];
514
- }
515
- renderUnifiedList();
516
- }
517
- } catch (_) {}
518
- };
519
-
520
- eventWs.onclose = function () {
521
- setTimeout(function () {
522
- connectEventSocket();
523
- }, 3000);
524
- };
525
-
526
- eventWs.onerror = function () {};
527
- }
528
-
529
- function refreshAll() {
530
- Promise.all([
531
- fetch('/sessions').then(function (res) { return res.json(); }),
532
- fetch('/worktrees').then(function (res) { return res.json(); }),
533
- fetch('/repos').then(function (res) { return res.json(); }),
534
- ])
535
- .then(function (results) {
536
- cachedSessions = results[0] || [];
537
- cachedWorktrees = results[1] || [];
538
- cachedRepos = results[2] || [];
539
-
540
- // Prune attention flags for sessions that no longer exist
541
- var activeIds = {};
542
- cachedSessions.forEach(function (s) { activeIds[s.id] = true; });
543
- Object.keys(attentionSessions).forEach(function (id) {
544
- if (!activeIds[id]) delete attentionSessions[id];
545
- });
546
-
547
- populateSidebarFilters();
548
- renderUnifiedList();
549
- })
550
- .catch(function () {});
551
- }
552
-
553
- function populateSidebarFilters() {
554
- var currentRoot = sidebarRootFilter.value;
555
- var roots = {};
556
- cachedSessions.forEach(function (s) {
557
- if (s.root) roots[s.root] = true;
558
- });
559
- cachedWorktrees.forEach(function (wt) {
560
- if (wt.root) roots[wt.root] = true;
561
- });
562
-
563
- sidebarRootFilter.innerHTML = '<option value="">All roots</option>';
564
- Object.keys(roots).sort().forEach(function (root) {
565
- var opt = document.createElement('option');
566
- opt.value = root;
567
- opt.textContent = rootShortName(root);
568
- sidebarRootFilter.appendChild(opt);
569
- });
570
- if (currentRoot && roots[currentRoot]) {
571
- sidebarRootFilter.value = currentRoot;
572
- }
573
-
574
- updateRepoFilter();
575
- }
576
-
577
- function updateRepoFilter() {
578
- var selectedRoot = sidebarRootFilter.value;
579
- var currentRepo = sidebarRepoFilter.value;
580
- var repos = {};
581
-
582
- cachedSessions.forEach(function (s) {
583
- if (!selectedRoot || s.root === selectedRoot) {
584
- if (s.repoName) repos[s.repoName] = true;
585
- }
586
- });
587
- cachedWorktrees.forEach(function (wt) {
588
- if (!selectedRoot || wt.root === selectedRoot) {
589
- if (wt.repoName) repos[wt.repoName] = true;
590
- }
591
- });
592
-
593
- sidebarRepoFilter.innerHTML = '<option value="">All repos</option>';
594
- Object.keys(repos).sort().forEach(function (repoName) {
595
- var opt = document.createElement('option');
596
- opt.value = repoName;
597
- opt.textContent = repoName;
598
- sidebarRepoFilter.appendChild(opt);
599
- });
600
- if (currentRepo && repos[currentRepo]) {
601
- sidebarRepoFilter.value = currentRepo;
602
- }
603
- }
604
-
605
- sidebarRootFilter.addEventListener('change', function () {
606
- updateRepoFilter();
607
- renderUnifiedList();
608
- });
609
-
610
- sidebarRepoFilter.addEventListener('change', function () {
611
- renderUnifiedList();
612
- });
613
-
614
- sessionFilter.addEventListener('input', function () {
615
- renderUnifiedList();
616
- });
617
-
618
- sidebarTabs.forEach(function (tab) {
619
- tab.addEventListener('click', function () {
620
- activeTab = tab.dataset.tab;
621
- sidebarTabs.forEach(function (t) { t.classList.remove('active'); });
622
- tab.classList.add('active');
623
- newSessionBtn.textContent = activeTab === 'repos' ? '+ New Session' : '+ New Worktree';
624
- renderUnifiedList();
625
- });
626
- });
627
-
628
- function rootShortName(path) {
629
- return path.split('/').filter(Boolean).pop() || path;
630
- }
631
-
632
- function formatRelativeTime(isoString) {
633
- if (!isoString) return '';
634
- var now = Date.now();
635
- var then = new Date(isoString).getTime();
636
- var diffSec = Math.floor((now - then) / 1000);
637
- if (diffSec < 60) return 'just now';
638
- var diffMin = Math.floor(diffSec / 60);
639
- if (diffMin < 60) return diffMin + ' min ago';
640
- var diffHr = Math.floor(diffMin / 60);
641
- if (diffHr < 24) return diffHr + (diffHr === 1 ? ' hour ago' : ' hours ago');
642
- var diffDay = Math.floor(diffHr / 24);
643
- if (diffDay === 1) return 'yesterday';
644
- if (diffDay < 7) return diffDay + ' days ago';
645
- var d = new Date(isoString);
646
- var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
647
- return months[d.getMonth()] + ' ' + d.getDate();
648
- }
649
-
650
- function renderUnifiedList() {
651
- var rootFilter = sidebarRootFilter.value;
652
- var repoFilter = sidebarRepoFilter.value;
653
- var textFilter = sessionFilter.value.toLowerCase();
654
-
655
- // Split sessions by type
656
- var repoSessions = cachedSessions.filter(function (s) { return s.type === 'repo'; });
657
- var worktreeSessions = cachedSessions.filter(function (s) { return s.type !== 'repo'; });
658
-
659
- // Filtered repo sessions
660
- var filteredRepoSessions = repoSessions.filter(function (s) {
661
- if (rootFilter && s.root !== rootFilter) return false;
662
- if (repoFilter && s.repoName !== repoFilter) return false;
663
- if (textFilter) {
664
- var name = (s.displayName || s.repoName || s.id).toLowerCase();
665
- if (name.indexOf(textFilter) === -1) return false;
666
- }
667
- return true;
668
- });
669
-
670
- // Idle repos: all repos without an active repo session
671
- var activeRepoPathSet = new Set();
672
- repoSessions.forEach(function (s) { activeRepoPathSet.add(s.repoPath); });
673
-
674
- var filteredIdleRepos = cachedRepos.filter(function (r) {
675
- if (activeRepoPathSet.has(r.path)) return false;
676
- if (rootFilter && r.root !== rootFilter) return false;
677
- if (repoFilter && r.name !== repoFilter) return false;
678
- if (textFilter) {
679
- var name = (r.name || '').toLowerCase();
680
- if (name.indexOf(textFilter) === -1) return false;
681
- }
682
- return true;
683
- });
684
-
685
- filteredIdleRepos.sort(function (a, b) {
686
- return (a.name || '').localeCompare(b.name || '');
687
- });
688
-
689
- // Filtered worktree sessions
690
- var filteredWorktreeSessions = worktreeSessions.filter(function (s) {
691
- if (rootFilter && s.root !== rootFilter) return false;
692
- if (repoFilter && s.repoName !== repoFilter) return false;
693
- if (textFilter) {
694
- var name = (s.displayName || s.repoName || s.worktreeName || s.id).toLowerCase();
695
- if (name.indexOf(textFilter) === -1) return false;
696
- }
697
- return true;
698
- });
699
-
700
- // Inactive worktrees (deduped against active sessions)
701
- var activeWorktreePaths = new Set();
702
- worktreeSessions.forEach(function (s) {
703
- if (s.repoPath) activeWorktreePaths.add(s.repoPath);
704
- });
705
-
706
- var filteredWorktrees = cachedWorktrees.filter(function (wt) {
707
- if (activeWorktreePaths.has(wt.path)) return false;
708
- if (rootFilter && wt.root !== rootFilter) return false;
709
- if (repoFilter && wt.repoName !== repoFilter) return false;
710
- if (textFilter) {
711
- var name = (wt.name || '').toLowerCase();
712
- if (name.indexOf(textFilter) === -1) return false;
713
- }
714
- return true;
715
- });
716
-
717
- filteredWorktrees.sort(function (a, b) {
718
- return (a.name || '').localeCompare(b.name || '');
719
- });
720
-
721
- // Update tab counts
722
- tabReposCount.textContent = filteredRepoSessions.length + filteredIdleRepos.length;
723
- tabWorktreesCount.textContent = filteredWorktreeSessions.length + filteredWorktrees.length;
724
-
725
- // Render based on active tab
726
- sessionList.innerHTML = '';
727
-
728
- if (activeTab === 'repos') {
729
- filteredRepoSessions.forEach(function (session) {
730
- sessionList.appendChild(createActiveSessionLi(session));
731
- });
732
- if (filteredRepoSessions.length > 0 && filteredIdleRepos.length > 0) {
733
- sessionList.appendChild(createSectionDivider('Available'));
734
- }
735
- filteredIdleRepos.forEach(function (repo) {
736
- sessionList.appendChild(createIdleRepoLi(repo));
737
- });
738
- } else {
739
- filteredWorktreeSessions.forEach(function (session) {
740
- sessionList.appendChild(createActiveSessionLi(session));
741
- });
742
- if (filteredWorktreeSessions.length > 0 && filteredWorktrees.length > 0) {
743
- sessionList.appendChild(createSectionDivider('Available'));
744
- }
745
- filteredWorktrees.forEach(function (wt) {
746
- sessionList.appendChild(createInactiveWorktreeLi(wt));
747
- });
748
- }
749
-
750
- highlightActiveSession();
751
- }
752
-
753
- function getSessionStatus(session) {
754
- if (attentionSessions[session.id]) return 'attention';
755
- if (session.idle) return 'idle';
756
- return 'running';
757
- }
758
-
759
- function createActiveSessionLi(session) {
760
- var li = document.createElement('li');
761
- li.className = 'active-session';
762
- li.dataset.sessionId = session.id;
763
-
764
- var infoDiv = document.createElement('div');
765
- infoDiv.className = 'session-info';
766
-
767
- var nameSpan = document.createElement('span');
768
- nameSpan.className = 'session-name';
769
- var displayName = session.displayName || session.repoName || session.id;
770
- nameSpan.textContent = displayName;
771
- nameSpan.title = displayName;
772
-
773
- var subSpan = document.createElement('span');
774
- subSpan.className = 'session-sub';
775
- subSpan.textContent = (session.root ? rootShortName(session.root) : '') + ' · ' + (session.repoName || '');
776
-
777
- var status = getSessionStatus(session);
778
- var dot = document.createElement('span');
779
- dot.className = 'status-dot status-dot--' + status;
780
- infoDiv.appendChild(dot);
781
- infoDiv.appendChild(nameSpan);
782
- infoDiv.appendChild(subSpan);
783
-
784
- var timeSpan = document.createElement('span');
785
- timeSpan.className = 'session-time';
786
- timeSpan.textContent = formatRelativeTime(session.lastActivity);
787
- infoDiv.appendChild(timeSpan);
788
-
789
- var actionsDiv = document.createElement('div');
790
- actionsDiv.className = 'session-actions';
791
-
792
- var renameBtn = document.createElement('button');
793
- renameBtn.className = 'session-rename-btn';
794
- renameBtn.setAttribute('aria-label', 'Rename session');
795
- renameBtn.textContent = '✎';
796
- renameBtn.addEventListener('click', function (e) {
797
- e.stopPropagation();
798
- startRename(li, session);
799
- });
800
-
801
- var killBtn = document.createElement('button');
802
- killBtn.className = 'session-kill';
803
- killBtn.setAttribute('aria-label', 'Kill session');
804
- killBtn.textContent = '×';
805
- killBtn.addEventListener('click', function (e) {
806
- e.stopPropagation();
807
- killSession(session.id);
808
- });
809
-
810
- actionsDiv.appendChild(renameBtn);
811
- actionsDiv.appendChild(killBtn);
812
-
813
- li.appendChild(infoDiv);
814
- li.appendChild(actionsDiv);
815
-
816
- li.addEventListener('click', function () {
817
- connectToSession(session.id);
818
- });
819
-
820
- return li;
821
- }
822
-
823
- function createInactiveWorktreeLi(wt) {
824
- var li = document.createElement('li');
825
- li.className = 'inactive-worktree';
826
-
827
- var infoDiv = document.createElement('div');
828
- infoDiv.className = 'session-info';
829
-
830
- var nameSpan = document.createElement('span');
831
- nameSpan.className = 'session-name';
832
- var wtDisplayName = wt.displayName || wt.name;
833
- nameSpan.textContent = wtDisplayName;
834
- nameSpan.title = wtDisplayName;
835
-
836
- var subSpan = document.createElement('span');
837
- subSpan.className = 'session-sub';
838
- subSpan.textContent = (wt.root ? rootShortName(wt.root) : '') + ' · ' + (wt.repoName || '');
839
-
840
- var dot = document.createElement('span');
841
- dot.className = 'status-dot status-dot--inactive';
842
- infoDiv.appendChild(dot);
843
- infoDiv.appendChild(nameSpan);
844
- infoDiv.appendChild(subSpan);
845
-
846
- var timeSpan = document.createElement('span');
847
- timeSpan.className = 'session-time';
848
- timeSpan.textContent = formatRelativeTime(wt.lastActivity);
849
- infoDiv.appendChild(timeSpan);
850
-
851
- li.appendChild(infoDiv);
852
-
853
- // Click to resume (but not if context menu just opened or long-press fired)
854
- li.addEventListener('click', function () {
855
- if (longPressFired || !contextMenu.hidden) return;
856
- startSession(wt.repoPath, wt.path);
857
- });
858
-
859
- // Right-click context menu (desktop)
860
- li.addEventListener('contextmenu', function (e) {
861
- e.preventDefault();
862
- e.stopPropagation();
863
- showContextMenu(e.clientX, e.clientY, wt);
864
- });
865
-
866
- // Long-press context menu (mobile)
867
- li.addEventListener('touchstart', function (e) {
868
- longPressFired = false;
869
- longPressTimer = setTimeout(function () {
870
- longPressTimer = null;
871
- longPressFired = true;
872
- var touch = e.touches[0];
873
- showContextMenu(touch.clientX, touch.clientY, wt);
874
- }, 500);
875
- }, { passive: true });
876
-
877
- li.addEventListener('touchend', function () {
878
- if (longPressTimer) {
879
- clearTimeout(longPressTimer);
880
- longPressTimer = null;
881
- }
882
- });
883
-
884
- li.addEventListener('touchmove', function () {
885
- if (longPressTimer) {
886
- clearTimeout(longPressTimer);
887
- longPressTimer = null;
888
- }
889
- });
890
-
891
- return li;
892
- }
893
-
894
- function createSectionDivider(label) {
895
- var divider = document.createElement('li');
896
- divider.className = 'session-divider';
897
- divider.textContent = label;
898
- return divider;
899
- }
900
-
901
- function createIdleRepoLi(repo) {
902
- var li = document.createElement('li');
903
- li.className = 'inactive-worktree';
904
- li.title = repo.path;
905
-
906
- var infoDiv = document.createElement('div');
907
- infoDiv.className = 'session-info';
908
-
909
- var nameSpan = document.createElement('span');
910
- nameSpan.className = 'session-name';
911
- nameSpan.textContent = repo.name;
912
- nameSpan.title = repo.name;
913
-
914
- var dot = document.createElement('span');
915
- dot.className = 'status-dot status-dot--inactive';
916
-
917
- var subSpan = document.createElement('span');
918
- subSpan.className = 'session-sub';
919
- subSpan.textContent = repo.root ? rootShortName(repo.root) : repo.path;
920
-
921
- infoDiv.appendChild(dot);
922
- infoDiv.appendChild(nameSpan);
923
- infoDiv.appendChild(subSpan);
924
- li.appendChild(infoDiv);
925
-
926
- li.addEventListener('click', function () {
927
- openNewSessionDialogForRepo(repo);
928
- });
929
-
930
- return li;
931
- }
932
-
933
- function startRename(li, session) {
934
- var nameSpan = li.querySelector('.session-name');
935
- if (!nameSpan) return;
936
-
937
- var currentName = nameSpan.textContent;
938
- var input = document.createElement('input');
939
- input.type = 'text';
940
- input.className = 'session-rename-input';
941
- input.value = currentName;
942
-
943
- nameSpan.replaceWith(input);
944
- input.focus();
945
- input.select();
946
-
947
- var committed = false;
948
-
949
- function commit() {
950
- if (committed) return;
951
- committed = true;
952
- var newName = input.value.trim();
953
- if (!newName || newName === currentName) {
954
- cancel();
955
- return;
956
- }
957
- fetch('/sessions/' + session.id, {
958
- method: 'PATCH',
959
- headers: { 'Content-Type': 'application/json' },
960
- body: JSON.stringify({ displayName: newName }),
961
- })
962
- .then(function () { refreshAll(); })
963
- .catch(function () { cancel(); });
964
- }
965
-
966
- function cancel() {
967
- committed = true;
968
- input.replaceWith(nameSpan);
969
- }
970
-
971
- input.addEventListener('keydown', function (e) {
972
- if (e.key === 'Enter') { e.preventDefault(); commit(); }
973
- if (e.key === 'Escape') { e.preventDefault(); cancel(); }
974
- });
975
-
976
- input.addEventListener('blur', commit);
977
- }
978
-
979
- function killSession(sessionId) {
980
- fetch('/sessions/' + sessionId, { method: 'DELETE' })
981
- .then(function () {
982
- if (sessionId === activeSessionId) {
983
- if (ws) {
984
- ws.close();
985
- ws = null;
986
- }
987
- activeSessionId = null;
988
- term.clear();
989
- noSessionMsg.hidden = false;
990
- updateSessionTitle();
991
- }
992
- refreshAll();
993
- })
994
- .catch(function () {});
995
- }
996
-
997
- // ── Context Menu Actions ──────────────────────────────────────────────────
998
-
999
- ctxResumeYolo.addEventListener('click', function (e) {
1000
- e.stopPropagation();
1001
- var target = contextMenuTarget;
1002
- hideContextMenu();
1003
- if (!target) return;
1004
- startSession(
1005
- target.repoPath,
1006
- target.worktreePath,
1007
- ['--dangerously-skip-permissions']
1008
- );
1009
- });
1010
-
1011
- ctxDeleteWorktree.addEventListener('click', function (e) {
1012
- e.stopPropagation();
1013
- var target = contextMenuTarget;
1014
- hideContextMenu();
1015
- if (!target) return;
1016
- contextMenuTarget = target;
1017
- deleteWtName.textContent = target.name;
1018
- deleteWtDialog.showModal();
1019
- });
1020
-
1021
- deleteWtCancel.addEventListener('click', function () {
1022
- deleteWtDialog.close();
1023
- contextMenuTarget = null;
1024
- });
1025
-
1026
- deleteWtConfirm.addEventListener('click', function () {
1027
- if (!contextMenuTarget) return;
1028
- var target = contextMenuTarget;
1029
- deleteWtDialog.close();
1030
- contextMenuTarget = null;
1031
-
1032
- fetch('/worktrees', {
1033
- method: 'DELETE',
1034
- headers: { 'Content-Type': 'application/json' },
1035
- body: JSON.stringify({
1036
- worktreePath: target.worktreePath,
1037
- repoPath: target.repoPath,
1038
- }),
1039
- })
1040
- .then(function (res) {
1041
- if (!res.ok) {
1042
- return res.json().then(function (data) {
1043
- alert(data.error || 'Failed to delete worktree');
1044
- });
1045
- }
1046
- // UI will auto-update via worktrees-changed WebSocket event
1047
- })
1048
- .catch(function () {
1049
- alert('Failed to delete worktree');
1050
- });
1051
- });
1052
-
1053
- function highlightActiveSession() {
1054
- var items = sessionList.querySelectorAll('li');
1055
- items.forEach(function (li) {
1056
- if (li.dataset.sessionId === activeSessionId) {
1057
- li.classList.add('active');
1058
- } else {
1059
- li.classList.remove('active');
1060
- }
1061
- });
1062
- }
1063
-
1064
- function updateSessionTitle() {
1065
- if (!activeSessionId) {
1066
- sessionTitle.textContent = 'No session';
1067
- return;
1068
- }
1069
- var activeLi = sessionList.querySelector('li.active .session-name');
1070
- sessionTitle.textContent = activeLi ? activeLi.textContent : activeSessionId.slice(0, 8);
1071
- }
1072
-
1073
- // ── Repos & New Session Dialog ──────────────────────────────────────────────
1074
-
1075
- function loadRepos() {
1076
- fetch('/repos')
1077
- .then(function (res) { return res.json(); })
1078
- .then(function (data) {
1079
- allRepos = data.repos || data || [];
1080
- })
1081
- .catch(function () {});
1082
- }
1083
-
1084
- function populateDialogRootSelect() {
1085
- var roots = {};
1086
- allRepos.forEach(function (repo) {
1087
- var root = repo.root || 'Other';
1088
- roots[root] = true;
1089
- });
1090
- dialogRootSelect.innerHTML = '<option value="">Select a root...</option>';
1091
- Object.keys(roots).forEach(function (root) {
1092
- var opt = document.createElement('option');
1093
- opt.value = root;
1094
- opt.textContent = rootShortName(root);
1095
- dialogRootSelect.appendChild(opt);
1096
- });
1097
- }
1098
-
1099
- dialogRootSelect.addEventListener('change', function () {
1100
- var root = dialogRootSelect.value;
1101
- dialogRepoSelect.innerHTML = '<option value="">Select a repo...</option>';
1102
- dialogBranchInput.value = '';
1103
- allBranches = [];
1104
-
1105
- if (!root) {
1106
- dialogRepoSelect.disabled = true;
1107
- return;
1108
- }
1109
-
1110
- var filtered = allRepos.filter(function (r) { return r.root === root; });
1111
- filtered.sort(function (a, b) { return a.name.localeCompare(b.name); });
1112
- filtered.forEach(function (repo) {
1113
- var opt = document.createElement('option');
1114
- opt.value = repo.path;
1115
- opt.textContent = repo.name;
1116
- dialogRepoSelect.appendChild(opt);
1117
- });
1118
- dialogRepoSelect.disabled = false;
1119
- });
1120
-
1121
- dialogRepoSelect.addEventListener('change', function () {
1122
- var repoPath = dialogRepoSelect.value;
1123
- dialogBranchInput.value = '';
1124
- loadBranches(repoPath);
1125
- });
1126
-
1127
- function startSession(repoPath, worktreePath, claudeArgs, branchName) {
1128
- var body = {
1129
- repoPath: repoPath,
1130
- repoName: repoPath.split('/').filter(Boolean).pop(),
1131
- };
1132
- if (worktreePath) body.worktreePath = worktreePath;
1133
- if (claudeArgs) body.claudeArgs = claudeArgs;
1134
- if (branchName) body.branchName = branchName;
1135
-
1136
- fetch('/sessions', {
1137
- method: 'POST',
1138
- headers: { 'Content-Type': 'application/json' },
1139
- body: JSON.stringify(body),
1140
- })
1141
- .then(function (res) { return res.json(); })
1142
- .then(function (data) {
1143
- if (dialog.open) dialog.close();
1144
- refreshAll();
1145
- if (data.id) {
1146
- connectToSession(data.id);
1147
- }
1148
- })
1149
- .catch(function () {});
1150
- }
1151
-
1152
- function resetDialogFields() {
1153
- customPath.value = '';
1154
- dialogYolo.checked = false;
1155
- dialogContinue.checked = false;
1156
- dialogBranchInput.value = '';
1157
- dialogBranchList.hidden = true;
1158
- allBranches = [];
1159
- populateDialogRootSelect();
1160
- }
1161
-
1162
- function showDialogForTab(tab) {
1163
- var dialogBranchField = dialogBranchInput.closest('.dialog-field');
1164
- if (tab === 'repos') {
1165
- dialogBranchField.hidden = true;
1166
- dialogContinueField.hidden = false;
1167
- dialogStart.textContent = 'New Session';
1168
- } else {
1169
- dialogBranchField.hidden = false;
1170
- dialogContinueField.hidden = true;
1171
- dialogStart.textContent = 'New Worktree';
1172
- }
1173
- }
1174
-
1175
- function openNewSessionDialogForRepo(repo) {
1176
- resetDialogFields();
1177
-
1178
- if (repo.root) {
1179
- dialogRootSelect.value = repo.root;
1180
- dialogRootSelect.dispatchEvent(new Event('change'));
1181
- dialogRepoSelect.value = repo.path;
1182
- }
1183
-
1184
- showDialogForTab('repos');
1185
- dialog.showModal();
1186
- }
1187
-
1188
- function startRepoSession(repoPath, continueSession, claudeArgs) {
1189
- var body = { repoPath: repoPath };
1190
- if (continueSession) body.continue = true;
1191
- if (claudeArgs) body.claudeArgs = claudeArgs;
1192
-
1193
- fetch('/sessions/repo', {
1194
- method: 'POST',
1195
- headers: { 'Content-Type': 'application/json' },
1196
- body: JSON.stringify(body),
1197
- })
1198
- .then(function (res) {
1199
- if (res.status === 409) {
1200
- return res.json().then(function (data) {
1201
- if (dialog.open) dialog.close();
1202
- refreshAll();
1203
- if (data.sessionId) connectToSession(data.sessionId);
1204
- return null;
1205
- });
1206
- }
1207
- return res.json();
1208
- })
1209
- .then(function (data) {
1210
- if (!data) return;
1211
- if (dialog.open) dialog.close();
1212
- refreshAll();
1213
- if (data.id) connectToSession(data.id);
1214
- })
1215
- .catch(function () {});
1216
- }
1217
-
1218
- newSessionBtn.addEventListener('click', function () {
1219
- resetDialogFields();
1220
-
1221
- var sidebarRoot = sidebarRootFilter.value;
1222
- if (sidebarRoot) {
1223
- dialogRootSelect.value = sidebarRoot;
1224
- dialogRootSelect.dispatchEvent(new Event('change'));
1225
- var sidebarRepo = sidebarRepoFilter.value;
1226
- if (sidebarRepo) {
1227
- var matchingRepo = allRepos.find(function (r) {
1228
- return r.root === sidebarRoot && r.name === sidebarRepo;
1229
- });
1230
- if (matchingRepo) {
1231
- dialogRepoSelect.value = matchingRepo.path;
1232
- }
1233
- }
1234
- } else {
1235
- dialogRepoSelect.innerHTML = '<option value="">Select a repo...</option>';
1236
- dialogRepoSelect.disabled = true;
1237
- }
1238
-
1239
- showDialogForTab(activeTab);
1240
- dialog.showModal();
1241
- });
1242
-
1243
- dialogStart.addEventListener('click', function () {
1244
- var repoPathValue = customPath.value.trim() || dialogRepoSelect.value;
1245
- if (!repoPathValue) return;
1246
- var args = dialogYolo.checked ? ['--dangerously-skip-permissions'] : undefined;
1247
-
1248
- if (activeTab === 'repos') {
1249
- startRepoSession(repoPathValue, dialogContinue.checked, args);
1250
- } else {
1251
- var branch = dialogBranchInput.value.trim() || undefined;
1252
- startSession(repoPathValue, undefined, args, branch);
1253
- }
1254
- });
1255
-
1256
- customPath.addEventListener('blur', function () {
1257
- var pathValue = customPath.value.trim();
1258
- if (pathValue) {
1259
- loadBranches(pathValue);
1260
- }
1261
- });
1262
-
1263
- dialogCancel.addEventListener('click', function () {
1264
- dialog.close();
1265
- });
1266
-
1267
- // ── Sidebar Toggle ──────────────────────────────────────────────────────────
1268
-
1269
- function openSidebar() {
1270
- sidebar.classList.add('open');
1271
- sidebarOverlay.classList.add('visible');
1272
- }
1273
-
1274
- function closeSidebar() {
1275
- sidebar.classList.remove('open');
1276
- sidebarOverlay.classList.remove('visible');
1277
- }
1278
-
1279
- menuBtn.addEventListener('click', openSidebar);
1280
- sidebarToggle.addEventListener('click', closeSidebar);
1281
- sidebarOverlay.addEventListener('click', closeSidebar);
1282
-
1283
- // ── Settings Dialog ────────────────────────────────────────────────────────
1284
-
1285
- var settingsBtn = document.getElementById('settings-btn');
1286
- var settingsDialog = document.getElementById('settings-dialog');
1287
- var settingsRootsList = document.getElementById('settings-roots-list');
1288
- var addRootPath = document.getElementById('add-root-path');
1289
- var addRootBtn = document.getElementById('add-root-btn');
1290
- var settingsClose = document.getElementById('settings-close');
1291
-
1292
- function renderRoots(roots) {
1293
- settingsRootsList.innerHTML = '';
1294
- roots.forEach(function (rootPath) {
1295
- var div = document.createElement('div');
1296
- div.className = 'settings-repo-item';
1297
-
1298
- var pathSpan = document.createElement('span');
1299
- pathSpan.className = 'repo-path';
1300
- pathSpan.textContent = rootPath;
1301
-
1302
- var removeBtn = document.createElement('button');
1303
- removeBtn.className = 'remove-repo';
1304
- removeBtn.textContent = '×';
1305
- removeBtn.addEventListener('click', function () {
1306
- fetch('/roots', {
1307
- method: 'DELETE',
1308
- headers: { 'Content-Type': 'application/json' },
1309
- body: JSON.stringify({ path: rootPath }),
1310
- })
1311
- .then(function (res) { return res.json(); })
1312
- .then(function (updated) {
1313
- renderRoots(updated);
1314
- loadRepos();
1315
- })
1316
- .catch(function () {});
1317
- });
1318
-
1319
- div.appendChild(pathSpan);
1320
- div.appendChild(removeBtn);
1321
- settingsRootsList.appendChild(div);
1322
- });
1323
- }
1324
-
1325
- function openSettings() {
1326
- fetch('/roots')
1327
- .then(function (res) { return res.json(); })
1328
- .then(function (roots) {
1329
- renderRoots(roots);
1330
- settingsDialog.showModal();
1331
- })
1332
- .catch(function () {});
1333
- }
1334
-
1335
- settingsBtn.addEventListener('click', openSettings);
1336
-
1337
- addRootBtn.addEventListener('click', function () {
1338
- var rootPath = addRootPath.value.trim();
1339
- if (!rootPath) return;
1340
-
1341
- fetch('/roots', {
1342
- method: 'POST',
1343
- headers: { 'Content-Type': 'application/json' },
1344
- body: JSON.stringify({ path: rootPath }),
1345
- })
1346
- .then(function (res) { return res.json(); })
1347
- .then(function (updated) {
1348
- renderRoots(updated);
1349
- addRootPath.value = '';
1350
- loadRepos();
1351
- })
1352
- .catch(function () {});
1353
- });
1354
-
1355
- addRootPath.addEventListener('keydown', function (e) {
1356
- if (e.key === 'Enter') addRootBtn.click();
1357
- });
1358
-
1359
- var settingsDevtools = document.getElementById('settings-devtools');
1360
-
1361
- // Initialize developer tools toggle from localStorage
1362
- var devtoolsEnabled = localStorage.getItem('devtools-enabled') === 'true';
1363
- settingsDevtools.checked = devtoolsEnabled;
1364
-
1365
- settingsDevtools.addEventListener('change', function () {
1366
- devtoolsEnabled = settingsDevtools.checked;
1367
- localStorage.setItem('devtools-enabled', devtoolsEnabled ? 'true' : 'false');
1368
- // Update debug toggle visibility immediately
1369
- var debugToggle = document.getElementById('debug-toggle');
1370
- if (debugToggle) {
1371
- debugToggle.style.display = devtoolsEnabled ? '' : 'none';
1372
- }
1373
- });
1374
-
1375
- settingsClose.addEventListener('click', function () {
1376
- settingsDialog.close();
1377
- loadRepos();
1378
- refreshAll();
1379
- });
1380
-
1381
- // ── Touch Toolbar ───────────────────────────────────────────────────────────
1382
-
1383
- function handleToolbarButton(e) {
1384
- var btn = e.target.closest('button');
1385
- if (!btn) return;
1386
- // Skip the upload button (handled separately)
1387
- if (btn.id === 'upload-image-btn') return;
1388
-
1389
- e.preventDefault();
1390
-
1391
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
1392
-
1393
- var text = btn.dataset.text;
1394
- var key = btn.dataset.key;
1395
-
1396
- // Flush composed text before sending Enter/newline so pending input isn't lost
1397
- if ((key === '\r' || key === '\x1b[13;2u') && mobileInput.flushComposedText) {
1398
- mobileInput.flushComposedText();
1399
- }
1400
-
1401
- if (text !== undefined) {
1402
- ws.send(text);
1403
- } else if (key !== undefined) {
1404
- ws.send(key);
1405
- }
1406
-
1407
- // Clear input after Enter/newline to reset state
1408
- if ((key === '\r' || key === '\x1b[13;2u') && mobileInput.clearInput) {
1409
- mobileInput.clearInput();
1410
- }
1411
-
1412
- // Re-focus the mobile input to keep keyboard open
1413
- if (isMobileDevice) {
1414
- mobileInput.focus();
1415
- }
1416
- }
1417
-
1418
- toolbar.addEventListener('touchstart', handleToolbarButton, { passive: false });
1419
-
1420
- toolbar.addEventListener('click', function (e) {
1421
- // On non-touch devices, handle normally
1422
- if (isMobileDevice) return; // already handled by touchstart
1423
- handleToolbarButton(e);
1424
- });
1425
-
1426
- // ── Image Paste Handling ─────────────────────────────────────────────────────
1427
-
1428
- var imageUploadInProgress = false;
1429
- var pendingImagePath = null;
1430
-
1431
- function showImageToast(text, showInsert) {
1432
- imageToastText.textContent = text;
1433
- imageToastInsert.hidden = !showInsert;
1434
- imageToast.hidden = false;
1435
- }
1436
-
1437
- function hideImageToast() {
1438
- imageToast.hidden = true;
1439
- pendingImagePath = null;
1440
- }
1441
-
1442
- function autoDismissImageToast(ms) {
1443
- setTimeout(function () {
1444
- if (!pendingImagePath) {
1445
- hideImageToast();
1446
- }
1447
- }, ms);
1448
- }
1449
-
1450
- function uploadImage(blob, mimeType) {
1451
- if (imageUploadInProgress) return;
1452
- if (!activeSessionId) return;
1453
-
1454
- imageUploadInProgress = true;
1455
- showImageToast('Pasting image\u2026', false);
1456
-
1457
- var reader = new FileReader();
1458
- reader.onload = function () {
1459
- var base64 = reader.result.split(',')[1];
1460
-
1461
- fetch('/sessions/' + activeSessionId + '/image', {
1462
- method: 'POST',
1463
- headers: { 'Content-Type': 'application/json' },
1464
- body: JSON.stringify({ data: base64, mimeType: mimeType }),
1465
- })
1466
- .then(function (res) {
1467
- if (res.status === 413) {
1468
- showImageToast('Image too large (max 10MB)', false);
1469
- autoDismissImageToast(4000);
1470
- return;
1471
- }
1472
- if (!res.ok) {
1473
- return res.json().then(function (data) {
1474
- showImageToast(data.error || 'Image upload failed', false);
1475
- autoDismissImageToast(4000);
1476
- });
1477
- }
1478
- return res.json().then(function (data) {
1479
- if (data.clipboardSet) {
1480
- showImageToast('Image pasted', false);
1481
- autoDismissImageToast(2000);
1482
- } else {
1483
- pendingImagePath = data.path;
1484
- showImageToast(data.path, true);
1485
- }
1486
- });
1487
- })
1488
- .catch(function () {
1489
- showImageToast('Image upload failed', false);
1490
- autoDismissImageToast(4000);
1491
- })
1492
- .then(function () {
1493
- imageUploadInProgress = false;
1494
- });
1495
- };
1496
-
1497
- reader.readAsDataURL(blob);
1498
- }
1499
-
1500
- terminalContainer.addEventListener('paste', function (e) {
1501
- if (!e.clipboardData || !e.clipboardData.items) return;
1502
-
1503
- var items = e.clipboardData.items;
1504
- for (var i = 0; i < items.length; i++) {
1505
- if (items[i].type.indexOf('image/') === 0) {
1506
- e.preventDefault();
1507
- e.stopPropagation();
1508
- var blob = items[i].getAsFile();
1509
- if (blob) {
1510
- uploadImage(blob, items[i].type);
1511
- }
1512
- return;
1513
- }
1514
- }
1515
- });
1516
-
1517
- terminalContainer.addEventListener('dragover', function (e) {
1518
- if (e.dataTransfer && e.dataTransfer.types.indexOf('Files') !== -1) {
1519
- e.preventDefault();
1520
- terminalContainer.classList.add('drag-over');
1521
- }
1522
- });
1523
-
1524
- terminalContainer.addEventListener('dragleave', function () {
1525
- terminalContainer.classList.remove('drag-over');
1526
- });
1527
-
1528
- terminalContainer.addEventListener('drop', function (e) {
1529
- e.preventDefault();
1530
- terminalContainer.classList.remove('drag-over');
1531
- if (!e.dataTransfer || !e.dataTransfer.files.length) return;
1532
-
1533
- var file = e.dataTransfer.files[0];
1534
- if (file.type.indexOf('image/') === 0) {
1535
- uploadImage(file, file.type);
1536
- }
1537
- });
1538
-
1539
- imageToastInsert.addEventListener('click', function () {
1540
- if (pendingImagePath && ws && ws.readyState === WebSocket.OPEN) {
1541
- ws.send(pendingImagePath);
1542
- }
1543
- hideImageToast();
1544
- });
1545
-
1546
- imageToastDismiss.addEventListener('click', function () {
1547
- hideImageToast();
1548
- });
1549
-
1550
- // ── Image Upload Button (mobile) ──────────────────────────────────────────
1551
-
1552
- uploadImageBtn.addEventListener('click', function (e) {
1553
- e.preventDefault();
1554
- if (!activeSessionId) return;
1555
- imageFileInput.click();
1556
- if (isMobileDevice) {
1557
- mobileInput.focus();
1558
- }
1559
- });
1560
-
1561
- imageFileInput.addEventListener('change', function () {
1562
- var file = imageFileInput.files[0];
1563
- if (file && file.type.indexOf('image/') === 0) {
1564
- uploadImage(file, file.type);
1565
- }
1566
- imageFileInput.value = '';
1567
- });
1568
-
1569
- // ── Mobile Input Proxy ──────────────────────────────────────────────────────
1570
-
1571
- (function () {
1572
- if (!isMobileDevice) return;
1573
-
1574
- // ── Debug Panel (hidden by default, toggle with button) ──
1575
- var debugPanel = document.createElement('div');
1576
- debugPanel.id = 'debug-panel';
1577
- debugPanel.style.cssText = 'position:fixed;top:0;left:0;right:0;height:30vh;overflow-y:auto;background:rgba(0,0,0,0.92);color:#0f0;font:11px/1.4 monospace;padding:6px 6px 6px 40px;z-index:9999;display:none;';
1578
- document.body.appendChild(debugPanel);
1579
-
1580
- var debugToggle = document.createElement('button');
1581
- debugToggle.id = 'debug-toggle';
1582
- debugToggle.textContent = 'dbg';
1583
- debugToggle.style.cssText = 'position:fixed;bottom:60px;right:8px;z-index:10000;background:#333;color:#0f0;border:1px solid #0f0;border-radius:6px;font:12px monospace;padding:6px 10px;opacity:0.5;min-width:44px;min-height:44px;';
1584
- // Hide debug toggle unless developer tools are enabled in settings
1585
- if (localStorage.getItem('devtools-enabled') !== 'true') {
1586
- debugToggle.style.display = 'none';
1587
- }
1588
- document.body.appendChild(debugToggle);
1589
-
1590
- var debugVisible = false;
1591
- debugToggle.addEventListener('click', function (e) {
1592
- e.preventDefault();
1593
- e.stopPropagation();
1594
- debugVisible = !debugVisible;
1595
- debugPanel.style.display = debugVisible ? 'block' : 'none';
1596
- debugToggle.style.opacity = debugVisible ? '1' : '0.6';
1597
- });
1598
-
1599
- var debugLines = [];
1600
- function dbg(msg) {
1601
- var t = performance.now().toFixed(1);
1602
- debugLines.push('[' + t + '] ' + msg);
1603
- if (debugLines.length > 200) debugLines.shift();
1604
- debugPanel.innerHTML = debugLines.join('<br>');
1605
- debugPanel.scrollTop = debugPanel.scrollHeight;
1606
- }
1607
-
1608
- var lastInputValue = '';
1609
- var isComposing = false;
1610
-
1611
- function focusMobileInput() {
1612
- if (document.activeElement !== mobileInput) {
1613
- mobileInput.focus();
1614
- }
1615
- }
1616
-
1617
- // Tap on terminal area focuses the hidden input (opens keyboard)
1618
- terminalContainer.addEventListener('touchend', function (e) {
1619
- // Don't interfere with scrollbar drag or selection
1620
- if (scrollbarDragging) return;
1621
- if (e.target === terminalScrollbarThumb || e.target === terminalScrollbar) return;
1622
- focusMobileInput();
1623
- });
1624
-
1625
- // When xterm would receive focus, redirect to hidden input
1626
- terminalContainer.addEventListener('focus', function () {
1627
- focusMobileInput();
1628
- }, true);
1629
-
1630
- // Compute the common prefix length between two strings
1631
- function commonPrefixLength(a, b) {
1632
- var len = 0;
1633
- while (len < a.length && len < b.length && a[len] === b[len]) {
1634
- len++;
1635
- }
1636
- return len;
1637
- }
1638
-
1639
- // Count Unicode code points in a string (handles surrogate pairs)
1640
- function codepointCount(str) {
1641
- var count = 0;
1642
- for (var i = 0; i < str.length; i++) {
1643
- count++;
1644
- if (str.charCodeAt(i) >= 0xD800 && str.charCodeAt(i) <= 0xDBFF) {
1645
- i++; // skip low surrogate
1646
- }
1647
- }
1648
- return count;
1649
- }
1650
-
1651
- // Batched send: accumulates payload across rapid input events (e.g. autocorrect
1652
- // fires deleteContentBackward + insertText ~2ms apart) and flushes in one
1653
- // ws.send() so the PTY receives backspaces + replacement text atomically.
1654
- var sendBuffer = '';
1655
- var sendTimer = null;
1656
- var SEND_DELAY = 10; // ms – enough to batch autocorrect pairs, imperceptible for typing
1657
-
1658
- function scheduleSend(data) {
1659
- sendBuffer += data;
1660
- if (sendTimer !== null) clearTimeout(sendTimer);
1661
- sendTimer = setTimeout(flushSendBuffer, SEND_DELAY);
1662
- }
1663
-
1664
- function flushSendBuffer() {
1665
- sendTimer = null;
1666
- if (sendBuffer && ws && ws.readyState === WebSocket.OPEN) {
1667
- dbg('FLUSH: "' + sendBuffer.replace(/\x7f/g, '\u232b') + '" (' + sendBuffer.length + ' bytes)');
1668
- ws.send(sendBuffer);
1669
- }
1670
- sendBuffer = '';
1671
- }
1672
-
1673
- // Send the diff between lastInputValue and currentValue to the terminal.
1674
- // Handles autocorrect expansions, deletions, and same-length replacements.
1675
- function sendInputDiff(currentValue) {
1676
- if (currentValue === lastInputValue) {
1677
- dbg('sendInputDiff: NO-OP (same)');
1678
- return;
1679
- }
1680
-
1681
- var commonLen = commonPrefixLength(lastInputValue, currentValue);
1682
- var deletedSlice = lastInputValue.slice(commonLen);
1683
- var charsToDelete = codepointCount(deletedSlice);
1684
- var newChars = currentValue.slice(commonLen);
1685
-
1686
- dbg('sendInputDiff: del=' + charsToDelete + ' "' + deletedSlice + '" add="' + newChars + '"');
1687
-
1688
- var payload = '';
1689
- for (var i = 0; i < charsToDelete; i++) {
1690
- payload += '\x7f';
1691
- }
1692
- payload += newChars;
1693
- if (payload) {
1694
- scheduleSend(payload);
1695
- }
1696
- }
1697
-
1698
- mobileInput.addEventListener('compositionstart', function (e) {
1699
- dbg('COMP_START data="' + e.data + '" val="' + mobileInput.value + '" last="' + lastInputValue + '"');
1700
- isComposing = true;
1701
- });
1702
-
1703
- mobileInput.addEventListener('compositionupdate', function (e) {
1704
- dbg('COMP_UPDATE data="' + e.data + '" val="' + mobileInput.value + '"');
1705
- });
1706
-
1707
- mobileInput.addEventListener('compositionend', function (e) {
1708
- dbg('COMP_END data="' + e.data + '" val="' + mobileInput.value + '" last="' + lastInputValue + '"');
1709
- isComposing = false;
1710
- if (ws && ws.readyState === WebSocket.OPEN) {
1711
- var currentValue = mobileInput.value;
1712
- sendInputDiff(currentValue);
1713
- lastInputValue = currentValue;
1714
- }
1715
- });
1716
-
1717
- mobileInput.addEventListener('blur', function () {
1718
- if (isComposing) {
1719
- isComposing = false;
1720
- lastInputValue = mobileInput.value;
1721
- }
1722
- });
1723
-
1724
- mobileInput.addEventListener('sessionchange', function () {
1725
- isComposing = false;
1726
- lastInputValue = '';
1727
- });
1728
-
1729
- // Flush any pending composed text and buffered sends to the terminal.
1730
- function flushComposedText() {
1731
- isComposing = false;
1732
- if (ws && ws.readyState === WebSocket.OPEN) {
1733
- var currentValue = mobileInput.value;
1734
- sendInputDiff(currentValue);
1735
- lastInputValue = currentValue;
1736
- }
1737
- flushSendBuffer();
1738
- }
1739
- function clearInput() {
1740
- mobileInput.value = '';
1741
- lastInputValue = '';
1742
- }
1743
- // Expose for toolbar handler
1744
- mobileInput.flushComposedText = flushComposedText;
1745
- mobileInput.clearInput = clearInput;
1746
-
1747
- // ── Form submit handler for reliable Enter on mobile ──
1748
- // On Android Chrome with Gboard, keydown fires with key="Unidentified" and
1749
- // keyCode=229 during active composition/prediction (which is nearly always).
1750
- // Wrapping the input in a <form> ensures the browser fires a "submit" event
1751
- // when Enter is pressed, regardless of composition state.
1752
- var inputForm = document.getElementById('mobile-input-form');
1753
- if (inputForm) {
1754
- inputForm.addEventListener('submit', function (e) {
1755
- e.preventDefault();
1756
- dbg('FORM_SUBMIT composing=' + isComposing + ' val="' + mobileInput.value + '"');
1757
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
1758
- flushComposedText();
1759
- ws.send('\r');
1760
- mobileInput.value = '';
1761
- lastInputValue = '';
1762
- });
1763
- }
1764
-
1765
- // Handle text input with autocorrect
1766
- var clearTimer = null;
1767
- mobileInput.addEventListener('beforeinput', function (e) {
1768
- dbg('BEFORE_INPUT type="' + e.inputType + '" data="' + (e.data || '') + '" composing=' + isComposing);
1769
- });
1770
-
1771
- mobileInput.addEventListener('input', function (e) {
1772
- dbg('INPUT type="' + e.inputType + '" composing=' + isComposing + ' val="' + mobileInput.value + '" last="' + lastInputValue + '"');
1773
-
1774
- // Reset the auto-clear timer to prevent unbounded growth
1775
- if (clearTimer) clearTimeout(clearTimer);
1776
- clearTimer = setTimeout(function () {
1777
- dbg('TIMER_CLEAR val="' + mobileInput.value + '"');
1778
- mobileInput.value = '';
1779
- lastInputValue = '';
1780
- }, 5000);
1781
-
1782
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
1783
-
1784
- if (isComposing) {
1785
- dbg(' INPUT: skipped (composing)');
1786
- return;
1787
- }
1788
-
1789
- var currentValue = mobileInput.value;
1790
- sendInputDiff(currentValue);
1791
- lastInputValue = currentValue;
1792
- });
1793
-
1794
- // Handle special keys (Enter, Backspace, Escape, arrows, Tab)
1795
- mobileInput.addEventListener('keydown', function (e) {
1796
- dbg('KEYDOWN key="' + e.key + '" shift=' + e.shiftKey + ' composing=' + isComposing + ' val="' + mobileInput.value + '"');
1797
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
1798
-
1799
- var handled = true;
1800
-
1801
- switch (e.key) {
1802
- case 'Enter':
1803
- flushComposedText();
1804
- if (e.shiftKey) {
1805
- ws.send('\x1b[13;2u'); // kitty protocol: Shift+Enter (newline)
1806
- } else {
1807
- ws.send('\r');
1808
- }
1809
- mobileInput.value = '';
1810
- lastInputValue = '';
1811
- break;
1812
- case 'Backspace':
1813
- if (mobileInput.value.length === 0) {
1814
- // Input is empty, send backspace directly
1815
- ws.send('\x7f');
1816
- }
1817
- // Otherwise, let the input event handle it via diff
1818
- handled = false;
1819
- break;
1820
- case 'Escape':
1821
- ws.send('\x1b');
1822
- mobileInput.value = '';
1823
- lastInputValue = '';
1824
- break;
1825
- case 'Tab':
1826
- ws.send('\t');
1827
- break;
1828
- case 'ArrowUp':
1829
- ws.send('\x1b[A');
1830
- break;
1831
- case 'ArrowDown':
1832
- ws.send('\x1b[B');
1833
- break;
1834
- default:
1835
- handled = false;
1836
- }
1837
-
1838
- if (handled) {
1839
- e.preventDefault();
1840
- }
1841
- });
1842
-
1843
- })();
1844
-
1845
- // ── Keyboard-Aware Viewport ─────────────────────────────────────────────────
1846
-
1847
- (function () {
1848
- if (!window.visualViewport) return;
1849
-
1850
- var vv = window.visualViewport;
1851
-
1852
- function onViewportResize() {
1853
- var keyboardHeight = window.innerHeight - vv.height;
1854
- if (keyboardHeight > 50) {
1855
- mainApp.style.height = vv.height + 'px';
1856
- mobileHeader.style.display = 'none';
1857
- } else {
1858
- mainApp.style.height = '';
1859
- mobileHeader.style.display = '';
1860
- }
1861
- if (fitAddon) {
1862
- fitAddon.fit();
1863
- sendResize();
1864
- }
1865
- }
1866
-
1867
- vv.addEventListener('resize', onViewportResize);
1868
- vv.addEventListener('scroll', onViewportResize);
1869
- })();
1870
-
1871
- // ── Update Toast ─────────────────────────────────────────────────────────────
1872
-
1873
- function checkForUpdates() {
1874
- fetch('/version')
1875
- .then(function (res) {
1876
- if (!res.ok) return;
1877
- return res.json();
1878
- })
1879
- .then(function (data) {
1880
- if (data && data.updateAvailable) {
1881
- showUpdateToast(data.current, data.latest);
1882
- }
1883
- })
1884
- .catch(function () {
1885
- // Silently ignore version check errors
1886
- });
1887
- }
1888
-
1889
- function showUpdateToast(current, latest) {
1890
- updateToastText.textContent = 'Update available: v' + current + ' \u2192 v' + latest;
1891
- updateToast.hidden = false;
1892
- updateToastBtn.disabled = false;
1893
- updateToastBtn.textContent = 'Update Now';
1894
-
1895
- updateToastBtn.onclick = function () {
1896
- triggerUpdate(latest);
1897
- };
1898
- }
1899
-
1900
- function triggerUpdate(latest) {
1901
- updateToastBtn.disabled = true;
1902
- updateToastBtn.textContent = 'Updating\u2026';
1903
-
1904
- fetch('/update', { method: 'POST' })
1905
- .then(function (res) {
1906
- return res.json().then(function (data) {
1907
- return { ok: res.ok, data: data };
1908
- });
1909
- })
1910
- .then(function (result) {
1911
- if (result.ok && result.data.restarting) {
1912
- updateToastText.textContent = 'Updated! Restarting server\u2026';
1913
- updateToastBtn.hidden = true;
1914
- updateToastDismiss.hidden = true;
1915
- setTimeout(function () {
1916
- location.reload();
1917
- }, 5000);
1918
- } else if (result.ok) {
1919
- updateToastText.textContent = 'Updated! Please restart the server manually.';
1920
- updateToastBtn.hidden = true;
1921
- } else {
1922
- updateToastText.textContent = 'Update failed: ' + (result.data.error || 'Unknown error');
1923
- updateToastBtn.disabled = false;
1924
- updateToastBtn.textContent = 'Retry';
1925
- }
1926
- })
1927
- .catch(function () {
1928
- updateToastText.textContent = 'Update failed. Please try again.';
1929
- updateToastBtn.disabled = false;
1930
- updateToastBtn.textContent = 'Retry';
1931
- });
1932
- }
1933
-
1934
- updateToastDismiss.addEventListener('click', function () {
1935
- updateToast.hidden = true;
1936
- });
1937
-
1938
- // ── Auto-auth Check ─────────────────────────────────────────────────────────
1939
-
1940
- fetch('/sessions')
1941
- .then(function (res) {
1942
- if (res.ok) {
1943
- // Already authenticated — skip PIN gate
1944
- pinGate.hidden = true;
1945
- mainApp.hidden = false;
1946
- initApp();
1947
- }
1948
- // If not ok (401/403), stay on PIN gate — no action needed
1949
- })
1950
- .catch(function () {
1951
- // Network error — stay on PIN gate
1952
- });
1953
- })();