cli-tunnel 1.1.0 → 1.2.0-beta.10

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cli-tunnel",
3
- "version": "1.1.0",
4
- "description": "Tunnel any CLI app to your phone - PTY + devtunnel + xterm.js",
3
+ "version": "1.2.0-beta.10",
4
+ "description": "Tunnel any CLI app to your phone PTY + devtunnel + xterm.js",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
@@ -13,7 +13,8 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "build": "tsc",
16
- "test": "vitest run"
16
+ "test": "vitest run",
17
+ "test:coverage": "vitest run --coverage"
17
18
  },
18
19
  "keywords": [
19
20
  "cli",
@@ -35,13 +36,14 @@
35
36
  "node": ">=22.0.0"
36
37
  },
37
38
  "dependencies": {
38
- "node-pty": "^1.1.0",
39
- "qrcode-terminal": "^0.12.0",
40
- "ws": "^8.19.0"
39
+ "node-pty": "1.1.0",
40
+ "qrcode-terminal": "0.12.0",
41
+ "ws": "8.19.0"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@types/node": "^25.3.2",
44
45
  "@types/ws": "^8.18.1",
46
+ "@vitest/coverage-v8": "^4.0.18",
45
47
  "typescript": "^5.9.3",
46
48
  "vitest": "^4.0.18"
47
49
  }
package/remote-ui/app.js CHANGED
@@ -5,6 +5,27 @@
5
5
  (function () {
6
6
  'use strict';
7
7
 
8
+ // ─── Mobile keyboard viewport fix ────────────────────────
9
+ // Keep the key bar visible above the on-screen keyboard
10
+ if (window.visualViewport) {
11
+ window.visualViewport.addEventListener('resize', () => {
12
+ const vv = window.visualViewport;
13
+ const inputArea = document.getElementById('input-area');
14
+ if (inputArea && vv) {
15
+ const offset = window.innerHeight - vv.height - vv.offsetTop;
16
+ inputArea.style.transform = offset > 0 ? `translateY(-${offset}px)` : '';
17
+ }
18
+ });
19
+ window.visualViewport.addEventListener('scroll', () => {
20
+ const vv = window.visualViewport;
21
+ const inputArea = document.getElementById('input-area');
22
+ if (inputArea && vv) {
23
+ const offset = window.innerHeight - vv.height - vv.offsetTop;
24
+ inputArea.style.transform = offset > 0 ? `translateY(-${offset}px)` : '';
25
+ }
26
+ });
27
+ }
28
+
8
29
  let ws = null;
9
30
  let connected = false;
10
31
  let sessionId = null;
@@ -24,7 +45,9 @@
24
45
  const permOverlay = $('#permission-overlay');
25
46
  const dashboard = $('#dashboard');
26
47
  const termContainer = $('#terminal-container');
27
- let currentView = 'terminal'; // 'dashboard' or 'terminal'
48
+ let currentView = 'terminal'; // 'dashboard', 'terminal', or 'grid'
49
+ let cachedSessions = [];
50
+ let gridTerminals = []; // { xterm, fitAddon, ws, session }
28
51
 
29
52
  // ─── xterm.js Terminal ───────────────────────────────────
30
53
  let xterm = null;
@@ -98,7 +121,7 @@
98
121
  const data = await resp.json();
99
122
  renderDashboard(data.sessions || []);
100
123
  } catch (err) {
101
- dashboard.innerHTML = '<div style="padding:12px;color:var(--red)">Failed to load sessions: ' + err.message + '</div>';
124
+ dashboard.innerHTML = '<div style="padding:12px;color:var(--red)">' + escapeHtml('Failed to load sessions: ' + err.message) + '</div>';
102
125
  }
103
126
  }
104
127
 
@@ -106,34 +129,52 @@
106
129
  const filtered = showOffline ? sessions : sessions.filter(s => s.online);
107
130
  const offlineCount = sessions.filter(s => !s.online).length;
108
131
  const onlineCount = sessions.filter(s => s.online).length;
132
+ const connectable = filtered.filter(s => s.online && s.token);
109
133
 
110
134
  let html = `<div style="padding:8px 4px;display:flex;align-items:center;gap:8px">
111
135
  <span style="color:var(--text-dim);font-size:12px">${onlineCount} online${offlineCount > 0 ? ', ' + offlineCount + ' offline' : ''}</span>
112
136
  <span style="flex:1"></span>
113
- <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>
114
- ${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>' : ''}
115
- <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>
137
+ ${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>' : ''}
138
+ <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>
139
+ ${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>' : ''}
140
+ <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>
116
141
  </div>`;
117
142
 
118
143
  if (filtered.length === 0) {
119
144
  html += '<div style="padding:20px 12px;color:var(--text-dim);text-align:center">' +
120
- (sessions.length === 0 ? 'No Squad RC sessions found.' : 'No online sessions. Tap "Show offline" to see stale ones.') +
145
+ (sessions.length === 0 ? 'No cli-tunnel sessions found.' : 'No online sessions. Tap "Show offline" to see stale ones.') +
121
146
  '</div>';
122
147
  } else {
123
- html += filtered.map(s => `
124
- <div class="session-card" ${s.online ? 'onclick="openSession(\'' + escapeHtml(s.url) + '\')"' : ''}>
148
+ html += filtered.map(s => {
149
+ const sessionUrl = s.token ? s.url + '?token=' + encodeURIComponent(s.token) : '';
150
+ return `
151
+ <div class="session-card" ${s.online && sessionUrl ? 'data-session-url="' + escapeHtml(sessionUrl) + '"' : ''}>
125
152
  <span class="status-dot ${s.online ? 'online' : 'offline'}"></span>
126
153
  <div class="info">
154
+ <div class="session-name">${escapeHtml(s.name)}</div>
127
155
  <div class="repo">📦 ${escapeHtml(s.repo)}</div>
128
156
  <div class="branch">🌿 ${escapeHtml(s.branch)}</div>
129
- <div class="machine">💻 ${escapeHtml(s.machine)}</div>
157
+ <div class="machine">💻 ${escapeHtml(s.machine)}${!s.token && s.online ? ' 🔒' : ''}</div>
130
158
  </div>
131
- ${s.online ? '<span class="arrow">→</span>' :
132
- '<button onclick="event.stopPropagation();deleteSession(\'' + escapeHtml(s.id) + '\')" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px" title="Remove">✕</button>'}
133
- </div>
134
- `).join('');
159
+ ${s.online && sessionUrl ? '<span class="arrow">→</span>' :
160
+ !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
+ : '<span style="color:var(--text-dim);font-size:11px">remote</span>'}
162
+ </div>`;
163
+ }).join('');
135
164
  }
136
165
  dashboard.innerHTML = html;
166
+ cachedSessions = sessions;
167
+ // Event delegation
168
+ dashboard.querySelectorAll('.session-card[data-session-url]').forEach(function(card) {
169
+ card.addEventListener('click', function() { openSession(card.dataset.sessionUrl); });
170
+ });
171
+ dashboard.querySelectorAll('[data-delete-id]').forEach(function(btn) {
172
+ btn.addEventListener('click', function(e) { e.stopPropagation(); deleteSession(btn.dataset.deleteId); });
173
+ });
174
+ dashboard.querySelector('[data-action="toggle-offline"]')?.addEventListener('click', function() { toggleOffline(); });
175
+ dashboard.querySelector('[data-action="clean-offline"]')?.addEventListener('click', function() { cleanOffline(); });
176
+ dashboard.querySelector('[data-action="refresh"]')?.addEventListener('click', function() { loadSessions(); });
177
+ dashboard.querySelector('[data-action="grid-view"]')?.addEventListener('click', function() { showGridView(sessions); });
137
178
  }
138
179
 
139
180
  window.openSession = (url) => {
@@ -160,7 +201,159 @@
160
201
  loadSessions();
161
202
  };
162
203
 
204
+ // ─── Grid View (tmux-style multi-terminal) ────────────────
205
+ function showGridView(sessions) {
206
+ const connectable = sessions.filter(function(s) { return s.online && s.token; });
207
+ if (connectable.length === 0) return;
208
+
209
+ // Clean up previous grid
210
+ destroyGrid();
211
+
212
+ currentView = 'grid';
213
+ dashboard.classList.add('hidden');
214
+ terminal.classList.add('hidden');
215
+ termContainer.classList.add('hidden');
216
+ $('#input-area').classList.add('hidden');
217
+
218
+ var gridEl = document.getElementById('grid-view');
219
+ if (!gridEl) {
220
+ gridEl = document.createElement('div');
221
+ gridEl.id = 'grid-view';
222
+ document.getElementById('app').insertBefore(gridEl, document.getElementById('input-area'));
223
+ }
224
+ gridEl.classList.remove('hidden');
225
+ gridEl.innerHTML = '';
226
+
227
+ // Calculate grid dimensions
228
+ var cols = connectable.length <= 2 ? connectable.length : connectable.length <= 4 ? 2 : 3;
229
+ gridEl.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
230
+
231
+ connectable.forEach(function(s) {
232
+ var panel = document.createElement('div');
233
+ panel.className = 'grid-panel';
234
+
235
+ // Header
236
+ var header = document.createElement('div');
237
+ 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>';
241
+ panel.appendChild(header);
242
+
243
+ // Terminal container
244
+ var termDiv = document.createElement('div');
245
+ termDiv.className = 'grid-panel-terminal';
246
+ panel.appendChild(termDiv);
247
+
248
+ gridEl.appendChild(panel);
249
+
250
+ // Create xterm instance for this panel
251
+ var panelXterm = new Terminal({
252
+ theme: {
253
+ background: '#0d1117', foreground: '#c9d1d9', cursor: '#3fb950',
254
+ selectionBackground: '#264f78',
255
+ },
256
+ fontFamily: "'Cascadia Code', 'SF Mono', 'Fira Code', 'Menlo', monospace",
257
+ fontSize: 11,
258
+ scrollback: 1000,
259
+ cursorBlink: true,
260
+ });
261
+ var panelFit = new FitAddon.FitAddon();
262
+ panelXterm.loadAddon(panelFit);
263
+ panelXterm.open(termDiv);
264
+
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;
271
+
272
+ (function connectPanel() {
273
+ var sessionOrigin = new URL(s.url).origin;
274
+ fetch(sessionOrigin + '/api/auth/ticket', {
275
+ method: 'POST',
276
+ headers: { 'Authorization': 'Bearer ' + s.token }
277
+ }).then(function(resp) {
278
+ if (!resp.ok) throw new Error('Auth failed');
279
+ return resp.json();
280
+ }).then(function(data) {
281
+ panelWs = new WebSocket(sessionOrigin.replace('https://', 'wss://') + '?ticket=' + encodeURIComponent(data.ticket));
282
+
283
+ panelWs.onopen = function() {
284
+ if (statusDot) { statusDot.style.color = 'var(--green)'; statusDot.title = 'Connected'; }
285
+ panelWs.send(JSON.stringify({ type: 'pty_resize', cols: panelXterm.cols, rows: panelXterm.rows }));
286
+ };
287
+ panelWs.onclose = function() {
288
+ if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Disconnected'; }
289
+ };
290
+ panelWs.onerror = function() {
291
+ if (statusDot) { statusDot.style.color = 'var(--red)'; }
292
+ };
293
+ panelWs.onmessage = function(e) {
294
+ try {
295
+ var msg = JSON.parse(e.data);
296
+ if (msg.type === 'pty') {
297
+ panelXterm.write(msg.data);
298
+ }
299
+ } catch (err) {}
300
+ };
301
+
302
+ panelXterm.onData(function(data) {
303
+ if (panelWs && panelWs.readyState === WebSocket.OPEN) {
304
+ panelWs.send(JSON.stringify({ type: 'pty_input', data: data }));
305
+ }
306
+ });
307
+
308
+ gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: panelWs, session: s });
309
+ }).catch(function() {
310
+ if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Auth failed'; }
311
+ gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: null, session: s });
312
+ });
313
+ })();
314
+
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);
318
+ });
319
+
320
+ gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: panelWs, session: s });
321
+ });
322
+
323
+ // Handle window resize for grid panels
324
+ window.addEventListener('resize', fitGridPanels);
325
+
326
+ // Add back button
327
+ if ($('#btn-sessions')) { $('#btn-sessions').textContent = 'List'; }
328
+ }
329
+
330
+ function fitGridPanels() {
331
+ gridTerminals.forEach(function(gt) {
332
+ if (gt.fitAddon) { try { gt.fitAddon.fit(); } catch(e) {} }
333
+ });
334
+ }
335
+
336
+ function destroyGrid() {
337
+ gridTerminals.forEach(function(gt) {
338
+ if (gt.ws) { try { gt.ws.close(); } catch(e) {} }
339
+ if (gt.xterm) { try { gt.xterm.dispose(); } catch(e) {} }
340
+ });
341
+ gridTerminals = [];
342
+ window.removeEventListener('resize', fitGridPanels);
343
+ var gridEl = document.getElementById('grid-view');
344
+ if (gridEl) { gridEl.innerHTML = ''; gridEl.classList.add('hidden'); }
345
+ }
346
+
163
347
  window.toggleView = () => {
348
+ if (currentView === 'grid') {
349
+ // Grid → dashboard (list view)
350
+ destroyGrid();
351
+ currentView = 'dashboard';
352
+ dashboard.classList.remove('hidden');
353
+ $('#btn-sessions').textContent = 'Terminal';
354
+ loadSessions();
355
+ return;
356
+ }
164
357
  if (currentView === 'terminal') {
165
358
  currentView = 'dashboard';
166
359
  terminal.classList.add('hidden');
@@ -170,6 +363,7 @@
170
363
  $('#btn-sessions').textContent = 'Terminal';
171
364
  loadSessions();
172
365
  } else {
366
+ destroyGrid();
173
367
  currentView = 'terminal';
174
368
  dashboard.classList.add('hidden');
175
369
  $('#input-area').classList.remove('hidden');
@@ -338,14 +532,49 @@
338
532
  }
339
533
  }
340
534
 
535
+ // ─── Detect hub mode (no token in URL) ────────────────────
536
+ const isHubMode = new URLSearchParams(window.location.search).get('hub') === '1';
537
+
341
538
  // ─── WebSocket ───────────────────────────────────────────
342
539
  let reconnectAttempt = 0;
343
540
 
344
- function connect() {
345
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
541
+ async function connect() {
542
+ if (isHubMode) {
543
+ // Hub mode — hide terminal UI, show sessions only
544
+ setStatus('online', 'Hub');
545
+ terminal.classList.add('hidden');
546
+ termContainer.classList.add('hidden');
547
+ $('#input-area').classList.add('hidden');
548
+ $('#btn-sessions').classList.add('hidden');
549
+ dashboard.classList.remove('hidden');
550
+ loadSessions();
551
+ // Auto-refresh every 10s
552
+ setInterval(loadSessions, 10000);
553
+ return;
554
+ }
555
+
346
556
  const tokenParam = new URLSearchParams(window.location.search).get('token');
347
- const wsUrl = tokenParam ? `${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}` : `${proto}//${location.host}`;
348
- ws = new WebSocket(wsUrl);
557
+ if (!tokenParam) { setStatus('offline', 'No credentials'); return; }
558
+
559
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
560
+
561
+ // F-02: Ticket-based auth (required)
562
+ try {
563
+ const resp = await fetch('/api/auth/ticket', {
564
+ method: 'POST',
565
+ headers: { 'Authorization': 'Bearer ' + tokenParam }
566
+ });
567
+ if (resp.ok) {
568
+ const { ticket } = await resp.json();
569
+ ws = new WebSocket(`${proto}//${location.host}?ticket=${encodeURIComponent(ticket)}`);
570
+ } else {
571
+ setStatus('offline', 'Auth failed');
572
+ return;
573
+ }
574
+ } catch {
575
+ setStatus('offline', 'Auth failed');
576
+ return;
577
+ }
349
578
  setStatus('connecting', 'Connecting...');
350
579
 
351
580
  ws.onopen = () => {
@@ -484,10 +713,12 @@
484
713
  <h3>${icon} ${escapeHtml(title)}</h3>
485
714
  <p>${escapeHtml(shortCmd || JSON.stringify(p).substring(0, 200))}</p>
486
715
  <div class="perm-actions">
487
- <button class="btn-deny" onclick="handlePerm(${msg.id}, false)">Deny</button>
488
- <button class="btn-approve" onclick="handlePerm(${msg.id}, true)">Approve</button>
716
+ <button class="btn-deny">Deny</button>
717
+ <button class="btn-approve">Approve</button>
489
718
  </div>
490
719
  </div>`;
720
+ permOverlay.querySelector('.btn-deny').addEventListener('click', () => window.handlePerm(msg.id, false));
721
+ permOverlay.querySelector('.btn-approve').addEventListener('click', () => window.handlePerm(msg.id, true));
491
722
  }
492
723
  window.handlePerm = (id, approved) => {
493
724
  if (ws?.readyState === WebSocket.OPEN) {
@@ -497,6 +728,25 @@
497
728
  };
498
729
 
499
730
  // ─── Mobile Key Bar ───────────────────────────────────────
731
+ // F-5: Event delegation for key-bar buttons (no inline onclick)
732
+ const keyBar = document.getElementById('key-bar');
733
+ if (keyBar) {
734
+ const keyMap: Record<string, string> = {
735
+ '\\x1b[A': '\x1b[A', '\\x1b[B': '\x1b[B', '\\x1b[C': '\x1b[C', '\\x1b[D': '\x1b[D',
736
+ '\\t': '\t', '\\r': '\r', '\\x1b': '\x1b', '\\x03': '\x03', ' ': ' ', '\\x7f': '\x7f',
737
+ };
738
+ keyBar.addEventListener('click', function(e) {
739
+ var btn = e.target;
740
+ if (btn && btn.tagName === 'BUTTON' && btn.dataset.key) {
741
+ 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 }));
744
+ }
745
+ if (xterm) xterm.focus();
746
+ }
747
+ });
748
+ }
749
+
500
750
  window.sendKey = (key) => {
501
751
  if (ws && ws.readyState === WebSocket.OPEN) {
502
752
  ws.send(JSON.stringify({ type: 'pty_input', data: key }));
@@ -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>
@@ -172,6 +172,9 @@ header {
172
172
  background: var(--bg-tool);
173
173
  border-top: 1px solid var(--border);
174
174
  flex-shrink: 0;
175
+ position: sticky;
176
+ bottom: 0;
177
+ z-index: 10;
175
178
  }
176
179
  #key-bar {
177
180
  display: flex;
@@ -239,10 +242,51 @@ header {
239
242
  .session-card .status-dot.offline { background: var(--text-dim); }
240
243
  .session-card .info { flex: 1; min-width: 0; }
241
244
  .session-card .repo { color: var(--blue); font-weight: bold; font-size: 13px; }
245
+ .session-card .session-name { color: var(--text-bright); font-weight: bold; font-size: 14px; }
242
246
  .session-card .branch { color: var(--text-dim); font-size: 11px; }
243
247
  .session-card .machine { color: var(--text-dim); font-size: 11px; }
244
248
  .session-card .arrow { color: var(--text-dim); }
245
249
 
250
+ /* Grid View (tmux-style multi-terminal) */
251
+ #grid-view {
252
+ flex: 1;
253
+ display: grid;
254
+ gap: 2px;
255
+ padding: 2px;
256
+ overflow: hidden;
257
+ background: var(--border);
258
+ }
259
+ .grid-panel {
260
+ display: flex;
261
+ flex-direction: column;
262
+ background: var(--bg);
263
+ overflow: hidden;
264
+ min-height: 0;
265
+ }
266
+ .grid-panel-header {
267
+ display: flex;
268
+ align-items: center;
269
+ gap: 6px;
270
+ padding: 3px 8px;
271
+ background: var(--bg-tool);
272
+ border-bottom: 1px solid var(--border);
273
+ flex-shrink: 0;
274
+ cursor: pointer;
275
+ font-size: 11px;
276
+ }
277
+ .grid-panel-header:hover { background: var(--border); }
278
+ .grid-panel-name { color: var(--blue); font-weight: bold; }
279
+ .grid-panel-machine { color: var(--text-dim); flex: 1; }
280
+ .grid-panel-status { font-size: 8px; color: var(--yellow); }
281
+ .grid-panel-terminal {
282
+ flex: 1;
283
+ overflow: hidden;
284
+ }
285
+ .grid-panel-terminal .xterm {
286
+ height: 100%;
287
+ padding: 2px;
288
+ }
289
+
246
290
  /* Scrollbar */
247
291
  ::-webkit-scrollbar { width: 6px; }
248
292
  ::-webkit-scrollbar-track { background: transparent; }