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/README.md +48 -5
- package/dist/index.js +471 -85
- package/package.json +8 -6
- package/remote-ui/app.js +269 -19
- package/remote-ui/index.html +10 -10
- package/remote-ui/styles.css +44 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cli-tunnel",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Tunnel any CLI app to your phone
|
|
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": "
|
|
39
|
-
"qrcode-terminal": "
|
|
40
|
-
"ws": "
|
|
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 '
|
|
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
|
|
114
|
-
|
|
115
|
-
<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
|
|
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
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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"
|
|
488
|
-
<button class="btn-approve"
|
|
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 }));
|
package/remote-ui/index.html
CHANGED
|
@@ -32,16 +32,16 @@
|
|
|
32
32
|
|
|
33
33
|
<footer id="input-area">
|
|
34
34
|
<div id="key-bar">
|
|
35
|
-
<button
|
|
36
|
-
<button
|
|
37
|
-
<button
|
|
38
|
-
<button
|
|
39
|
-
<button
|
|
40
|
-
<button
|
|
41
|
-
<button
|
|
42
|
-
<button
|
|
43
|
-
<button
|
|
44
|
-
<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">></span>
|
package/remote-ui/styles.css
CHANGED
|
@@ -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; }
|