cli-tunnel 1.2.0-beta.9 → 1.2.1-beta.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/remote-ui/app.js CHANGED
@@ -47,7 +47,10 @@
47
47
  const termContainer = $('#terminal-container');
48
48
  let currentView = 'terminal'; // 'dashboard', 'terminal', or 'grid'
49
49
  let cachedSessions = [];
50
- let gridTerminals = []; // { xterm, fitAddon, ws, session }
50
+ let gridTerminals = []; // { xterm, fitAddon, ws, session, panel }
51
+ var gridMode = 'thumbnails';
52
+ var focusedIndex = 0;
53
+ var tmuxPreset = 'equal';
51
54
 
52
55
  // ─── xterm.js Terminal ───────────────────────────────────
53
56
  let xterm = null;
@@ -90,15 +93,24 @@
90
93
  fitAddon.fit();
91
94
 
92
95
  // Send terminal size to PTY so copilot renders correctly
96
+ var lastCols = 0, lastRows = 0;
97
+ var resizeTimer = null;
93
98
  function sendResize() {
94
99
  if (ws && ws.readyState === WebSocket.OPEN && xterm) {
95
- ws.send(JSON.stringify({ type: 'pty_resize', cols: xterm.cols, rows: xterm.rows }));
100
+ if (xterm.cols !== lastCols || xterm.rows !== lastRows) {
101
+ lastCols = xterm.cols;
102
+ lastRows = xterm.rows;
103
+ ws.send(JSON.stringify({ type: 'pty_resize', cols: xterm.cols, rows: xterm.rows }));
104
+ }
96
105
  }
97
106
  }
98
107
 
99
- // Handle resize
108
+ // Handle resize — debounced to avoid rapid PTY resizes (mobile keyboard, URL bar, etc.)
100
109
  window.addEventListener('resize', () => {
101
- if (fitAddon) { fitAddon.fit(); sendResize(); }
110
+ if (resizeTimer) clearTimeout(resizeTimer);
111
+ resizeTimer = setTimeout(() => {
112
+ if (fitAddon) { fitAddon.fit(); sendResize(); }
113
+ }, 150);
102
114
  });
103
115
 
104
116
  // Send initial size
@@ -117,7 +129,9 @@
117
129
 
118
130
  async function loadSessions() {
119
131
  try {
120
- const resp = await fetch('/api/sessions');
132
+ const tokenParam = new URLSearchParams(window.location.search).get('token');
133
+ const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
134
+ const resp = await fetch('/api/sessions', { headers });
121
135
  const data = await resp.json();
122
136
  renderDashboard(data.sessions || []);
123
137
  } catch (err) {
@@ -146,17 +160,17 @@
146
160
  '</div>';
147
161
  } else {
148
162
  html += filtered.map(s => {
149
- const sessionUrl = s.token ? s.url + '?token=' + encodeURIComponent(s.token) : '';
163
+ const hasAccess = s.hasToken;
150
164
  return `
151
- <div class="session-card" ${s.online && sessionUrl ? 'data-session-url="' + escapeHtml(sessionUrl) + '"' : ''}>
165
+ <div class="session-card" ${s.online && hasAccess ? 'data-session-port="' + s.port + '" data-session-base-url="' + escapeHtml(s.url) + '"' : ''}>
152
166
  <span class="status-dot ${s.online ? 'online' : 'offline'}"></span>
153
167
  <div class="info">
154
168
  <div class="session-name">${escapeHtml(s.name)}</div>
155
169
  <div class="repo">📦 ${escapeHtml(s.repo)}</div>
156
170
  <div class="branch">🌿 ${escapeHtml(s.branch)}</div>
157
- <div class="machine">💻 ${escapeHtml(s.machine)}${!s.token && s.online ? ' 🔒' : ''}</div>
171
+ <div class="machine">💻 ${escapeHtml(s.machine)}${!hasAccess && s.online ? ' 🔒' : ''}</div>
158
172
  </div>
159
- ${s.online && sessionUrl ? '<span class="arrow">→</span>' :
173
+ ${s.online && hasAccess ? '<span class="arrow">→</span>' :
160
174
  !s.online ? '<button data-delete-id="' + escapeHtml(s.id) + '" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px" title="Remove">✕</button>'
161
175
  : '<span style="color:var(--text-dim);font-size:11px">remote</span>'}
162
176
  </div>`;
@@ -165,8 +179,25 @@
165
179
  dashboard.innerHTML = html;
166
180
  cachedSessions = sessions;
167
181
  // Event delegation
168
- dashboard.querySelectorAll('.session-card[data-session-url]').forEach(function(card) {
169
- card.addEventListener('click', function() { openSession(card.dataset.sessionUrl); });
182
+ dashboard.querySelectorAll('.session-card[data-session-port]').forEach(function(card) {
183
+ card.addEventListener('click', function() {
184
+ var port = card.dataset.sessionPort;
185
+ var baseUrl = card.dataset.sessionBaseUrl;
186
+ var tokenParam = new URLSearchParams(window.location.search).get('token');
187
+ var proxyUrl = '/api/proxy/ticket/' + port;
188
+ fetch(proxyUrl, {
189
+ method: 'POST',
190
+ headers: tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {}
191
+ }).then(function(r) { return r.json(); }).then(function(data) {
192
+ if (data.ticket) {
193
+ window.location.href = baseUrl + '?ticket=' + encodeURIComponent(data.ticket);
194
+ } else {
195
+ window.location.href = baseUrl;
196
+ }
197
+ }).catch(function() {
198
+ window.location.href = baseUrl;
199
+ });
200
+ });
170
201
  });
171
202
  dashboard.querySelectorAll('[data-delete-id]').forEach(function(btn) {
172
203
  btn.addEventListener('click', function(e) { e.stopPropagation(); deleteSession(btn.dataset.deleteId); });
@@ -187,29 +218,36 @@
187
218
  };
188
219
 
189
220
  window.cleanOffline = async () => {
190
- const resp = await fetch('/api/sessions');
221
+ const tokenParam = new URLSearchParams(window.location.search).get('token');
222
+ const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
223
+ const resp = await fetch('/api/sessions', { headers });
191
224
  const data = await resp.json();
192
225
  const offline = (data.sessions || []).filter(s => !s.online);
193
226
  for (const s of offline) {
194
- await fetch('/api/sessions/' + s.id, { method: 'DELETE' });
227
+ await fetch('/api/sessions/' + s.id, { method: 'DELETE', headers });
195
228
  }
196
229
  loadSessions();
197
230
  };
198
231
 
199
232
  window.deleteSession = async (id) => {
200
- await fetch('/api/sessions/' + id, { method: 'DELETE' });
233
+ const tokenParam = new URLSearchParams(window.location.search).get('token');
234
+ const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
235
+ await fetch('/api/sessions/' + id, { method: 'DELETE', headers });
201
236
  loadSessions();
202
237
  };
203
238
 
204
- // ─── Grid View (tmux-style multi-terminal) ────────────────
239
+ // ─── Grid View (multi-terminal with layout modes) ───────────
205
240
  function showGridView(sessions) {
206
- const connectable = sessions.filter(function(s) { return s.online && s.token; });
241
+ var connectable = sessions.filter(function(s) { return s.online && s.token; });
207
242
  if (connectable.length === 0) return;
208
243
 
209
244
  // Clean up previous grid
210
245
  destroyGrid();
211
246
 
212
247
  currentView = 'grid';
248
+ gridMode = 'thumbnails';
249
+ focusedIndex = 0;
250
+ tmuxPreset = 'equal';
213
251
  dashboard.classList.add('hidden');
214
252
  terminal.classList.add('hidden');
215
253
  termContainer.classList.add('hidden');
@@ -224,30 +262,95 @@
224
262
  gridEl.classList.remove('hidden');
225
263
  gridEl.innerHTML = '';
226
264
 
227
- // Calculate grid dimensions
228
- var cols = connectable.length <= 2 ? connectable.length : connectable.length <= 4 ? 2 : 3;
229
- gridEl.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
265
+ // ── Toolbar ──
266
+ var toolbar = document.createElement('div');
267
+ toolbar.className = 'grid-toolbar';
268
+
269
+ var modes = [
270
+ { id: 'thumbnails', label: '\u229E Tiles' },
271
+ { id: 'tmux', label: '\u229F Tmux' },
272
+ { id: 'focus', label: '\u25C9 Focus' },
273
+ { id: 'fullscreen', label: '\u2A21 Full' }
274
+ ];
275
+ modes.forEach(function(m) {
276
+ var btn = document.createElement('button');
277
+ btn.textContent = m.label;
278
+ btn.dataset.mode = m.id;
279
+ if (m.id === gridMode) btn.classList.add('active');
280
+ btn.addEventListener('click', function() { switchGridMode(m.id); });
281
+ toolbar.appendChild(btn);
282
+ });
283
+
284
+ // Tmux preset buttons (visible only in tmux mode)
285
+ var presetGroup = document.createElement('span');
286
+ presetGroup.className = 'grid-toolbar-presets hidden';
287
+ presetGroup.id = 'tmux-presets';
288
+ var presets = [
289
+ { id: 'equal', label: '\u2550 Equal' },
290
+ { id: 'main-side', label: '\u2590 Main+Side' },
291
+ { id: 'stacked', label: '\u2261 Stacked' }
292
+ ];
293
+ presets.forEach(function(p) {
294
+ var btn = document.createElement('button');
295
+ btn.textContent = p.label;
296
+ btn.dataset.preset = p.id;
297
+ if (p.id === tmuxPreset) btn.classList.add('active');
298
+ btn.addEventListener('click', function() { switchTmuxPreset(p.id); });
299
+ presetGroup.appendChild(btn);
300
+ });
301
+ toolbar.appendChild(presetGroup);
302
+
303
+ var spacer = document.createElement('span');
304
+ spacer.className = 'spacer';
305
+ toolbar.appendChild(spacer);
306
+
307
+ var listBtn = document.createElement('button');
308
+ listBtn.textContent = '\u2190 List';
309
+ listBtn.addEventListener('click', function() {
310
+ destroyGrid();
311
+ currentView = 'dashboard';
312
+ dashboard.classList.remove('hidden');
313
+ if ($('#btn-sessions')) $('#btn-sessions').textContent = 'Terminal';
314
+ loadSessions();
315
+ });
316
+ toolbar.appendChild(listBtn);
317
+ gridEl.appendChild(toolbar);
318
+
319
+ // ── Content container ──
320
+ var contentEl = document.createElement('div');
321
+ contentEl.id = 'grid-content';
322
+ gridEl.appendChild(contentEl);
230
323
 
231
- connectable.forEach(function(s) {
324
+ // ── Create panels & connect ──
325
+ connectable.forEach(function(s, index) {
232
326
  var panel = document.createElement('div');
233
327
  panel.className = 'grid-panel';
328
+ panel.dataset.index = index;
234
329
 
235
- // Header
236
330
  var header = document.createElement('div');
237
331
  header.className = 'grid-panel-header';
238
- header.innerHTML = '<span class="grid-panel-name">' + escapeHtml(s.name) + '</span>' +
239
- '<span class="grid-panel-machine">' + escapeHtml(s.machine) + '</span>' +
240
- '<span class="grid-panel-status">●</span>';
332
+ var nameSpan = document.createElement('span');
333
+ nameSpan.className = 'grid-panel-name';
334
+ nameSpan.textContent = s.name;
335
+ var machineSpan = document.createElement('span');
336
+ machineSpan.className = 'grid-panel-machine';
337
+ machineSpan.textContent = s.machine;
338
+ var statusDot = document.createElement('span');
339
+ statusDot.className = 'grid-panel-status';
340
+ statusDot.textContent = '\u25CF';
341
+ header.appendChild(nameSpan);
342
+ header.appendChild(machineSpan);
343
+ header.appendChild(statusDot);
241
344
  panel.appendChild(header);
242
345
 
243
- // Terminal container
244
346
  var termDiv = document.createElement('div');
245
347
  termDiv.className = 'grid-panel-terminal';
246
348
  panel.appendChild(termDiv);
247
349
 
248
- gridEl.appendChild(panel);
350
+ // Append to contentEl so xterm.open has a DOM-attached container
351
+ contentEl.appendChild(panel);
249
352
 
250
- // Create xterm instance for this panel
353
+ // xterm instance
251
354
  var panelXterm = new Terminal({
252
355
  theme: {
253
356
  background: '#0d1117', foreground: '#c9d1d9', cursor: '#3fb950',
@@ -262,61 +365,237 @@
262
365
  panelXterm.loadAddon(panelFit);
263
366
  panelXterm.open(termDiv);
264
367
 
265
- // Delay fit to ensure container has size
266
- setTimeout(function() { panelFit.fit(); }, 100);
368
+ // Store entry before async connect so index is stable
369
+ var entry = { xterm: panelXterm, fitAddon: panelFit, ws: null, session: s, panel: panel };
370
+ gridTerminals.push(entry);
267
371
 
268
372
  // Connect WebSocket to this session
269
- var proto = 'wss:';
270
- var wsUrl = s.url.replace('https://', 'wss://') + '?token=' + encodeURIComponent(s.token);
271
- var panelWs = new WebSocket(wsUrl);
272
- var statusDot = header.querySelector('.grid-panel-status');
273
-
274
- panelWs.onopen = function() {
275
- if (statusDot) { statusDot.style.color = 'var(--green)'; statusDot.title = 'Connected'; }
276
- // Send initial size
277
- panelWs.send(JSON.stringify({ type: 'pty_resize', cols: panelXterm.cols, rows: panelXterm.rows }));
278
- };
279
- panelWs.onclose = function() {
280
- if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Disconnected'; }
281
- };
282
- panelWs.onerror = function() {
283
- if (statusDot) { statusDot.style.color = 'var(--red)'; }
284
- };
285
- panelWs.onmessage = function(e) {
286
- try {
287
- var msg = JSON.parse(e.data);
288
- if (msg.type === 'pty') {
289
- panelXterm.write(msg.data);
290
- }
291
- } catch (err) {}
292
- };
373
+ (function connectPanel() {
374
+ // Use hub's proxy endpoint to get a ticket for the session
375
+ var tokenParam = new URLSearchParams(window.location.search).get('token');
376
+ var proxyUrl = '/api/proxy/ticket/' + s.port;
377
+ var wsBase = s.isLocal ? 'ws://127.0.0.1:' + s.port : s.url.replace('https://', 'wss://');
378
+
379
+ fetch(proxyUrl, {
380
+ method: 'POST',
381
+ headers: { 'Authorization': 'Bearer ' + tokenParam }
382
+ }).then(function(resp) {
383
+ if (!resp.ok) throw new Error('Auth failed');
384
+ return resp.json();
385
+ }).then(function(data) {
386
+ var panelWs = new WebSocket(wsBase + '?ticket=' + encodeURIComponent(data.ticket));
387
+ entry.ws = panelWs;
388
+
389
+ panelWs.onopen = function() {
390
+ if (statusDot) { statusDot.style.color = 'var(--green)'; statusDot.title = 'Connected'; }
391
+ panelWs.send(JSON.stringify({ type: 'pty_resize', cols: panelXterm.cols, rows: panelXterm.rows }));
392
+ };
393
+ panelWs.onclose = function() {
394
+ if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Disconnected'; }
395
+ };
396
+ panelWs.onerror = function() {
397
+ if (statusDot) { statusDot.style.color = 'var(--red)'; }
398
+ };
399
+ panelWs.onmessage = function(e) {
400
+ try {
401
+ var msg = JSON.parse(e.data);
402
+ if (msg.type === 'pty') {
403
+ panelXterm.write(msg.data);
404
+ }
405
+ } catch (err) {}
406
+ };
407
+
408
+ panelXterm.onData(function(data) {
409
+ if (panelWs && panelWs.readyState === WebSocket.OPEN) {
410
+ panelWs.send(JSON.stringify({ type: 'pty_input', data: data }));
411
+ }
412
+ });
413
+ }).catch(function() {
414
+ if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Auth failed'; }
415
+ });
416
+ })();
417
+ });
293
418
 
294
- // Keyboard input from this panel send to its session
295
- panelXterm.onData(function(data) {
296
- if (panelWs.readyState === WebSocket.OPEN) {
297
- panelWs.send(JSON.stringify({ type: 'pty_input', data: data }));
298
- }
419
+ // ── Event delegation for panel clicks ──
420
+ contentEl.addEventListener('click', function(e) {
421
+ var panel = e.target.closest('.grid-panel');
422
+ if (!panel) return;
423
+ var idx = parseInt(panel.dataset.index, 10);
424
+ if (isNaN(idx)) return;
425
+
426
+ if (gridMode === 'thumbnails') {
427
+ focusedIndex = idx;
428
+ switchGridMode('fullscreen');
429
+ } else if (gridMode === 'focus' && panel.classList.contains('focus-strip')) {
430
+ focusedIndex = idx;
431
+ applyGridLayout('focus');
432
+ } else if (gridMode === 'tmux') {
433
+ focusedIndex = idx;
434
+ contentEl.querySelectorAll('.grid-panel').forEach(function(p) { p.classList.remove('active'); });
435
+ panel.classList.add('active');
436
+ }
437
+ });
438
+
439
+ // Apply initial layout
440
+ applyGridLayout(gridMode);
441
+
442
+ // Handle window resize
443
+ window.removeEventListener('resize', fitGridPanels);
444
+ window.addEventListener('resize', fitGridPanels);
445
+ if ($('#btn-sessions')) { $('#btn-sessions').textContent = 'List'; }
446
+ }
447
+
448
+ function switchGridMode(mode) {
449
+ gridMode = mode;
450
+ if (mode === 'fullscreen') {
451
+ $('#input-area').classList.remove('hidden');
452
+ $('#input-form').classList.add('hidden');
453
+ } else {
454
+ $('#input-area').classList.add('hidden');
455
+ }
456
+ applyGridLayout(mode);
457
+ }
458
+
459
+ function switchTmuxPreset(preset) {
460
+ tmuxPreset = preset;
461
+ var presetGroup = document.getElementById('tmux-presets');
462
+ if (presetGroup) {
463
+ presetGroup.querySelectorAll('[data-preset]').forEach(function(btn) {
464
+ btn.classList.toggle('active', btn.dataset.preset === preset);
299
465
  });
466
+ }
467
+ if (gridMode === 'tmux') applyGridLayout('tmux');
468
+ }
469
+
470
+ function applyGridLayout(mode) {
471
+ gridMode = mode;
472
+ var contentEl = document.getElementById('grid-content');
473
+ if (!contentEl || gridTerminals.length === 0) return;
474
+
475
+ // Clamp focusedIndex
476
+ if (focusedIndex >= gridTerminals.length) focusedIndex = 0;
300
477
 
301
- // Click header to go full-screen on this session
302
- header.addEventListener('click', function() {
303
- window.location.href = s.url + '?token=' + encodeURIComponent(s.token);
478
+ // Update toolbar button states
479
+ var toolbar = contentEl.parentElement.querySelector('.grid-toolbar');
480
+ if (toolbar) {
481
+ toolbar.querySelectorAll('[data-mode]').forEach(function(btn) {
482
+ btn.classList.toggle('active', btn.dataset.mode === mode);
304
483
  });
484
+ var presetsEl = document.getElementById('tmux-presets');
485
+ if (presetsEl) presetsEl.classList.toggle('hidden', mode !== 'tmux');
486
+ }
305
487
 
306
- gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: panelWs, session: s });
488
+ // Detach all panels without destroying them
489
+ gridTerminals.forEach(function(gt, i) {
490
+ if (gt.panel.parentNode) gt.panel.parentNode.removeChild(gt.panel);
491
+ gt.panel.className = 'grid-panel';
492
+ gt.panel.dataset.index = i;
493
+ var termDiv = gt.panel.querySelector('.grid-panel-terminal');
494
+ if (termDiv) termDiv.style.cssText = '';
495
+ gt.panel.style.cssText = '';
307
496
  });
308
497
 
309
- // Handle window resize for grid panels
310
- window.addEventListener('resize', fitGridPanels);
498
+ // Remove leftover elements (focus-strips, back-to-grid button)
499
+ while (contentEl.firstChild) contentEl.removeChild(contentEl.firstChild);
500
+
501
+ // Reset content styles
502
+ contentEl.className = 'mode-' + mode;
503
+ contentEl.style.cssText = '';
504
+
505
+ switch (mode) {
506
+ case 'thumbnails':
507
+ gridTerminals.forEach(function(gt) {
508
+ gt.panel.classList.add('thumbnail');
509
+ var termDiv = gt.panel.querySelector('.grid-panel-terminal');
510
+ termDiv.style.width = '560px';
511
+ termDiv.style.height = '360px';
512
+ termDiv.style.transform = 'scale(0.5)';
513
+ termDiv.style.transformOrigin = 'top left';
514
+ contentEl.appendChild(gt.panel);
515
+ });
516
+ break;
517
+
518
+ case 'tmux':
519
+ gridTerminals.forEach(function(gt, i) {
520
+ if (i === focusedIndex) gt.panel.classList.add('active');
521
+ contentEl.appendChild(gt.panel);
522
+ });
523
+ if (tmuxPreset === 'equal') {
524
+ var cols = gridTerminals.length <= 2 ? gridTerminals.length : gridTerminals.length <= 4 ? 2 : 3;
525
+ contentEl.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
526
+ } else if (tmuxPreset === 'main-side') {
527
+ contentEl.style.gridTemplateColumns = '70% 30%';
528
+ var sideCount = Math.max(gridTerminals.length - 1, 1);
529
+ contentEl.style.gridTemplateRows = 'repeat(' + sideCount + ', 1fr)';
530
+ if (gridTerminals.length > 0) gridTerminals[0].panel.style.gridRow = '1 / -1';
531
+ } else if (tmuxPreset === 'stacked') {
532
+ contentEl.style.gridTemplateColumns = '1fr';
533
+ }
534
+ break;
535
+
536
+ case 'focus':
537
+ var mainGt = gridTerminals[focusedIndex];
538
+ mainGt.panel.classList.add('focus-main');
539
+ contentEl.appendChild(mainGt.panel);
540
+ if (gridTerminals.length > 1) {
541
+ var stripsEl = document.createElement('div');
542
+ stripsEl.className = 'focus-strips';
543
+ gridTerminals.forEach(function(gt, i) {
544
+ if (i === focusedIndex) return;
545
+ gt.panel.classList.add('focus-strip');
546
+ stripsEl.appendChild(gt.panel);
547
+ });
548
+ contentEl.appendChild(stripsEl);
549
+ }
550
+ break;
551
+
552
+ case 'fullscreen':
553
+ var fullGt = gridTerminals[focusedIndex];
554
+ fullGt.panel.classList.add('fullscreen');
555
+ contentEl.appendChild(fullGt.panel);
556
+ var backBtn = document.createElement('button');
557
+ backBtn.className = 'back-to-grid';
558
+ backBtn.textContent = '\u2190 Grid';
559
+ backBtn.addEventListener('click', function() { switchGridMode('thumbnails'); });
560
+ contentEl.appendChild(backBtn);
561
+ break;
562
+ }
311
563
 
312
- // Add back button
313
- if ($('#btn-sessions')) { $('#btn-sessions').textContent = 'List'; }
564
+ // Fit visible terminals after DOM settles
565
+ setTimeout(function() {
566
+ gridTerminals.forEach(function(gt) {
567
+ if (!document.contains(gt.panel)) return;
568
+ if (gt.fitAddon) {
569
+ try {
570
+ gt.fitAddon.fit();
571
+ if (gt.ws && gt.ws.readyState === WebSocket.OPEN && gt.xterm) {
572
+ gt.ws.send(JSON.stringify({ type: 'pty_resize', cols: gt.xterm.cols, rows: gt.xterm.rows }));
573
+ }
574
+ } catch(e) {}
575
+ }
576
+ });
577
+ }, 100);
314
578
  }
315
579
 
580
+ var gridResizeTimer = null;
316
581
  function fitGridPanels() {
317
- gridTerminals.forEach(function(gt) {
318
- if (gt.fitAddon) { try { gt.fitAddon.fit(); } catch(e) {} }
319
- });
582
+ if (gridResizeTimer) clearTimeout(gridResizeTimer);
583
+ gridResizeTimer = setTimeout(function() {
584
+ gridTerminals.forEach(function(gt) {
585
+ if (!document.contains(gt.panel)) return;
586
+ if (gt.fitAddon) {
587
+ try {
588
+ var prevCols = gt.xterm ? gt.xterm.cols : 0;
589
+ var prevRows = gt.xterm ? gt.xterm.rows : 0;
590
+ gt.fitAddon.fit();
591
+ if (gt.ws && gt.ws.readyState === WebSocket.OPEN && gt.xterm &&
592
+ (gt.xterm.cols !== prevCols || gt.xterm.rows !== prevRows)) {
593
+ gt.ws.send(JSON.stringify({ type: 'pty_resize', cols: gt.xterm.cols, rows: gt.xterm.rows }));
594
+ }
595
+ } catch(e) {}
596
+ }
597
+ });
598
+ }, 150);
320
599
  }
321
600
 
322
601
  function destroyGrid() {
@@ -328,6 +607,10 @@
328
607
  window.removeEventListener('resize', fitGridPanels);
329
608
  var gridEl = document.getElementById('grid-view');
330
609
  if (gridEl) { gridEl.innerHTML = ''; gridEl.classList.add('hidden'); }
610
+ $('#input-area').classList.add('hidden');
611
+ gridMode = 'thumbnails';
612
+ focusedIndex = 0;
613
+ tmuxPreset = 'equal';
331
614
  }
332
615
 
333
616
  window.toggleView = () => {
@@ -519,7 +802,7 @@
519
802
  }
520
803
 
521
804
  // ─── Detect hub mode (no token in URL) ────────────────────
522
- const isHubMode = !new URLSearchParams(window.location.search).get('token');
805
+ const isHubMode = new URLSearchParams(window.location.search).get('hub') === '1';
523
806
 
524
807
  // ─── WebSocket ───────────────────────────────────────────
525
808
  let reconnectAttempt = 0;
@@ -544,7 +827,7 @@
544
827
 
545
828
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
546
829
 
547
- // F-02: Try ticket-based auth first
830
+ // F-02: Ticket-based auth (required)
548
831
  try {
549
832
  const resp = await fetch('/api/auth/ticket', {
550
833
  method: 'POST',
@@ -554,19 +837,20 @@
554
837
  const { ticket } = await resp.json();
555
838
  ws = new WebSocket(`${proto}//${location.host}?ticket=${encodeURIComponent(ticket)}`);
556
839
  } else {
557
- // Fallback to token-in-URL (backward compat)
558
- ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`);
840
+ setStatus('offline', 'Auth failed');
841
+ return;
559
842
  }
560
843
  } catch {
561
- // Fallback to token-in-URL
562
- ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`);
844
+ setStatus('offline', 'Auth failed');
845
+ return;
563
846
  }
564
847
  setStatus('connecting', 'Connecting...');
565
848
 
566
849
  ws.onopen = () => {
567
850
  connected = true;
568
851
  reconnectAttempt = 0;
569
- setTimeout(() => initializeACP(1), 1000);
852
+ setStatus('online', 'Connected');
853
+ // PTY mode: server sends pty data immediately, no ACP handshake needed
570
854
  };
571
855
  ws.onclose = () => {
572
856
  connected = false; acpReady = false; sessionId = null;
@@ -714,6 +998,33 @@
714
998
  };
715
999
 
716
1000
  // ─── Mobile Key Bar ───────────────────────────────────────
1001
+ // F-5: Event delegation for key-bar buttons (no inline onclick)
1002
+ const keyBar = document.getElementById('key-bar');
1003
+ if (keyBar) {
1004
+ var keyMap = {
1005
+ '\\x1b[A': '\x1b[A', '\\x1b[B': '\x1b[B', '\\x1b[C': '\x1b[C', '\\x1b[D': '\x1b[D',
1006
+ '\\t': '\t', '\\r': '\r', '\\x1b': '\x1b', '\\x03': '\x03', ' ': ' ', '\\x7f': '\x7f',
1007
+ };
1008
+ keyBar.addEventListener('click', function(e) {
1009
+ var btn = e.target;
1010
+ if (btn && btn.tagName === 'BUTTON' && btn.dataset.key) {
1011
+ var key = keyMap[btn.dataset.key] || btn.dataset.key;
1012
+ if (currentView === 'grid' && gridMode === 'fullscreen' && gridTerminals[focusedIndex]) {
1013
+ var gt = gridTerminals[focusedIndex];
1014
+ if (gt.ws && gt.ws.readyState === WebSocket.OPEN) {
1015
+ gt.ws.send(JSON.stringify({ type: 'pty_input', data: key }));
1016
+ }
1017
+ if (gt.xterm) gt.xterm.focus();
1018
+ } else {
1019
+ if (ws && ws.readyState === WebSocket.OPEN) {
1020
+ ws.send(JSON.stringify({ type: 'pty_input', data: key }));
1021
+ }
1022
+ if (xterm) xterm.focus();
1023
+ }
1024
+ }
1025
+ });
1026
+ }
1027
+
717
1028
  window.sendKey = (key) => {
718
1029
  if (ws && ws.readyState === WebSocket.OPEN) {
719
1030
  ws.send(JSON.stringify({ type: 'pty_input', data: key }));
@@ -758,7 +1069,7 @@
758
1069
  requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
759
1070
  }
760
1071
  function escapeHtml(s) {
761
- const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML.replace(/'/g, '&#39;');
1072
+ const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML.replace(/'/g, '&#39;').replace(/"/g, '&quot;');
762
1073
  }
763
1074
  function formatText(text) {
764
1075
  return escapeHtml(text)
@@ -32,16 +32,16 @@
32
32
 
33
33
  <footer id="input-area">
34
34
  <div id="key-bar">
35
- <button onclick="sendKey('\x1b[A')">↑</button>
36
- <button onclick="sendKey('\x1b[B')">↓</button>
37
- <button onclick="sendKey('\x1b[C')">→</button>
38
- <button onclick="sendKey('\x1b[D')">←</button>
39
- <button onclick="sendKey('\t')">Tab</button>
40
- <button onclick="sendKey('\r')">Enter</button>
41
- <button onclick="sendKey('\x1b')">Esc</button>
42
- <button onclick="sendKey('\x03')">Ctrl+C</button>
43
- <button onclick="sendKey(' ')">Space</button>
44
- <button onclick="sendKey('\x7f')">⌫</button>
35
+ <button data-key="\x1b[A">↑</button>
36
+ <button data-key="\x1b[B">↓</button>
37
+ <button data-key="\x1b[C">→</button>
38
+ <button data-key="\x1b[D">←</button>
39
+ <button data-key="\t">Tab</button>
40
+ <button data-key="\r">Enter</button>
41
+ <button data-key="\x1b">Esc</button>
42
+ <button data-key="\x03">Ctrl+C</button>
43
+ <button data-key=" ">Space</button>
44
+ <button data-key="\x7f">⌫</button>
45
45
  </div>
46
46
  <form id="input-form">
47
47
  <span class="prompt">&gt;</span>