cli-tunnel 1.2.0-beta.11 → 1.2.0-beta.13

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/dist/index.js CHANGED
@@ -338,7 +338,7 @@ const server = http.createServer(async (req, res) => {
338
338
  const baseId = t.tunnelId?.split('.')[0] || t.tunnelId;
339
339
  const token = tokenMap.get(baseId) || tokenMap.get(t.tunnelId);
340
340
  if (token)
341
- session.token = token;
341
+ session.hasToken = true;
342
342
  return session;
343
343
  });
344
344
  res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
@@ -434,8 +434,6 @@ const wss = new WebSocketServer({
434
434
  server,
435
435
  maxPayload: 1048576,
436
436
  verifyClient: (info) => {
437
- if (hubMode)
438
- return true; // Hub mode doesn't need WS auth
439
437
  // F-18: Session expiry
440
438
  if (Date.now() - sessionCreatedAt > SESSION_TTL)
441
439
  return false;
@@ -738,6 +736,21 @@ async function main() {
738
736
  // Keep process alive
739
737
  await new Promise(() => { });
740
738
  }
739
+ // Wait for user to scan QR / copy URL before starting the CLI tool
740
+ if (hasTunnel) {
741
+ console.log(` ${BOLD}Press any key to start ${command}...${RESET}`);
742
+ await new Promise((resolve) => {
743
+ if (process.stdin.isTTY)
744
+ process.stdin.setRawMode(true);
745
+ process.stdin.resume();
746
+ process.stdin.once('data', () => {
747
+ if (process.stdin.isTTY)
748
+ process.stdin.setRawMode(false);
749
+ process.stdin.pause();
750
+ resolve();
751
+ });
752
+ });
753
+ }
741
754
  console.log(` ${DIM}Starting ${command}...${RESET}\n`);
742
755
  // Spawn PTY
743
756
  const nodePty = await import('node-pty');
@@ -766,7 +779,9 @@ async function main() {
766
779
  const DANGEROUS_VARS = new Set(['NODE_OPTIONS', 'NODE_REPL_HISTORY', 'NODE_EXTRA_CA_CERTS',
767
780
  'NODE_PATH', 'NODE_REDIRECT_WARNINGS', 'NODE_PENDING_DEPRECATION',
768
781
  'UV_THREADPOOL_SIZE', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES',
769
- 'SSH_AUTH_SOCK', 'GPG_TTY']);
782
+ 'SSH_AUTH_SOCK', 'GPG_TTY',
783
+ 'PYTHONPATH', 'BASH_ENV', 'BASH_FUNC', 'JAVA_TOOL_OPTIONS', 'JAVA_OPTIONS', '_JAVA_OPTIONS',
784
+ 'PROMPT_COMMAND', 'ENV', 'ZDOTDIR', 'PERL5OPT', 'RUBYOPT']);
770
785
  const sensitivePattern = /token|secret|key|password|credential|api_key|private_key|access_key|connection_string|auth|kubeconfig|docker_host|docker_config/i;
771
786
  const safeEnv = {};
772
787
  for (const [k, v] of Object.entries(process.env)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-tunnel",
3
- "version": "1.2.0-beta.11",
3
+ "version": "1.2.0-beta.13",
4
4
  "description": "Tunnel any CLI app to your phone — PTY + devtunnel + xterm.js",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
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;
@@ -148,17 +151,17 @@
148
151
  '</div>';
149
152
  } else {
150
153
  html += filtered.map(s => {
151
- const sessionUrl = s.token ? s.url + '?token=' + encodeURIComponent(s.token) : '';
154
+ const hasAccess = s.hasToken;
152
155
  return `
153
- <div class="session-card" ${s.online && sessionUrl ? 'data-session-url="' + escapeHtml(sessionUrl) + '"' : ''}>
156
+ <div class="session-card" ${s.online && hasAccess ? 'data-session-port="' + s.port + '" data-session-base-url="' + escapeHtml(s.url) + '"' : ''}>
154
157
  <span class="status-dot ${s.online ? 'online' : 'offline'}"></span>
155
158
  <div class="info">
156
159
  <div class="session-name">${escapeHtml(s.name)}</div>
157
160
  <div class="repo">📦 ${escapeHtml(s.repo)}</div>
158
161
  <div class="branch">🌿 ${escapeHtml(s.branch)}</div>
159
- <div class="machine">💻 ${escapeHtml(s.machine)}${!s.token && s.online ? ' 🔒' : ''}</div>
162
+ <div class="machine">💻 ${escapeHtml(s.machine)}${!hasAccess && s.online ? ' 🔒' : ''}</div>
160
163
  </div>
161
- ${s.online && sessionUrl ? '<span class="arrow">→</span>' :
164
+ ${s.online && hasAccess ? '<span class="arrow">→</span>' :
162
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>'
163
166
  : '<span style="color:var(--text-dim);font-size:11px">remote</span>'}
164
167
  </div>`;
@@ -167,8 +170,25 @@
167
170
  dashboard.innerHTML = html;
168
171
  cachedSessions = sessions;
169
172
  // Event delegation
170
- dashboard.querySelectorAll('.session-card[data-session-url]').forEach(function(card) {
171
- card.addEventListener('click', function() { openSession(card.dataset.sessionUrl); });
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
+ });
172
192
  });
173
193
  dashboard.querySelectorAll('[data-delete-id]').forEach(function(btn) {
174
194
  btn.addEventListener('click', function(e) { e.stopPropagation(); deleteSession(btn.dataset.deleteId); });
@@ -207,15 +227,18 @@
207
227
  loadSessions();
208
228
  };
209
229
 
210
- // ─── Grid View (tmux-style multi-terminal) ────────────────
230
+ // ─── Grid View (multi-terminal with layout modes) ───────────
211
231
  function showGridView(sessions) {
212
- const connectable = sessions.filter(function(s) { return s.online && s.token; });
232
+ var connectable = sessions.filter(function(s) { return s.online && s.token; });
213
233
  if (connectable.length === 0) return;
214
234
 
215
235
  // Clean up previous grid
216
236
  destroyGrid();
217
237
 
218
238
  currentView = 'grid';
239
+ gridMode = 'thumbnails';
240
+ focusedIndex = 0;
241
+ tmuxPreset = 'equal';
219
242
  dashboard.classList.add('hidden');
220
243
  terminal.classList.add('hidden');
221
244
  termContainer.classList.add('hidden');
@@ -230,30 +253,95 @@
230
253
  gridEl.classList.remove('hidden');
231
254
  gridEl.innerHTML = '';
232
255
 
233
- // Calculate grid dimensions
234
- var cols = connectable.length <= 2 ? connectable.length : connectable.length <= 4 ? 2 : 3;
235
- gridEl.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
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);
236
314
 
237
- connectable.forEach(function(s) {
315
+ // ── Create panels & connect ──
316
+ connectable.forEach(function(s, index) {
238
317
  var panel = document.createElement('div');
239
318
  panel.className = 'grid-panel';
319
+ panel.dataset.index = index;
240
320
 
241
- // Header
242
321
  var header = document.createElement('div');
243
322
  header.className = 'grid-panel-header';
244
- header.innerHTML = '<span class="grid-panel-name">' + escapeHtml(s.name) + '</span>' +
245
- '<span class="grid-panel-machine">' + escapeHtml(s.machine) + '</span>' +
246
- '<span class="grid-panel-status">●</span>';
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);
247
335
  panel.appendChild(header);
248
336
 
249
- // Terminal container
250
337
  var termDiv = document.createElement('div');
251
338
  termDiv.className = 'grid-panel-terminal';
252
339
  panel.appendChild(termDiv);
253
340
 
254
- gridEl.appendChild(panel);
341
+ // Append to contentEl so xterm.open has a DOM-attached container
342
+ contentEl.appendChild(panel);
255
343
 
256
- // Create xterm instance for this panel
344
+ // xterm instance
257
345
  var panelXterm = new Terminal({
258
346
  theme: {
259
347
  background: '#0d1117', foreground: '#c9d1d9', cursor: '#3fb950',
@@ -268,13 +356,11 @@
268
356
  panelXterm.loadAddon(panelFit);
269
357
  panelXterm.open(termDiv);
270
358
 
271
- // Delay fit to ensure container has size
272
- setTimeout(function() { panelFit.fit(); }, 100);
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);
273
362
 
274
363
  // Connect WebSocket to this session
275
- var statusDot = header.querySelector('.grid-panel-status');
276
- var panelWs = null;
277
-
278
364
  (function connectPanel() {
279
365
  // Use hub's proxy endpoint to get a ticket for the session
280
366
  var tokenParam = new URLSearchParams(window.location.search).get('token');
@@ -288,7 +374,8 @@
288
374
  if (!resp.ok) throw new Error('Auth failed');
289
375
  return resp.json();
290
376
  }).then(function(data) {
291
- panelWs = new WebSocket(wsBase + '?ticket=' + encodeURIComponent(data.ticket));
377
+ var panelWs = new WebSocket(wsBase + '?ticket=' + encodeURIComponent(data.ticket));
378
+ entry.ws = panelWs;
292
379
 
293
380
  panelWs.onopen = function() {
294
381
  if (statusDot) { statusDot.style.color = 'var(--green)'; statusDot.title = 'Connected'; }
@@ -314,32 +401,183 @@
314
401
  panelWs.send(JSON.stringify({ type: 'pty_input', data: data }));
315
402
  }
316
403
  });
317
-
318
- gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: panelWs, session: s });
319
404
  }).catch(function() {
320
405
  if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Auth failed'; }
321
- gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: null, session: s });
322
406
  });
323
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.addEventListener('resize', fitGridPanels);
435
+ if ($('#btn-sessions')) { $('#btn-sessions').textContent = 'List'; }
436
+ }
437
+
438
+ function switchGridMode(mode) {
439
+ gridMode = mode;
440
+ if (mode === 'fullscreen') {
441
+ $('#input-area').classList.remove('hidden');
442
+ $('#input-form').classList.add('hidden');
443
+ } else {
444
+ $('#input-area').classList.add('hidden');
445
+ }
446
+ applyGridLayout(mode);
447
+ }
324
448
 
325
- // Click header to go full-screen on this session
326
- header.addEventListener('click', function() {
327
- window.location.href = s.url + '?token=' + encodeURIComponent(s.token);
449
+ function switchTmuxPreset(preset) {
450
+ tmuxPreset = preset;
451
+ var presetGroup = document.getElementById('tmux-presets');
452
+ if (presetGroup) {
453
+ presetGroup.querySelectorAll('[data-preset]').forEach(function(btn) {
454
+ btn.classList.toggle('active', btn.dataset.preset === preset);
328
455
  });
456
+ }
457
+ if (gridMode === 'tmux') applyGridLayout('tmux');
458
+ }
459
+
460
+ function applyGridLayout(mode) {
461
+ gridMode = mode;
462
+ var contentEl = document.getElementById('grid-content');
463
+ if (!contentEl || gridTerminals.length === 0) return;
329
464
 
330
- gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: panelWs, session: s });
465
+ // Clamp focusedIndex
466
+ if (focusedIndex >= gridTerminals.length) focusedIndex = 0;
467
+
468
+ // Update toolbar button states
469
+ var toolbar = contentEl.parentElement.querySelector('.grid-toolbar');
470
+ if (toolbar) {
471
+ toolbar.querySelectorAll('[data-mode]').forEach(function(btn) {
472
+ btn.classList.toggle('active', btn.dataset.mode === mode);
473
+ });
474
+ var presetsEl = document.getElementById('tmux-presets');
475
+ if (presetsEl) presetsEl.classList.toggle('hidden', mode !== 'tmux');
476
+ }
477
+
478
+ // Detach all panels without destroying them
479
+ gridTerminals.forEach(function(gt, i) {
480
+ if (gt.panel.parentNode) gt.panel.parentNode.removeChild(gt.panel);
481
+ gt.panel.className = 'grid-panel';
482
+ gt.panel.dataset.index = i;
483
+ var termDiv = gt.panel.querySelector('.grid-panel-terminal');
484
+ if (termDiv) termDiv.style.cssText = '';
485
+ gt.panel.style.cssText = '';
331
486
  });
332
487
 
333
- // Handle window resize for grid panels
334
- window.addEventListener('resize', fitGridPanels);
488
+ // Remove leftover elements (focus-strips, back-to-grid button)
489
+ while (contentEl.firstChild) contentEl.removeChild(contentEl.firstChild);
490
+
491
+ // Reset content styles
492
+ contentEl.className = 'mode-' + mode;
493
+ contentEl.style.cssText = '';
494
+
495
+ switch (mode) {
496
+ case 'thumbnails':
497
+ gridTerminals.forEach(function(gt) {
498
+ gt.panel.classList.add('thumbnail');
499
+ var termDiv = gt.panel.querySelector('.grid-panel-terminal');
500
+ termDiv.style.width = '560px';
501
+ termDiv.style.height = '360px';
502
+ termDiv.style.transform = 'scale(0.5)';
503
+ termDiv.style.transformOrigin = 'top left';
504
+ contentEl.appendChild(gt.panel);
505
+ });
506
+ break;
335
507
 
336
- // Add back button
337
- if ($('#btn-sessions')) { $('#btn-sessions').textContent = 'List'; }
508
+ case 'tmux':
509
+ gridTerminals.forEach(function(gt, i) {
510
+ if (i === focusedIndex) gt.panel.classList.add('active');
511
+ contentEl.appendChild(gt.panel);
512
+ });
513
+ if (tmuxPreset === 'equal') {
514
+ var cols = gridTerminals.length <= 2 ? gridTerminals.length : gridTerminals.length <= 4 ? 2 : 3;
515
+ contentEl.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
516
+ } else if (tmuxPreset === 'main-side') {
517
+ contentEl.style.gridTemplateColumns = '70% 30%';
518
+ var sideCount = Math.max(gridTerminals.length - 1, 1);
519
+ contentEl.style.gridTemplateRows = 'repeat(' + sideCount + ', 1fr)';
520
+ if (gridTerminals.length > 0) gridTerminals[0].panel.style.gridRow = '1 / -1';
521
+ } else if (tmuxPreset === 'stacked') {
522
+ contentEl.style.gridTemplateColumns = '1fr';
523
+ }
524
+ break;
525
+
526
+ case 'focus':
527
+ var mainGt = gridTerminals[focusedIndex];
528
+ mainGt.panel.classList.add('focus-main');
529
+ contentEl.appendChild(mainGt.panel);
530
+ if (gridTerminals.length > 1) {
531
+ var stripsEl = document.createElement('div');
532
+ stripsEl.className = 'focus-strips';
533
+ gridTerminals.forEach(function(gt, i) {
534
+ if (i === focusedIndex) return;
535
+ gt.panel.classList.add('focus-strip');
536
+ stripsEl.appendChild(gt.panel);
537
+ });
538
+ contentEl.appendChild(stripsEl);
539
+ }
540
+ break;
541
+
542
+ case 'fullscreen':
543
+ var fullGt = gridTerminals[focusedIndex];
544
+ fullGt.panel.classList.add('fullscreen');
545
+ contentEl.appendChild(fullGt.panel);
546
+ var backBtn = document.createElement('button');
547
+ backBtn.className = 'back-to-grid';
548
+ backBtn.textContent = '\u2190 Grid';
549
+ backBtn.addEventListener('click', function() { switchGridMode('thumbnails'); });
550
+ contentEl.appendChild(backBtn);
551
+ break;
552
+ }
553
+
554
+ // Fit visible terminals after DOM settles
555
+ setTimeout(function() {
556
+ gridTerminals.forEach(function(gt) {
557
+ if (!document.contains(gt.panel)) return;
558
+ if (gt.fitAddon) {
559
+ try {
560
+ gt.fitAddon.fit();
561
+ if (gt.ws && gt.ws.readyState === WebSocket.OPEN && gt.xterm) {
562
+ gt.ws.send(JSON.stringify({ type: 'pty_resize', cols: gt.xterm.cols, rows: gt.xterm.rows }));
563
+ }
564
+ } catch(e) {}
565
+ }
566
+ });
567
+ }, 100);
338
568
  }
339
569
 
340
570
  function fitGridPanels() {
341
571
  gridTerminals.forEach(function(gt) {
342
- if (gt.fitAddon) { try { gt.fitAddon.fit(); } catch(e) {} }
572
+ if (!document.contains(gt.panel)) return;
573
+ if (gt.fitAddon) {
574
+ try {
575
+ gt.fitAddon.fit();
576
+ if (gt.ws && gt.ws.readyState === WebSocket.OPEN && gt.xterm) {
577
+ gt.ws.send(JSON.stringify({ type: 'pty_resize', cols: gt.xterm.cols, rows: gt.xterm.rows }));
578
+ }
579
+ } catch(e) {}
580
+ }
343
581
  });
344
582
  }
345
583
 
@@ -352,6 +590,10 @@
352
590
  window.removeEventListener('resize', fitGridPanels);
353
591
  var gridEl = document.getElementById('grid-view');
354
592
  if (gridEl) { gridEl.innerHTML = ''; gridEl.classList.add('hidden'); }
593
+ $('#input-area').classList.add('hidden');
594
+ gridMode = 'thumbnails';
595
+ focusedIndex = 0;
596
+ tmuxPreset = 'equal';
355
597
  }
356
598
 
357
599
  window.toggleView = () => {
@@ -749,10 +991,18 @@
749
991
  var btn = e.target;
750
992
  if (btn && btn.tagName === 'BUTTON' && btn.dataset.key) {
751
993
  var key = keyMap[btn.dataset.key] || btn.dataset.key;
752
- if (ws && ws.readyState === WebSocket.OPEN) {
753
- ws.send(JSON.stringify({ type: 'pty_input', data: key }));
994
+ if (currentView === 'grid' && gridMode === 'fullscreen' && gridTerminals[focusedIndex]) {
995
+ var gt = gridTerminals[focusedIndex];
996
+ if (gt.ws && gt.ws.readyState === WebSocket.OPEN) {
997
+ gt.ws.send(JSON.stringify({ type: 'pty_input', data: key }));
998
+ }
999
+ if (gt.xterm) gt.xterm.focus();
1000
+ } else {
1001
+ if (ws && ws.readyState === WebSocket.OPEN) {
1002
+ ws.send(JSON.stringify({ type: 'pty_input', data: key }));
1003
+ }
1004
+ if (xterm) xterm.focus();
754
1005
  }
755
- if (xterm) xterm.focus();
756
1006
  }
757
1007
  });
758
1008
  }
@@ -801,7 +1051,7 @@
801
1051
  requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
802
1052
  }
803
1053
  function escapeHtml(s) {
804
- const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML.replace(/'/g, '&#39;');
1054
+ const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML.replace(/'/g, '&#39;').replace(/"/g, '&quot;');
805
1055
  }
806
1056
  function formatText(text) {
807
1057
  return escapeHtml(text)
@@ -247,15 +247,143 @@ header {
247
247
  .session-card .machine { color: var(--text-dim); font-size: 11px; }
248
248
  .session-card .arrow { color: var(--text-dim); }
249
249
 
250
- /* Grid View (tmux-style multi-terminal) */
250
+ /* Grid View (multi-terminal with layout modes) */
251
251
  #grid-view {
252
252
  flex: 1;
253
+ display: flex;
254
+ flex-direction: column;
255
+ overflow: hidden;
256
+ }
257
+
258
+ /* Grid toolbar */
259
+ .grid-toolbar {
260
+ display: flex;
261
+ gap: 4px;
262
+ padding: 4px 8px;
263
+ background: var(--bg-tool);
264
+ border-bottom: 1px solid var(--border);
265
+ flex-shrink: 0;
266
+ }
267
+ .grid-toolbar button {
268
+ background: var(--bg);
269
+ border: 1px solid var(--border);
270
+ color: var(--text-dim);
271
+ font-family: var(--font);
272
+ font-size: 11px;
273
+ padding: 3px 8px;
274
+ border-radius: 4px;
275
+ cursor: pointer;
276
+ }
277
+ .grid-toolbar button.active {
278
+ border-color: var(--blue);
279
+ color: var(--blue);
280
+ }
281
+ .grid-toolbar .spacer { flex: 1; }
282
+ .grid-toolbar-presets { display: flex; gap: 4px; margin-left: 8px; }
283
+ .grid-toolbar-presets.hidden { display: none; }
284
+
285
+ /* Grid content area */
286
+ #grid-content {
287
+ flex: 1;
288
+ overflow: hidden;
289
+ min-height: 0;
290
+ }
291
+
292
+ /* Thumbnail mode */
293
+ #grid-content.mode-thumbnails {
294
+ display: flex;
295
+ flex-wrap: wrap;
296
+ gap: 8px;
297
+ padding: 8px;
298
+ align-content: flex-start;
299
+ overflow-y: auto;
300
+ }
301
+ .grid-panel.thumbnail {
302
+ width: 280px;
303
+ height: 200px;
304
+ border: 1px solid var(--border);
305
+ border-radius: 6px;
306
+ cursor: pointer;
307
+ overflow: hidden;
308
+ flex-shrink: 0;
309
+ }
310
+ .grid-panel.thumbnail:hover {
311
+ border-color: var(--blue);
312
+ }
313
+ .grid-panel.thumbnail .grid-panel-terminal {
314
+ overflow: hidden;
315
+ }
316
+
317
+ /* Tmux mode */
318
+ #grid-content.mode-tmux {
253
319
  display: grid;
254
320
  gap: 2px;
255
- padding: 2px;
256
- overflow: hidden;
257
321
  background: var(--border);
258
322
  }
323
+ .grid-panel.active {
324
+ outline: 2px solid var(--blue);
325
+ outline-offset: -2px;
326
+ }
327
+
328
+ /* Focus mode */
329
+ #grid-content.mode-focus {
330
+ display: flex;
331
+ flex-direction: column;
332
+ }
333
+ .grid-panel.focus-main {
334
+ flex: 1;
335
+ min-height: 0;
336
+ }
337
+ .grid-panel.focus-main .grid-panel-header {
338
+ background: var(--blue);
339
+ }
340
+ .grid-panel.focus-main .grid-panel-name {
341
+ color: #fff;
342
+ }
343
+ .focus-strips {
344
+ display: flex;
345
+ flex-shrink: 0;
346
+ overflow-x: auto;
347
+ }
348
+ .grid-panel.focus-strip {
349
+ height: 80px;
350
+ cursor: pointer;
351
+ border-top: 1px solid var(--border);
352
+ flex: 1;
353
+ min-width: 120px;
354
+ }
355
+ .grid-panel.focus-strip:hover {
356
+ background: var(--bg-tool);
357
+ }
358
+
359
+ /* Fullscreen mode */
360
+ #grid-content.mode-fullscreen {
361
+ display: flex;
362
+ flex-direction: column;
363
+ position: relative;
364
+ }
365
+ .grid-panel.fullscreen {
366
+ flex: 1;
367
+ min-height: 0;
368
+ }
369
+ .back-to-grid {
370
+ position: absolute;
371
+ top: 4px;
372
+ right: 8px;
373
+ background: var(--bg-tool);
374
+ border: 1px solid var(--border);
375
+ color: var(--text-dim);
376
+ font-family: var(--font);
377
+ font-size: 11px;
378
+ padding: 3px 8px;
379
+ border-radius: 4px;
380
+ cursor: pointer;
381
+ z-index: 10;
382
+ }
383
+ .back-to-grid:hover {
384
+ border-color: var(--blue);
385
+ color: var(--blue);
386
+ }
259
387
  .grid-panel {
260
388
  display: flex;
261
389
  flex-direction: column;