cli-tunnel 1.2.0-beta.8 → 1.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.
package/remote-ui/app.js CHANGED
@@ -45,7 +45,12 @@
45
45
  const permOverlay = $('#permission-overlay');
46
46
  const dashboard = $('#dashboard');
47
47
  const termContainer = $('#terminal-container');
48
- let currentView = 'terminal'; // 'dashboard' or 'terminal'
48
+ let currentView = 'terminal'; // 'dashboard', 'terminal', or 'grid'
49
+ let cachedSessions = [];
50
+ let gridTerminals = []; // { xterm, fitAddon, ws, session, panel }
51
+ var gridMode = 'thumbnails';
52
+ var focusedIndex = 0;
53
+ var tmuxPreset = 'equal';
49
54
 
50
55
  // ─── xterm.js Terminal ───────────────────────────────────
51
56
  let xterm = null;
@@ -115,7 +120,9 @@
115
120
 
116
121
  async function loadSessions() {
117
122
  try {
118
- const resp = await fetch('/api/sessions');
123
+ const tokenParam = new URLSearchParams(window.location.search).get('token');
124
+ const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
125
+ const resp = await fetch('/api/sessions', { headers });
119
126
  const data = await resp.json();
120
127
  renderDashboard(data.sessions || []);
121
128
  } catch (err) {
@@ -127,41 +134,69 @@
127
134
  const filtered = showOffline ? sessions : sessions.filter(s => s.online);
128
135
  const offlineCount = sessions.filter(s => !s.online).length;
129
136
  const onlineCount = sessions.filter(s => s.online).length;
137
+ const connectable = filtered.filter(s => s.online && s.token);
130
138
 
131
139
  let html = `<div style="padding:8px 4px;display:flex;align-items:center;gap:8px">
132
140
  <span style="color:var(--text-dim);font-size:12px">${onlineCount} online${offlineCount > 0 ? ', ' + offlineCount + ' offline' : ''}</span>
133
141
  <span style="flex:1"></span>
134
- <button onclick="toggleOffline()" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">${showOffline ? 'Hide offline' : 'Show offline'}</button>
135
- ${offlineCount > 0 ? '<button onclick="cleanOffline()" style="background:none;border:1px solid var(--red);color:var(--red);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">Clean offline</button>' : ''}
136
- <button onclick="loadSessions()" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">↻</button>
142
+ ${connectable.length > 1 ? '<button data-action="grid-view" style="background:none;border:1px solid var(--blue);color:var(--blue);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">⊞ Grid</button>' : ''}
143
+ <button data-action="toggle-offline" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">${showOffline ? 'Hide offline' : 'Show offline'}</button>
144
+ ${offlineCount > 0 ? '<button data-action="clean-offline" style="background:none;border:1px solid var(--red);color:var(--red);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">Clean offline</button>' : ''}
145
+ <button data-action="refresh" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">↻</button>
137
146
  </div>`;
138
147
 
139
148
  if (filtered.length === 0) {
140
149
  html += '<div style="padding:20px 12px;color:var(--text-dim);text-align:center">' +
141
- (sessions.length === 0 ? 'No Squad RC sessions found.' : 'No online sessions. Tap "Show offline" to see stale ones.') +
150
+ (sessions.length === 0 ? 'No cli-tunnel sessions found.' : 'No online sessions. Tap "Show offline" to see stale ones.') +
142
151
  '</div>';
143
152
  } else {
144
- html += filtered.map(s => `
145
- <div class="session-card" ${s.online ? 'data-session-url="' + escapeHtml(s.url) + '"' : ''}>
153
+ html += filtered.map(s => {
154
+ const hasAccess = s.hasToken;
155
+ return `
156
+ <div class="session-card" ${s.online && hasAccess ? 'data-session-port="' + s.port + '" data-session-base-url="' + escapeHtml(s.url) + '"' : ''}>
146
157
  <span class="status-dot ${s.online ? 'online' : 'offline'}"></span>
147
158
  <div class="info">
159
+ <div class="session-name">${escapeHtml(s.name)}</div>
148
160
  <div class="repo">📦 ${escapeHtml(s.repo)}</div>
149
161
  <div class="branch">🌿 ${escapeHtml(s.branch)}</div>
150
- <div class="machine">💻 ${escapeHtml(s.machine)}</div>
162
+ <div class="machine">💻 ${escapeHtml(s.machine)}${!hasAccess && s.online ? ' 🔒' : ''}</div>
151
163
  </div>
152
- ${s.online ? '<span class="arrow">→</span>' :
153
- '<button data-delete-id="' + escapeHtml(s.id) + '" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px" title="Remove">✕</button>'}
154
- </div>
155
- `).join('');
164
+ ${s.online && hasAccess ? '<span class="arrow">→</span>' :
165
+ !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>'
166
+ : '<span style="color:var(--text-dim);font-size:11px">remote</span>'}
167
+ </div>`;
168
+ }).join('');
156
169
  }
157
170
  dashboard.innerHTML = html;
158
- // #16: XSS fix — use event delegation instead of inline onclick
159
- dashboard.querySelectorAll('.session-card[data-session-url]').forEach(function(card) {
160
- card.addEventListener('click', function() { openSession(card.dataset.sessionUrl); });
171
+ cachedSessions = sessions;
172
+ // Event delegation
173
+ dashboard.querySelectorAll('.session-card[data-session-port]').forEach(function(card) {
174
+ card.addEventListener('click', function() {
175
+ var port = card.dataset.sessionPort;
176
+ var baseUrl = card.dataset.sessionBaseUrl;
177
+ var tokenParam = new URLSearchParams(window.location.search).get('token');
178
+ var proxyUrl = '/api/proxy/ticket/' + port;
179
+ fetch(proxyUrl, {
180
+ method: 'POST',
181
+ headers: tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {}
182
+ }).then(function(r) { return r.json(); }).then(function(data) {
183
+ if (data.ticket) {
184
+ window.location.href = baseUrl + '?ticket=' + encodeURIComponent(data.ticket);
185
+ } else {
186
+ window.location.href = baseUrl;
187
+ }
188
+ }).catch(function() {
189
+ window.location.href = baseUrl;
190
+ });
191
+ });
161
192
  });
162
193
  dashboard.querySelectorAll('[data-delete-id]').forEach(function(btn) {
163
194
  btn.addEventListener('click', function(e) { e.stopPropagation(); deleteSession(btn.dataset.deleteId); });
164
195
  });
196
+ dashboard.querySelector('[data-action="toggle-offline"]')?.addEventListener('click', function() { toggleOffline(); });
197
+ dashboard.querySelector('[data-action="clean-offline"]')?.addEventListener('click', function() { cleanOffline(); });
198
+ dashboard.querySelector('[data-action="refresh"]')?.addEventListener('click', function() { loadSessions(); });
199
+ dashboard.querySelector('[data-action="grid-view"]')?.addEventListener('click', function() { showGridView(sessions); });
165
200
  }
166
201
 
167
202
  window.openSession = (url) => {
@@ -174,21 +209,404 @@
174
209
  };
175
210
 
176
211
  window.cleanOffline = async () => {
177
- const resp = await fetch('/api/sessions');
212
+ const tokenParam = new URLSearchParams(window.location.search).get('token');
213
+ const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
214
+ const resp = await fetch('/api/sessions', { headers });
178
215
  const data = await resp.json();
179
216
  const offline = (data.sessions || []).filter(s => !s.online);
180
217
  for (const s of offline) {
181
- await fetch('/api/sessions/' + s.id, { method: 'DELETE' });
218
+ await fetch('/api/sessions/' + s.id, { method: 'DELETE', headers });
182
219
  }
183
220
  loadSessions();
184
221
  };
185
222
 
186
223
  window.deleteSession = async (id) => {
187
- await fetch('/api/sessions/' + id, { method: 'DELETE' });
224
+ const tokenParam = new URLSearchParams(window.location.search).get('token');
225
+ const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
226
+ await fetch('/api/sessions/' + id, { method: 'DELETE', headers });
188
227
  loadSessions();
189
228
  };
190
229
 
230
+ // ─── Grid View (multi-terminal with layout modes) ───────────
231
+ function showGridView(sessions) {
232
+ var connectable = sessions.filter(function(s) { return s.online && s.token; });
233
+ if (connectable.length === 0) return;
234
+
235
+ // Clean up previous grid
236
+ destroyGrid();
237
+
238
+ currentView = 'grid';
239
+ gridMode = 'thumbnails';
240
+ focusedIndex = 0;
241
+ tmuxPreset = 'equal';
242
+ dashboard.classList.add('hidden');
243
+ terminal.classList.add('hidden');
244
+ termContainer.classList.add('hidden');
245
+ $('#input-area').classList.add('hidden');
246
+
247
+ var gridEl = document.getElementById('grid-view');
248
+ if (!gridEl) {
249
+ gridEl = document.createElement('div');
250
+ gridEl.id = 'grid-view';
251
+ document.getElementById('app').insertBefore(gridEl, document.getElementById('input-area'));
252
+ }
253
+ gridEl.classList.remove('hidden');
254
+ gridEl.innerHTML = '';
255
+
256
+ // ── Toolbar ──
257
+ var toolbar = document.createElement('div');
258
+ toolbar.className = 'grid-toolbar';
259
+
260
+ var modes = [
261
+ { id: 'thumbnails', label: '\u229E Tiles' },
262
+ { id: 'tmux', label: '\u229F Tmux' },
263
+ { id: 'focus', label: '\u25C9 Focus' },
264
+ { id: 'fullscreen', label: '\u2A21 Full' }
265
+ ];
266
+ modes.forEach(function(m) {
267
+ var btn = document.createElement('button');
268
+ btn.textContent = m.label;
269
+ btn.dataset.mode = m.id;
270
+ if (m.id === gridMode) btn.classList.add('active');
271
+ btn.addEventListener('click', function() { switchGridMode(m.id); });
272
+ toolbar.appendChild(btn);
273
+ });
274
+
275
+ // Tmux preset buttons (visible only in tmux mode)
276
+ var presetGroup = document.createElement('span');
277
+ presetGroup.className = 'grid-toolbar-presets hidden';
278
+ presetGroup.id = 'tmux-presets';
279
+ var presets = [
280
+ { id: 'equal', label: '\u2550 Equal' },
281
+ { id: 'main-side', label: '\u2590 Main+Side' },
282
+ { id: 'stacked', label: '\u2261 Stacked' }
283
+ ];
284
+ presets.forEach(function(p) {
285
+ var btn = document.createElement('button');
286
+ btn.textContent = p.label;
287
+ btn.dataset.preset = p.id;
288
+ if (p.id === tmuxPreset) btn.classList.add('active');
289
+ btn.addEventListener('click', function() { switchTmuxPreset(p.id); });
290
+ presetGroup.appendChild(btn);
291
+ });
292
+ toolbar.appendChild(presetGroup);
293
+
294
+ var spacer = document.createElement('span');
295
+ spacer.className = 'spacer';
296
+ toolbar.appendChild(spacer);
297
+
298
+ var listBtn = document.createElement('button');
299
+ listBtn.textContent = '\u2190 List';
300
+ listBtn.addEventListener('click', function() {
301
+ destroyGrid();
302
+ currentView = 'dashboard';
303
+ dashboard.classList.remove('hidden');
304
+ if ($('#btn-sessions')) $('#btn-sessions').textContent = 'Terminal';
305
+ loadSessions();
306
+ });
307
+ toolbar.appendChild(listBtn);
308
+ gridEl.appendChild(toolbar);
309
+
310
+ // ── Content container ──
311
+ var contentEl = document.createElement('div');
312
+ contentEl.id = 'grid-content';
313
+ gridEl.appendChild(contentEl);
314
+
315
+ // ── Create panels & connect ──
316
+ connectable.forEach(function(s, index) {
317
+ var panel = document.createElement('div');
318
+ panel.className = 'grid-panel';
319
+ panel.dataset.index = index;
320
+
321
+ var header = document.createElement('div');
322
+ header.className = 'grid-panel-header';
323
+ var nameSpan = document.createElement('span');
324
+ nameSpan.className = 'grid-panel-name';
325
+ nameSpan.textContent = s.name;
326
+ var machineSpan = document.createElement('span');
327
+ machineSpan.className = 'grid-panel-machine';
328
+ machineSpan.textContent = s.machine;
329
+ var statusDot = document.createElement('span');
330
+ statusDot.className = 'grid-panel-status';
331
+ statusDot.textContent = '\u25CF';
332
+ header.appendChild(nameSpan);
333
+ header.appendChild(machineSpan);
334
+ header.appendChild(statusDot);
335
+ panel.appendChild(header);
336
+
337
+ var termDiv = document.createElement('div');
338
+ termDiv.className = 'grid-panel-terminal';
339
+ panel.appendChild(termDiv);
340
+
341
+ // Append to contentEl so xterm.open has a DOM-attached container
342
+ contentEl.appendChild(panel);
343
+
344
+ // xterm instance
345
+ var panelXterm = new Terminal({
346
+ theme: {
347
+ background: '#0d1117', foreground: '#c9d1d9', cursor: '#3fb950',
348
+ selectionBackground: '#264f78',
349
+ },
350
+ fontFamily: "'Cascadia Code', 'SF Mono', 'Fira Code', 'Menlo', monospace",
351
+ fontSize: 11,
352
+ scrollback: 1000,
353
+ cursorBlink: true,
354
+ });
355
+ var panelFit = new FitAddon.FitAddon();
356
+ panelXterm.loadAddon(panelFit);
357
+ panelXterm.open(termDiv);
358
+
359
+ // Store entry before async connect so index is stable
360
+ var entry = { xterm: panelXterm, fitAddon: panelFit, ws: null, session: s, panel: panel };
361
+ gridTerminals.push(entry);
362
+
363
+ // Connect WebSocket to this session
364
+ (function connectPanel() {
365
+ // Use hub's proxy endpoint to get a ticket for the session
366
+ var tokenParam = new URLSearchParams(window.location.search).get('token');
367
+ var proxyUrl = '/api/proxy/ticket/' + s.port;
368
+ var wsBase = s.isLocal ? 'ws://127.0.0.1:' + s.port : s.url.replace('https://', 'wss://');
369
+
370
+ fetch(proxyUrl, {
371
+ method: 'POST',
372
+ headers: { 'Authorization': 'Bearer ' + tokenParam }
373
+ }).then(function(resp) {
374
+ if (!resp.ok) throw new Error('Auth failed');
375
+ return resp.json();
376
+ }).then(function(data) {
377
+ var panelWs = new WebSocket(wsBase + '?ticket=' + encodeURIComponent(data.ticket));
378
+ entry.ws = panelWs;
379
+
380
+ panelWs.onopen = function() {
381
+ if (statusDot) { statusDot.style.color = 'var(--green)'; statusDot.title = 'Connected'; }
382
+ panelWs.send(JSON.stringify({ type: 'pty_resize', cols: panelXterm.cols, rows: panelXterm.rows }));
383
+ };
384
+ panelWs.onclose = function() {
385
+ if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Disconnected'; }
386
+ };
387
+ panelWs.onerror = function() {
388
+ if (statusDot) { statusDot.style.color = 'var(--red)'; }
389
+ };
390
+ panelWs.onmessage = function(e) {
391
+ try {
392
+ var msg = JSON.parse(e.data);
393
+ if (msg.type === 'pty') {
394
+ panelXterm.write(msg.data);
395
+ }
396
+ } catch (err) {}
397
+ };
398
+
399
+ panelXterm.onData(function(data) {
400
+ if (panelWs && panelWs.readyState === WebSocket.OPEN) {
401
+ panelWs.send(JSON.stringify({ type: 'pty_input', data: data }));
402
+ }
403
+ });
404
+ }).catch(function() {
405
+ if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Auth failed'; }
406
+ });
407
+ })();
408
+ });
409
+
410
+ // ── Event delegation for panel clicks ──
411
+ contentEl.addEventListener('click', function(e) {
412
+ var panel = e.target.closest('.grid-panel');
413
+ if (!panel) return;
414
+ var idx = parseInt(panel.dataset.index, 10);
415
+ if (isNaN(idx)) return;
416
+
417
+ if (gridMode === 'thumbnails') {
418
+ focusedIndex = idx;
419
+ switchGridMode('fullscreen');
420
+ } else if (gridMode === 'focus' && panel.classList.contains('focus-strip')) {
421
+ focusedIndex = idx;
422
+ applyGridLayout('focus');
423
+ } else if (gridMode === 'tmux') {
424
+ focusedIndex = idx;
425
+ contentEl.querySelectorAll('.grid-panel').forEach(function(p) { p.classList.remove('active'); });
426
+ panel.classList.add('active');
427
+ }
428
+ });
429
+
430
+ // Apply initial layout
431
+ applyGridLayout(gridMode);
432
+
433
+ // Handle window resize
434
+ window.removeEventListener('resize', fitGridPanels);
435
+ window.addEventListener('resize', fitGridPanels);
436
+ if ($('#btn-sessions')) { $('#btn-sessions').textContent = 'List'; }
437
+ }
438
+
439
+ function switchGridMode(mode) {
440
+ gridMode = mode;
441
+ if (mode === 'fullscreen') {
442
+ $('#input-area').classList.remove('hidden');
443
+ $('#input-form').classList.add('hidden');
444
+ } else {
445
+ $('#input-area').classList.add('hidden');
446
+ }
447
+ applyGridLayout(mode);
448
+ }
449
+
450
+ function switchTmuxPreset(preset) {
451
+ tmuxPreset = preset;
452
+ var presetGroup = document.getElementById('tmux-presets');
453
+ if (presetGroup) {
454
+ presetGroup.querySelectorAll('[data-preset]').forEach(function(btn) {
455
+ btn.classList.toggle('active', btn.dataset.preset === preset);
456
+ });
457
+ }
458
+ if (gridMode === 'tmux') applyGridLayout('tmux');
459
+ }
460
+
461
+ function applyGridLayout(mode) {
462
+ gridMode = mode;
463
+ var contentEl = document.getElementById('grid-content');
464
+ if (!contentEl || gridTerminals.length === 0) return;
465
+
466
+ // Clamp focusedIndex
467
+ if (focusedIndex >= gridTerminals.length) focusedIndex = 0;
468
+
469
+ // Update toolbar button states
470
+ var toolbar = contentEl.parentElement.querySelector('.grid-toolbar');
471
+ if (toolbar) {
472
+ toolbar.querySelectorAll('[data-mode]').forEach(function(btn) {
473
+ btn.classList.toggle('active', btn.dataset.mode === mode);
474
+ });
475
+ var presetsEl = document.getElementById('tmux-presets');
476
+ if (presetsEl) presetsEl.classList.toggle('hidden', mode !== 'tmux');
477
+ }
478
+
479
+ // Detach all panels without destroying them
480
+ gridTerminals.forEach(function(gt, i) {
481
+ if (gt.panel.parentNode) gt.panel.parentNode.removeChild(gt.panel);
482
+ gt.panel.className = 'grid-panel';
483
+ gt.panel.dataset.index = i;
484
+ var termDiv = gt.panel.querySelector('.grid-panel-terminal');
485
+ if (termDiv) termDiv.style.cssText = '';
486
+ gt.panel.style.cssText = '';
487
+ });
488
+
489
+ // Remove leftover elements (focus-strips, back-to-grid button)
490
+ while (contentEl.firstChild) contentEl.removeChild(contentEl.firstChild);
491
+
492
+ // Reset content styles
493
+ contentEl.className = 'mode-' + mode;
494
+ contentEl.style.cssText = '';
495
+
496
+ switch (mode) {
497
+ case 'thumbnails':
498
+ gridTerminals.forEach(function(gt) {
499
+ gt.panel.classList.add('thumbnail');
500
+ var termDiv = gt.panel.querySelector('.grid-panel-terminal');
501
+ termDiv.style.width = '560px';
502
+ termDiv.style.height = '360px';
503
+ termDiv.style.transform = 'scale(0.5)';
504
+ termDiv.style.transformOrigin = 'top left';
505
+ contentEl.appendChild(gt.panel);
506
+ });
507
+ break;
508
+
509
+ case 'tmux':
510
+ gridTerminals.forEach(function(gt, i) {
511
+ if (i === focusedIndex) gt.panel.classList.add('active');
512
+ contentEl.appendChild(gt.panel);
513
+ });
514
+ if (tmuxPreset === 'equal') {
515
+ var cols = gridTerminals.length <= 2 ? gridTerminals.length : gridTerminals.length <= 4 ? 2 : 3;
516
+ contentEl.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
517
+ } else if (tmuxPreset === 'main-side') {
518
+ contentEl.style.gridTemplateColumns = '70% 30%';
519
+ var sideCount = Math.max(gridTerminals.length - 1, 1);
520
+ contentEl.style.gridTemplateRows = 'repeat(' + sideCount + ', 1fr)';
521
+ if (gridTerminals.length > 0) gridTerminals[0].panel.style.gridRow = '1 / -1';
522
+ } else if (tmuxPreset === 'stacked') {
523
+ contentEl.style.gridTemplateColumns = '1fr';
524
+ }
525
+ break;
526
+
527
+ case 'focus':
528
+ var mainGt = gridTerminals[focusedIndex];
529
+ mainGt.panel.classList.add('focus-main');
530
+ contentEl.appendChild(mainGt.panel);
531
+ if (gridTerminals.length > 1) {
532
+ var stripsEl = document.createElement('div');
533
+ stripsEl.className = 'focus-strips';
534
+ gridTerminals.forEach(function(gt, i) {
535
+ if (i === focusedIndex) return;
536
+ gt.panel.classList.add('focus-strip');
537
+ stripsEl.appendChild(gt.panel);
538
+ });
539
+ contentEl.appendChild(stripsEl);
540
+ }
541
+ break;
542
+
543
+ case 'fullscreen':
544
+ var fullGt = gridTerminals[focusedIndex];
545
+ fullGt.panel.classList.add('fullscreen');
546
+ contentEl.appendChild(fullGt.panel);
547
+ var backBtn = document.createElement('button');
548
+ backBtn.className = 'back-to-grid';
549
+ backBtn.textContent = '\u2190 Grid';
550
+ backBtn.addEventListener('click', function() { switchGridMode('thumbnails'); });
551
+ contentEl.appendChild(backBtn);
552
+ break;
553
+ }
554
+
555
+ // Fit visible terminals after DOM settles
556
+ setTimeout(function() {
557
+ gridTerminals.forEach(function(gt) {
558
+ if (!document.contains(gt.panel)) return;
559
+ if (gt.fitAddon) {
560
+ try {
561
+ gt.fitAddon.fit();
562
+ if (gt.ws && gt.ws.readyState === WebSocket.OPEN && gt.xterm) {
563
+ gt.ws.send(JSON.stringify({ type: 'pty_resize', cols: gt.xterm.cols, rows: gt.xterm.rows }));
564
+ }
565
+ } catch(e) {}
566
+ }
567
+ });
568
+ }, 100);
569
+ }
570
+
571
+ function fitGridPanels() {
572
+ gridTerminals.forEach(function(gt) {
573
+ if (!document.contains(gt.panel)) return;
574
+ if (gt.fitAddon) {
575
+ try {
576
+ gt.fitAddon.fit();
577
+ if (gt.ws && gt.ws.readyState === WebSocket.OPEN && gt.xterm) {
578
+ gt.ws.send(JSON.stringify({ type: 'pty_resize', cols: gt.xterm.cols, rows: gt.xterm.rows }));
579
+ }
580
+ } catch(e) {}
581
+ }
582
+ });
583
+ }
584
+
585
+ function destroyGrid() {
586
+ gridTerminals.forEach(function(gt) {
587
+ if (gt.ws) { try { gt.ws.close(); } catch(e) {} }
588
+ if (gt.xterm) { try { gt.xterm.dispose(); } catch(e) {} }
589
+ });
590
+ gridTerminals = [];
591
+ window.removeEventListener('resize', fitGridPanels);
592
+ var gridEl = document.getElementById('grid-view');
593
+ if (gridEl) { gridEl.innerHTML = ''; gridEl.classList.add('hidden'); }
594
+ $('#input-area').classList.add('hidden');
595
+ gridMode = 'thumbnails';
596
+ focusedIndex = 0;
597
+ tmuxPreset = 'equal';
598
+ }
599
+
191
600
  window.toggleView = () => {
601
+ if (currentView === 'grid') {
602
+ // Grid → dashboard (list view)
603
+ destroyGrid();
604
+ currentView = 'dashboard';
605
+ dashboard.classList.remove('hidden');
606
+ $('#btn-sessions').textContent = 'Terminal';
607
+ loadSessions();
608
+ return;
609
+ }
192
610
  if (currentView === 'terminal') {
193
611
  currentView = 'dashboard';
194
612
  terminal.classList.add('hidden');
@@ -198,6 +616,7 @@
198
616
  $('#btn-sessions').textContent = 'Terminal';
199
617
  loadSessions();
200
618
  } else {
619
+ destroyGrid();
201
620
  currentView = 'terminal';
202
621
  dashboard.classList.add('hidden');
203
622
  $('#input-area').classList.remove('hidden');
@@ -367,7 +786,7 @@
367
786
  }
368
787
 
369
788
  // ─── Detect hub mode (no token in URL) ────────────────────
370
- const isHubMode = !new URLSearchParams(window.location.search).get('token');
789
+ const isHubMode = new URLSearchParams(window.location.search).get('hub') === '1';
371
790
 
372
791
  // ─── WebSocket ───────────────────────────────────────────
373
792
  let reconnectAttempt = 0;
@@ -392,7 +811,7 @@
392
811
 
393
812
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
394
813
 
395
- // F-02: Try ticket-based auth first
814
+ // F-02: Ticket-based auth (required)
396
815
  try {
397
816
  const resp = await fetch('/api/auth/ticket', {
398
817
  method: 'POST',
@@ -402,12 +821,12 @@
402
821
  const { ticket } = await resp.json();
403
822
  ws = new WebSocket(`${proto}//${location.host}?ticket=${encodeURIComponent(ticket)}`);
404
823
  } else {
405
- // Fallback to token-in-URL (backward compat)
406
- ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`);
824
+ setStatus('offline', 'Auth failed');
825
+ return;
407
826
  }
408
827
  } catch {
409
- // Fallback to token-in-URL
410
- ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`);
828
+ setStatus('offline', 'Auth failed');
829
+ return;
411
830
  }
412
831
  setStatus('connecting', 'Connecting...');
413
832
 
@@ -562,6 +981,33 @@
562
981
  };
563
982
 
564
983
  // ─── Mobile Key Bar ───────────────────────────────────────
984
+ // F-5: Event delegation for key-bar buttons (no inline onclick)
985
+ const keyBar = document.getElementById('key-bar');
986
+ if (keyBar) {
987
+ var keyMap = {
988
+ '\\x1b[A': '\x1b[A', '\\x1b[B': '\x1b[B', '\\x1b[C': '\x1b[C', '\\x1b[D': '\x1b[D',
989
+ '\\t': '\t', '\\r': '\r', '\\x1b': '\x1b', '\\x03': '\x03', ' ': ' ', '\\x7f': '\x7f',
990
+ };
991
+ keyBar.addEventListener('click', function(e) {
992
+ var btn = e.target;
993
+ if (btn && btn.tagName === 'BUTTON' && btn.dataset.key) {
994
+ var key = keyMap[btn.dataset.key] || btn.dataset.key;
995
+ if (currentView === 'grid' && gridMode === 'fullscreen' && gridTerminals[focusedIndex]) {
996
+ var gt = gridTerminals[focusedIndex];
997
+ if (gt.ws && gt.ws.readyState === WebSocket.OPEN) {
998
+ gt.ws.send(JSON.stringify({ type: 'pty_input', data: key }));
999
+ }
1000
+ if (gt.xterm) gt.xterm.focus();
1001
+ } else {
1002
+ if (ws && ws.readyState === WebSocket.OPEN) {
1003
+ ws.send(JSON.stringify({ type: 'pty_input', data: key }));
1004
+ }
1005
+ if (xterm) xterm.focus();
1006
+ }
1007
+ }
1008
+ });
1009
+ }
1010
+
565
1011
  window.sendKey = (key) => {
566
1012
  if (ws && ws.readyState === WebSocket.OPEN) {
567
1013
  ws.send(JSON.stringify({ type: 'pty_input', data: key }));
@@ -606,7 +1052,7 @@
606
1052
  requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
607
1053
  }
608
1054
  function escapeHtml(s) {
609
- const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML.replace(/'/g, '&#39;');
1055
+ const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML.replace(/'/g, '&#39;').replace(/"/g, '&quot;');
610
1056
  }
611
1057
  function formatText(text) {
612
1058
  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>