claude-relay 2.1.2 → 2.2.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.
@@ -1,16 +1,22 @@
1
- import { refreshIcons } from './icons.js';
1
+ import { iconHtml, refreshIcons } from './icons.js';
2
2
  import { closeSidebar } from './sidebar.js';
3
+ import { closeFileViewer } from './filebrowser.js';
3
4
 
4
5
  var ctx;
5
- var term = null;
6
- var fitAddon = null;
7
- var resizeObserver = null;
6
+ var tabs = new Map(); // termId -> { id, title, exited, xterm, fitAddon, bodyEl }
7
+ var activeTabId = null;
8
8
  var isOpen = false;
9
+ var ctrlActive = false;
10
+ var isTouchDevice = "ontouchstart" in window;
11
+ var viewportHandler = null;
12
+ var resizeObserver = null;
13
+ var toolbarBound = false;
9
14
 
15
+ // --- Init ---
10
16
  export function initTerminal(_ctx) {
11
17
  ctx = _ctx;
12
18
 
13
- // Close button
19
+ // Close panel button
14
20
  document.getElementById("terminal-close").addEventListener("click", function () {
15
21
  closeTerminal();
16
22
  });
@@ -35,32 +41,157 @@ export function initTerminal(_ctx) {
35
41
  openTerminal();
36
42
  });
37
43
  }
44
+
45
+ // New tab button
46
+ var newTabBtn = document.getElementById("terminal-new-tab");
47
+ if (newTabBtn) {
48
+ newTabBtn.addEventListener("click", function () {
49
+ createNewTab();
50
+ });
51
+ }
38
52
  }
39
53
 
54
+ // --- Open terminal panel ---
40
55
  export function openTerminal() {
41
56
  var container = ctx.terminalContainerEl;
42
- var body = ctx.terminalBodyEl;
43
57
 
44
- // Hide file viewer if open
45
- ctx.fileViewerEl.classList.add("hidden");
58
+ // Hide file viewer if open (also unwatches)
59
+ closeFileViewer();
46
60
 
47
- // If already open, just show it
48
- if (isOpen && term) {
49
- container.classList.remove("hidden");
50
- term.focus();
51
- fitTerminal();
52
- return;
61
+ container.classList.remove("hidden");
62
+ isOpen = true;
63
+
64
+ // If no tabs exist, create one
65
+ if (tabs.size === 0) {
66
+ createNewTab();
67
+ return; // createNewTab will handle the rest via term_created
53
68
  }
54
69
 
55
- container.classList.remove("hidden");
70
+ // Attach to active tab (or first available)
71
+ if (!activeTabId || !tabs.has(activeTabId)) {
72
+ activeTabId = tabs.keys().next().value;
73
+ }
74
+
75
+ activateTab(activeTabId);
76
+
77
+ // Mobile: close sidebar
78
+ if (window.innerWidth <= 768) {
79
+ closeSidebar();
80
+ }
81
+
82
+ refreshIcons();
83
+ }
84
+
85
+ // --- Close terminal panel (hide, detach, but keep PTYs alive) ---
86
+ export function closeTerminal() {
87
+ var container = ctx.terminalContainerEl;
88
+ container.classList.add("hidden");
89
+
90
+ // Detach from active tab
91
+ if (activeTabId && ctx.ws && ctx.connected) {
92
+ ctx.ws.send(JSON.stringify({ type: "term_detach", id: activeTabId }));
93
+ }
94
+
95
+ cleanupListeners();
96
+
97
+ // Hide toolbar
98
+ var toolbar = document.getElementById("terminal-toolbar");
99
+ if (toolbar) {
100
+ toolbar.classList.add("hidden");
101
+ var ctrlBtn = toolbar.querySelector("[data-key='ctrl']");
102
+ if (ctrlBtn) ctrlBtn.classList.remove("active");
103
+ }
104
+ ctrlActive = false;
105
+
106
+ isOpen = false;
107
+ }
108
+
109
+ // --- Create new tab ---
110
+ function createNewTab() {
111
+ if (!ctx.ws || !ctx.connected) return;
112
+
113
+ // Get current terminal body dimensions for cols/rows
114
+ var cols = 80;
115
+ var rows = 24;
116
+ if (activeTabId && tabs.has(activeTabId)) {
117
+ var activeTab = tabs.get(activeTabId);
118
+ if (activeTab.xterm) {
119
+ cols = activeTab.xterm.cols || 80;
120
+ rows = activeTab.xterm.rows || 24;
121
+ }
122
+ }
123
+
124
+ ctx.ws.send(JSON.stringify({ type: "term_create", cols: cols, rows: rows }));
125
+ }
126
+
127
+ // --- Close a tab (kill PTY) ---
128
+ function closeTab(termId) {
129
+ if (!ctx.ws || !ctx.connected) return;
130
+ ctx.ws.send(JSON.stringify({ type: "term_close", id: termId }));
131
+ }
132
+
133
+ // --- Activate a tab (show xterm, attach) ---
134
+ function activateTab(termId) {
135
+ var tab = tabs.get(termId);
136
+ if (!tab) return;
137
+
138
+ // Detach from old active
139
+ if (activeTabId && activeTabId !== termId && ctx.ws && ctx.connected) {
140
+ ctx.ws.send(JSON.stringify({ type: "term_detach", id: activeTabId }));
141
+ }
142
+
143
+ // Hide all tab bodies
144
+ for (var t of tabs.values()) {
145
+ if (t.bodyEl) t.bodyEl.style.display = "none";
146
+ }
56
147
 
57
- // Create xterm instance
58
- if (typeof Terminal === "undefined") {
59
- body.innerHTML = '<div class="terminal-hint">xterm.js not loaded</div>';
60
- return;
148
+ activeTabId = termId;
149
+
150
+ // Lazy-create xterm instance
151
+ if (!tab.xterm) {
152
+ createXtermForTab(tab);
153
+ }
154
+
155
+ // Show this tab's body
156
+ if (tab.bodyEl) tab.bodyEl.style.display = "";
157
+
158
+ // Attach to server
159
+ if (ctx.ws && ctx.connected) {
160
+ ctx.ws.send(JSON.stringify({ type: "term_attach", id: termId }));
161
+ }
162
+
163
+ // Fit and focus
164
+ setupListeners();
165
+ fitTerminal();
166
+
167
+ if (tab.xterm) {
168
+ tab.xterm.focus();
169
+ }
170
+
171
+ // Show toolbar on touch devices
172
+ var toolbar = document.getElementById("terminal-toolbar");
173
+ if (toolbar && isTouchDevice) {
174
+ toolbar.classList.remove("hidden");
175
+ initToolbar(toolbar);
61
176
  }
62
177
 
63
- term = new Terminal({
178
+ // Mobile viewport handling
179
+ if (window.visualViewport && !viewportHandler) {
180
+ viewportHandler = function () {
181
+ ctx.terminalContainerEl.style.height = window.visualViewport.height + "px";
182
+ fitTerminal();
183
+ };
184
+ window.visualViewport.addEventListener("resize", viewportHandler);
185
+ }
186
+
187
+ renderTabBar();
188
+ }
189
+
190
+ // --- Create xterm.js instance for a tab ---
191
+ function createXtermForTab(tab) {
192
+ if (typeof Terminal === "undefined") return;
193
+
194
+ var xterm = new Terminal({
64
195
  cursorBlink: true,
65
196
  fontSize: 13,
66
197
  fontFamily: "'SF Mono', Menlo, Monaco, 'Courier New', monospace",
@@ -88,100 +219,379 @@ export function openTerminal() {
88
219
  },
89
220
  });
90
221
 
222
+ var fitAddon = null;
91
223
  if (typeof FitAddon !== "undefined") {
92
224
  fitAddon = new FitAddon.FitAddon();
93
- term.loadAddon(fitAddon);
225
+ xterm.loadAddon(fitAddon);
94
226
  }
95
227
 
96
- term.open(body);
228
+ // Create a container div for this tab's terminal
229
+ var bodyEl = document.createElement("div");
230
+ bodyEl.className = "terminal-tab-body";
231
+ ctx.terminalBodyEl.appendChild(bodyEl);
97
232
 
98
- // Send user input to server
99
- term.onData(function (data) {
233
+ xterm.open(bodyEl);
234
+
235
+ // Route input to server
236
+ xterm.onData(function (data) {
100
237
  if (ctx.ws && ctx.connected) {
101
- ctx.ws.send(JSON.stringify({ type: "term_input", data: data }));
238
+ ctx.ws.send(JSON.stringify({ type: "term_input", id: tab.id, data: data }));
102
239
  }
103
240
  });
104
241
 
105
- // Fit terminal to container
106
- fitTerminal();
242
+ tab.xterm = xterm;
243
+ tab.fitAddon = fitAddon;
244
+ tab.bodyEl = bodyEl;
245
+ }
107
246
 
108
- // Open PTY on server
109
- if (ctx.ws && ctx.connected) {
110
- ctx.ws.send(JSON.stringify({ type: "term_open" }));
111
- }
247
+ // --- Fit active terminal ---
248
+ function fitTerminal() {
249
+ if (!activeTabId) return;
250
+ var tab = tabs.get(activeTabId);
251
+ if (!tab || !tab.fitAddon || !tab.xterm) return;
252
+
253
+ try {
254
+ tab.fitAddon.fit();
255
+ if (ctx.ws && ctx.connected) {
256
+ ctx.ws.send(JSON.stringify({
257
+ type: "term_resize",
258
+ id: activeTabId,
259
+ cols: tab.xterm.cols,
260
+ rows: tab.xterm.rows,
261
+ }));
262
+ }
263
+ } catch (e) {}
264
+ }
265
+
266
+ // --- Setup/cleanup resize listeners ---
267
+ function setupListeners() {
268
+ cleanupListeners();
112
269
 
113
- // Auto-fit on resize
114
270
  window.addEventListener("resize", fitTerminal);
115
271
 
116
- if (typeof ResizeObserver !== "undefined") {
272
+ if (typeof ResizeObserver !== "undefined" && ctx.terminalBodyEl) {
117
273
  resizeObserver = new ResizeObserver(function () {
118
274
  fitTerminal();
119
275
  });
120
- resizeObserver.observe(body);
276
+ resizeObserver.observe(ctx.terminalBodyEl);
121
277
  }
278
+ }
122
279
 
123
- isOpen = true;
124
- term.focus();
280
+ function cleanupListeners() {
281
+ window.removeEventListener("resize", fitTerminal);
125
282
 
126
- // Mobile: close sidebar
127
- if (window.innerWidth <= 768) {
128
- closeSidebar();
283
+ if (resizeObserver) {
284
+ resizeObserver.disconnect();
285
+ resizeObserver = null;
286
+ }
287
+
288
+ if (viewportHandler && window.visualViewport) {
289
+ window.visualViewport.removeEventListener("resize", viewportHandler);
290
+ viewportHandler = null;
291
+ }
292
+ ctx.terminalContainerEl.style.height = "";
293
+ }
294
+
295
+ // --- Render tab bar ---
296
+ function renderTabBar() {
297
+ var tabsEl = document.getElementById("terminal-tabs");
298
+ if (!tabsEl) return;
299
+
300
+ tabsEl.innerHTML = "";
301
+
302
+ for (var tab of tabs.values()) {
303
+ (function (t) {
304
+ var el = document.createElement("div");
305
+ el.className = "terminal-tab";
306
+ if (t.id === activeTabId) el.classList.add("active");
307
+ if (t.exited) el.classList.add("exited");
308
+
309
+ var label = document.createElement("span");
310
+ label.className = "terminal-tab-label";
311
+ label.textContent = t.title;
312
+ el.appendChild(label);
313
+
314
+ // Double-click label to rename
315
+ label.addEventListener("dblclick", function (e) {
316
+ e.stopPropagation();
317
+ startRenameTab(t, label);
318
+ });
319
+
320
+ var closeBtn = document.createElement("button");
321
+ closeBtn.className = "terminal-tab-close";
322
+ closeBtn.innerHTML = '<i data-lucide="x" style="width:12px;height:12px"></i>';
323
+ closeBtn.addEventListener("click", function (e) {
324
+ e.stopPropagation();
325
+ closeTab(t.id);
326
+ });
327
+ el.appendChild(closeBtn);
328
+
329
+ el.addEventListener("click", function () {
330
+ if (t.id !== activeTabId) {
331
+ activateTab(t.id);
332
+ }
333
+ });
334
+
335
+ tabsEl.appendChild(el);
336
+ })(tab);
129
337
  }
130
338
 
339
+ updateTerminalBadge();
131
340
  refreshIcons();
132
341
  }
133
342
 
134
- export function handleTermOutput(msg) {
135
- if (term && msg.data) {
136
- term.write(msg.data);
343
+ // --- Rename tab inline ---
344
+ function startRenameTab(tab, labelEl) {
345
+ var input = document.createElement("input");
346
+ input.className = "terminal-tab-rename";
347
+ input.value = tab.title;
348
+ input.maxLength = 50;
349
+
350
+ labelEl.replaceWith(input);
351
+ input.focus();
352
+ input.select();
353
+
354
+ function commit() {
355
+ var newTitle = input.value.trim();
356
+ if (newTitle && newTitle !== tab.title) {
357
+ tab.title = newTitle;
358
+ if (ctx.ws && ctx.connected) {
359
+ ctx.ws.send(JSON.stringify({ type: "term_rename", id: tab.id, title: newTitle }));
360
+ }
361
+ }
362
+ renderTabBar();
137
363
  }
364
+
365
+ input.addEventListener("blur", commit);
366
+ input.addEventListener("keydown", function (e) {
367
+ if (e.key === "Enter") { input.blur(); }
368
+ if (e.key === "Escape") {
369
+ input.value = tab.title; // revert
370
+ input.blur();
371
+ }
372
+ e.stopPropagation();
373
+ });
138
374
  }
139
375
 
140
- export function handleTermExited() {
141
- if (term) {
142
- term.write("\r\n\x1b[90m[Process exited]\x1b[0m\r\n");
376
+ // --- Update terminal count badge ---
377
+ function updateTerminalBadge() {
378
+ var countEl = document.getElementById("terminal-count");
379
+ if (!countEl) return;
380
+
381
+ var count = 0;
382
+ for (var t of tabs.values()) {
383
+ if (!t.exited) count++;
384
+ }
385
+
386
+ if (count > 0) {
387
+ countEl.textContent = count;
388
+ countEl.classList.remove("hidden");
389
+ } else {
390
+ countEl.classList.add("hidden");
143
391
  }
144
392
  }
145
393
 
146
- export function closeTerminal() {
147
- var container = ctx.terminalContainerEl;
148
- container.classList.add("hidden");
394
+ // --- Handle server messages ---
149
395
 
150
- if (ctx.ws && ctx.connected) {
151
- ctx.ws.send(JSON.stringify({ type: "term_close" }));
396
+ export function handleTermList(msg) {
397
+ var serverTerminals = msg.terminals || [];
398
+ var serverIds = new Set();
399
+
400
+ // Add/update tabs from server list
401
+ for (var i = 0; i < serverTerminals.length; i++) {
402
+ var st = serverTerminals[i];
403
+ serverIds.add(st.id);
404
+
405
+ if (tabs.has(st.id)) {
406
+ var existing = tabs.get(st.id);
407
+ existing.title = st.title;
408
+ existing.exited = st.exited;
409
+ } else {
410
+ tabs.set(st.id, {
411
+ id: st.id,
412
+ title: st.title,
413
+ exited: st.exited,
414
+ xterm: null,
415
+ fitAddon: null,
416
+ bodyEl: null,
417
+ });
418
+ }
152
419
  }
153
420
 
154
- if (resizeObserver) {
155
- resizeObserver.disconnect();
156
- resizeObserver = null;
421
+ // Remove tabs no longer on server
422
+ for (var id of tabs.keys()) {
423
+ if (!serverIds.has(id)) {
424
+ var removed = tabs.get(id);
425
+ if (removed.xterm) {
426
+ removed.xterm.dispose();
427
+ }
428
+ if (removed.bodyEl && removed.bodyEl.parentNode) {
429
+ removed.bodyEl.parentNode.removeChild(removed.bodyEl);
430
+ }
431
+ tabs.delete(id);
432
+ }
157
433
  }
158
434
 
159
- window.removeEventListener("resize", fitTerminal);
435
+ // If active tab was removed, switch to first available
436
+ if (activeTabId && !tabs.has(activeTabId)) {
437
+ activeTabId = null;
438
+ }
439
+
440
+ renderTabBar();
160
441
 
161
- if (term) {
162
- term.dispose();
163
- term = null;
164
- fitAddon = null;
442
+ // If panel is open and we have tabs, re-attach
443
+ if (isOpen && tabs.size > 0) {
444
+ if (!activeTabId) {
445
+ activeTabId = tabs.keys().next().value;
446
+ }
447
+ activateTab(activeTabId);
165
448
  }
166
449
 
167
- // Clear the body for fresh open next time
168
- ctx.terminalBodyEl.innerHTML = "";
169
- isOpen = false;
450
+ // If panel is open and all tabs are gone, close panel
451
+ if (isOpen && tabs.size === 0) {
452
+ closeTerminal();
453
+ }
170
454
  }
171
455
 
172
- function fitTerminal() {
173
- if (!fitAddon || !term) return;
174
- try {
175
- fitAddon.fit();
176
- // Tell server about the new size
177
- if (ctx.ws && ctx.connected) {
178
- ctx.ws.send(JSON.stringify({
179
- type: "term_resize",
180
- cols: term.cols,
181
- rows: term.rows,
182
- }));
456
+ export function handleTermCreated(msg) {
457
+ // Switch to the newly created tab
458
+ if (msg.id && tabs.has(msg.id)) {
459
+ activateTab(msg.id);
460
+ }
461
+ }
462
+
463
+ export function handleTermOutput(msg) {
464
+ if (!msg.id) return;
465
+ var tab = tabs.get(msg.id);
466
+ if (tab && tab.xterm && msg.data) {
467
+ tab.xterm.write(msg.data);
468
+ }
469
+ }
470
+
471
+ export function handleTermExited(msg) {
472
+ if (!msg.id) return;
473
+ var tab = tabs.get(msg.id);
474
+ if (tab) {
475
+ tab.exited = true;
476
+ if (tab.xterm) {
477
+ tab.xterm.write("\r\n\x1b[90m[Process exited]\x1b[0m\r\n");
478
+ }
479
+ renderTabBar();
480
+ }
481
+ }
482
+
483
+ export function handleTermClosed(msg) {
484
+ if (!msg.id) return;
485
+ var tab = tabs.get(msg.id);
486
+ if (tab) {
487
+ if (tab.xterm) tab.xterm.dispose();
488
+ if (tab.bodyEl && tab.bodyEl.parentNode) {
489
+ tab.bodyEl.parentNode.removeChild(tab.bodyEl);
490
+ }
491
+ tabs.delete(msg.id);
492
+
493
+ if (activeTabId === msg.id) {
494
+ activeTabId = null;
495
+ if (tabs.size > 0) {
496
+ activeTabId = tabs.keys().next().value;
497
+ activateTab(activeTabId);
498
+ }
499
+ }
500
+
501
+ renderTabBar();
502
+
503
+ // Close panel if no tabs left
504
+ if (isOpen && tabs.size === 0) {
505
+ closeTerminal();
506
+ }
507
+ }
508
+ }
509
+
510
+ // --- Reset on reconnect ---
511
+ export function resetTerminals() {
512
+ // Dispose all xterm instances (server state survives, client re-syncs via term_list)
513
+ for (var tab of tabs.values()) {
514
+ if (tab.xterm) {
515
+ tab.xterm.dispose();
516
+ tab.xterm = null;
517
+ tab.fitAddon = null;
518
+ }
519
+ if (tab.bodyEl && tab.bodyEl.parentNode) {
520
+ tab.bodyEl.parentNode.removeChild(tab.bodyEl);
521
+ tab.bodyEl = null;
183
522
  }
184
- } catch (e) {
185
- // ignore fit errors (element not visible, etc.)
523
+ }
524
+ tabs.clear();
525
+ activeTabId = null;
526
+ cleanupListeners();
527
+ renderTabBar();
528
+ }
529
+
530
+ // --- Mobile toolbar ---
531
+ var KEY_MAP = {
532
+ tab: "\t",
533
+ esc: "\x1b",
534
+ up: "\x1b[A",
535
+ down: "\x1b[B",
536
+ right: "\x1b[C",
537
+ left: "\x1b[D",
538
+ };
539
+
540
+ function initToolbar(toolbar) {
541
+ if (!toolbarBound) {
542
+ toolbarBound = true;
543
+
544
+ toolbar.addEventListener("mousedown", function (e) { e.preventDefault(); });
545
+
546
+ toolbar.addEventListener("click", function (e) {
547
+ var btn = e.target.closest(".term-key");
548
+ if (!btn) return;
549
+
550
+ var tab = activeTabId ? tabs.get(activeTabId) : null;
551
+ if (!tab || !tab.xterm) return;
552
+
553
+ var key = btn.dataset.key;
554
+ if (!key) return;
555
+
556
+ if (key === "ctrl") {
557
+ ctrlActive = !ctrlActive;
558
+ btn.classList.toggle("active", ctrlActive);
559
+ return;
560
+ }
561
+
562
+ var seq = KEY_MAP[key];
563
+ if (!seq) return;
564
+
565
+ if (ctx.ws && ctx.connected) {
566
+ ctx.ws.send(JSON.stringify({ type: "term_input", id: activeTabId, data: seq }));
567
+ }
568
+
569
+ if (ctrlActive) {
570
+ ctrlActive = false;
571
+ var ctrlBtn = toolbar.querySelector("[data-key='ctrl']");
572
+ if (ctrlBtn) ctrlBtn.classList.remove("active");
573
+ }
574
+ });
575
+ }
576
+
577
+ // Attach Ctrl handler to active terminal
578
+ var tab = activeTabId ? tabs.get(activeTabId) : null;
579
+ if (tab && tab.xterm) {
580
+ tab.xterm.attachCustomKeyEventHandler(function (ev) {
581
+ if (ctrlActive && ev.type === "keydown" && ev.key.length === 1) {
582
+ var charCode = ev.key.toUpperCase().charCodeAt(0);
583
+ if (charCode >= 65 && charCode <= 90) {
584
+ var ctrlChar = String.fromCharCode(charCode - 64);
585
+ if (ctx.ws && ctx.connected) {
586
+ ctx.ws.send(JSON.stringify({ type: "term_input", id: activeTabId, data: ctrlChar }));
587
+ }
588
+ ctrlActive = false;
589
+ var ctrlBtn = document.querySelector("#terminal-toolbar [data-key='ctrl']");
590
+ if (ctrlBtn) ctrlBtn.classList.remove("active");
591
+ return false;
592
+ }
593
+ }
594
+ return true;
595
+ });
186
596
  }
187
597
  }