cli-tunnel 1.2.0-beta.10 → 1.2.0-beta.12

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
@@ -228,7 +228,7 @@ setInterval(() => {
228
228
  ticketRateLimits.delete(ip);
229
229
  }
230
230
  }, 60000);
231
- const server = http.createServer((req, res) => {
231
+ const server = http.createServer(async (req, res) => {
232
232
  const clientIp = req.socket.remoteAddress || 'unknown';
233
233
  // F-8: Rate limiting for HTTP endpoints
234
234
  if (req.url?.startsWith('/api/')) {
@@ -274,6 +274,40 @@ const server = http.createServer((req, res) => {
274
274
  return;
275
275
  }
276
276
  }
277
+ // Hub ticket proxy — fetch ticket from local session on behalf of grid client
278
+ if (hubMode && req.url?.startsWith('/api/proxy/ticket/') && req.method === 'POST') {
279
+ const targetPort = parseInt(req.url.replace('/api/proxy/ticket/', ''), 10);
280
+ if (!Number.isFinite(targetPort) || targetPort < 1 || targetPort > 65535) {
281
+ res.writeHead(400, { 'Content-Type': 'application/json' });
282
+ res.end(JSON.stringify({ error: 'Invalid port' }));
283
+ return;
284
+ }
285
+ // Find token for this port from session files
286
+ const localSessions = readLocalSessions();
287
+ const session = localSessions.find(s => s.port === targetPort);
288
+ if (!session) {
289
+ res.writeHead(404, { 'Content-Type': 'application/json' });
290
+ res.end(JSON.stringify({ error: 'Session not found' }));
291
+ return;
292
+ }
293
+ try {
294
+ const ticketResp = await fetch(`http://127.0.0.1:${targetPort}/api/auth/ticket`, {
295
+ method: 'POST', headers: { 'Authorization': `Bearer ${session.token}` },
296
+ signal: AbortSignal.timeout(3000),
297
+ });
298
+ if (!ticketResp.ok)
299
+ throw new Error('Ticket request failed');
300
+ const ticketData = await ticketResp.json();
301
+ res.writeHead(200, { 'Content-Type': 'application/json' });
302
+ res.end(JSON.stringify({ ticket: ticketData.ticket, port: targetPort }));
303
+ }
304
+ catch {
305
+ res.writeHead(502, { 'Content-Type': 'application/json' });
306
+ res.end(JSON.stringify({ error: 'Session unreachable' }));
307
+ return;
308
+ }
309
+ return;
310
+ }
277
311
  // Sessions API
278
312
  if ((req.url === '/api/sessions' || req.url?.startsWith('/api/sessions?')) && req.method === 'GET') {
279
313
  try {
@@ -304,7 +338,7 @@ const server = http.createServer((req, res) => {
304
338
  const baseId = t.tunnelId?.split('.')[0] || t.tunnelId;
305
339
  const token = tokenMap.get(baseId) || tokenMap.get(t.tunnelId);
306
340
  if (token)
307
- session.token = token;
341
+ session.hasToken = true;
308
342
  return session;
309
343
  });
310
344
  res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
@@ -383,7 +417,7 @@ const server = http.createServer((req, res) => {
383
417
  'Content-Type': mimes[ext] || 'application/octet-stream',
384
418
  'X-Frame-Options': 'DENY',
385
419
  'X-Content-Type-Options': 'nosniff',
386
- 'Content-Security-Policy': "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net; connect-src 'self' ws://localhost:* wss://*.devtunnels.ms;",
420
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self' ws://localhost:* ws://127.0.0.1:* http://127.0.0.1:* wss://*.devtunnels.ms https://*.devtunnels.ms;",
387
421
  'Referrer-Policy': 'no-referrer',
388
422
  'Cache-Control': 'no-store',
389
423
  'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
@@ -400,8 +434,6 @@ const wss = new WebSocketServer({
400
434
  server,
401
435
  maxPayload: 1048576,
402
436
  verifyClient: (info) => {
403
- if (hubMode)
404
- return true; // Hub mode doesn't need WS auth
405
437
  // F-18: Session expiry
406
438
  if (Date.now() - sessionCreatedAt > SESSION_TTL)
407
439
  return false;
@@ -732,7 +764,9 @@ async function main() {
732
764
  const DANGEROUS_VARS = new Set(['NODE_OPTIONS', 'NODE_REPL_HISTORY', 'NODE_EXTRA_CA_CERTS',
733
765
  'NODE_PATH', 'NODE_REDIRECT_WARNINGS', 'NODE_PENDING_DEPRECATION',
734
766
  'UV_THREADPOOL_SIZE', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES',
735
- 'SSH_AUTH_SOCK', 'GPG_TTY']);
767
+ 'SSH_AUTH_SOCK', 'GPG_TTY',
768
+ 'PYTHONPATH', 'BASH_ENV', 'BASH_FUNC', 'JAVA_TOOL_OPTIONS', 'JAVA_OPTIONS', '_JAVA_OPTIONS',
769
+ 'PROMPT_COMMAND', 'ENV', 'ZDOTDIR', 'PERL5OPT', 'RUBYOPT']);
736
770
  const sensitivePattern = /token|secret|key|password|credential|api_key|private_key|access_key|connection_string|auth|kubeconfig|docker_host|docker_config/i;
737
771
  const safeEnv = {};
738
772
  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.10",
3
+ "version": "1.2.0-beta.12",
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;
@@ -117,7 +120,9 @@
117
120
 
118
121
  async function loadSessions() {
119
122
  try {
120
- 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 });
121
126
  const data = await resp.json();
122
127
  renderDashboard(data.sessions || []);
123
128
  } catch (err) {
@@ -146,17 +151,17 @@
146
151
  '</div>';
147
152
  } else {
148
153
  html += filtered.map(s => {
149
- const sessionUrl = s.token ? s.url + '?token=' + encodeURIComponent(s.token) : '';
154
+ const hasAccess = s.hasToken;
150
155
  return `
151
- <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) + '"' : ''}>
152
157
  <span class="status-dot ${s.online ? 'online' : 'offline'}"></span>
153
158
  <div class="info">
154
159
  <div class="session-name">${escapeHtml(s.name)}</div>
155
160
  <div class="repo">📦 ${escapeHtml(s.repo)}</div>
156
161
  <div class="branch">🌿 ${escapeHtml(s.branch)}</div>
157
- <div class="machine">💻 ${escapeHtml(s.machine)}${!s.token && s.online ? ' 🔒' : ''}</div>
162
+ <div class="machine">💻 ${escapeHtml(s.machine)}${!hasAccess && s.online ? ' 🔒' : ''}</div>
158
163
  </div>
159
- ${s.online && sessionUrl ? '<span class="arrow">→</span>' :
164
+ ${s.online && hasAccess ? '<span class="arrow">→</span>' :
160
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>'
161
166
  : '<span style="color:var(--text-dim);font-size:11px">remote</span>'}
162
167
  </div>`;
@@ -165,8 +170,25 @@
165
170
  dashboard.innerHTML = html;
166
171
  cachedSessions = sessions;
167
172
  // Event delegation
168
- dashboard.querySelectorAll('.session-card[data-session-url]').forEach(function(card) {
169
- 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
+ });
170
192
  });
171
193
  dashboard.querySelectorAll('[data-delete-id]').forEach(function(btn) {
172
194
  btn.addEventListener('click', function(e) { e.stopPropagation(); deleteSession(btn.dataset.deleteId); });
@@ -187,29 +209,36 @@
187
209
  };
188
210
 
189
211
  window.cleanOffline = async () => {
190
- 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 });
191
215
  const data = await resp.json();
192
216
  const offline = (data.sessions || []).filter(s => !s.online);
193
217
  for (const s of offline) {
194
- await fetch('/api/sessions/' + s.id, { method: 'DELETE' });
218
+ await fetch('/api/sessions/' + s.id, { method: 'DELETE', headers });
195
219
  }
196
220
  loadSessions();
197
221
  };
198
222
 
199
223
  window.deleteSession = async (id) => {
200
- 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 });
201
227
  loadSessions();
202
228
  };
203
229
 
204
- // ─── Grid View (tmux-style multi-terminal) ────────────────
230
+ // ─── Grid View (multi-terminal with layout modes) ───────────
205
231
  function showGridView(sessions) {
206
- const connectable = sessions.filter(function(s) { return s.online && s.token; });
232
+ var connectable = sessions.filter(function(s) { return s.online && s.token; });
207
233
  if (connectable.length === 0) return;
208
234
 
209
235
  // Clean up previous grid
210
236
  destroyGrid();
211
237
 
212
238
  currentView = 'grid';
239
+ gridMode = 'thumbnails';
240
+ focusedIndex = 0;
241
+ tmuxPreset = 'equal';
213
242
  dashboard.classList.add('hidden');
214
243
  terminal.classList.add('hidden');
215
244
  termContainer.classList.add('hidden');
@@ -224,30 +253,95 @@
224
253
  gridEl.classList.remove('hidden');
225
254
  gridEl.innerHTML = '';
226
255
 
227
- // Calculate grid dimensions
228
- var cols = connectable.length <= 2 ? connectable.length : connectable.length <= 4 ? 2 : 3;
229
- 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);
230
297
 
231
- connectable.forEach(function(s) {
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) {
232
317
  var panel = document.createElement('div');
233
318
  panel.className = 'grid-panel';
319
+ panel.dataset.index = index;
234
320
 
235
- // Header
236
321
  var header = document.createElement('div');
237
322
  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>';
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);
241
335
  panel.appendChild(header);
242
336
 
243
- // Terminal container
244
337
  var termDiv = document.createElement('div');
245
338
  termDiv.className = 'grid-panel-terminal';
246
339
  panel.appendChild(termDiv);
247
340
 
248
- gridEl.appendChild(panel);
341
+ // Append to contentEl so xterm.open has a DOM-attached container
342
+ contentEl.appendChild(panel);
249
343
 
250
- // Create xterm instance for this panel
344
+ // xterm instance
251
345
  var panelXterm = new Terminal({
252
346
  theme: {
253
347
  background: '#0d1117', foreground: '#c9d1d9', cursor: '#3fb950',
@@ -262,23 +356,26 @@
262
356
  panelXterm.loadAddon(panelFit);
263
357
  panelXterm.open(termDiv);
264
358
 
265
- // Delay fit to ensure container has size
266
- setTimeout(function() { panelFit.fit(); }, 100);
267
-
268
- // Connect WebSocket to this session via ticket auth
269
- var statusDot = header.querySelector('.grid-panel-status');
270
- var panelWs = null;
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);
271
362
 
363
+ // Connect WebSocket to this session
272
364
  (function connectPanel() {
273
- var sessionOrigin = new URL(s.url).origin;
274
- fetch(sessionOrigin + '/api/auth/ticket', {
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, {
275
371
  method: 'POST',
276
- headers: { 'Authorization': 'Bearer ' + s.token }
372
+ headers: { 'Authorization': 'Bearer ' + tokenParam }
277
373
  }).then(function(resp) {
278
374
  if (!resp.ok) throw new Error('Auth failed');
279
375
  return resp.json();
280
376
  }).then(function(data) {
281
- panelWs = new WebSocket(sessionOrigin.replace('https://', 'wss://') + '?ticket=' + encodeURIComponent(data.ticket));
377
+ var panelWs = new WebSocket(wsBase + '?ticket=' + encodeURIComponent(data.ticket));
378
+ entry.ws = panelWs;
282
379
 
283
380
  panelWs.onopen = function() {
284
381
  if (statusDot) { statusDot.style.color = 'var(--green)'; statusDot.title = 'Connected'; }
@@ -304,32 +401,183 @@
304
401
  panelWs.send(JSON.stringify({ type: 'pty_input', data: data }));
305
402
  }
306
403
  });
307
-
308
- gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: panelWs, session: s });
309
404
  }).catch(function() {
310
405
  if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Auth failed'; }
311
- gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: null, session: s });
312
406
  });
313
407
  })();
408
+ });
314
409
 
315
- // Click header to go full-screen on this session
316
- header.addEventListener('click', function() {
317
- window.location.href = s.url + '?token=' + encodeURIComponent(s.token);
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
+ }
448
+
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);
318
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;
319
464
 
320
- 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 = '';
321
486
  });
322
487
 
323
- // Handle window resize for grid panels
324
- 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;
325
507
 
326
- // Add back button
327
- 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);
328
568
  }
329
569
 
330
570
  function fitGridPanels() {
331
571
  gridTerminals.forEach(function(gt) {
332
- 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
+ }
333
581
  });
334
582
  }
335
583
 
@@ -342,6 +590,10 @@
342
590
  window.removeEventListener('resize', fitGridPanels);
343
591
  var gridEl = document.getElementById('grid-view');
344
592
  if (gridEl) { gridEl.innerHTML = ''; gridEl.classList.add('hidden'); }
593
+ $('#input-area').classList.add('hidden');
594
+ gridMode = 'thumbnails';
595
+ focusedIndex = 0;
596
+ tmuxPreset = 'equal';
345
597
  }
346
598
 
347
599
  window.toggleView = () => {
@@ -731,7 +983,7 @@
731
983
  // F-5: Event delegation for key-bar buttons (no inline onclick)
732
984
  const keyBar = document.getElementById('key-bar');
733
985
  if (keyBar) {
734
- const keyMap: Record<string, string> = {
986
+ var keyMap = {
735
987
  '\\x1b[A': '\x1b[A', '\\x1b[B': '\x1b[B', '\\x1b[C': '\x1b[C', '\\x1b[D': '\x1b[D',
736
988
  '\\t': '\t', '\\r': '\r', '\\x1b': '\x1b', '\\x03': '\x03', ' ': ' ', '\\x7f': '\x7f',
737
989
  };
@@ -739,10 +991,18 @@
739
991
  var btn = e.target;
740
992
  if (btn && btn.tagName === 'BUTTON' && btn.dataset.key) {
741
993
  var key = keyMap[btn.dataset.key] || btn.dataset.key;
742
- if (ws && ws.readyState === WebSocket.OPEN) {
743
- 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();
744
1005
  }
745
- if (xterm) xterm.focus();
746
1006
  }
747
1007
  });
748
1008
  }
@@ -791,7 +1051,7 @@
791
1051
  requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
792
1052
  }
793
1053
  function escapeHtml(s) {
794
- 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;');
795
1055
  }
796
1056
  function formatText(text) {
797
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;