claude-remote-cli 1.7.2 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/sessions.js +30 -3
- package/dist/server/ws.js +8 -3
- package/package.json +1 -1
- package/public/app.js +358 -10
- package/public/index.html +13 -2
- package/public/manifest.json +8 -0
- package/public/style.css +86 -14
package/dist/server/sessions.js
CHANGED
|
@@ -6,6 +6,11 @@ import path from 'node:path';
|
|
|
6
6
|
import { readMeta, writeMeta } from './config.js';
|
|
7
7
|
// In-memory registry: id -> Session
|
|
8
8
|
const sessions = new Map();
|
|
9
|
+
const IDLE_TIMEOUT_MS = 5000;
|
|
10
|
+
let idleChangeCallback = null;
|
|
11
|
+
function onIdleChange(cb) {
|
|
12
|
+
idleChangeCallback = cb;
|
|
13
|
+
}
|
|
9
14
|
function create({ repoName, repoPath, cwd, root, worktreeName, displayName, command, args = [], cols = 80, rows = 24, configPath }) {
|
|
10
15
|
const id = crypto.randomBytes(8).toString('hex');
|
|
11
16
|
const createdAt = new Date().toISOString();
|
|
@@ -34,6 +39,7 @@ function create({ repoName, repoPath, cwd, root, worktreeName, displayName, comm
|
|
|
34
39
|
createdAt,
|
|
35
40
|
lastActivity: createdAt,
|
|
36
41
|
scrollback,
|
|
42
|
+
idle: false,
|
|
37
43
|
};
|
|
38
44
|
sessions.set(id, session);
|
|
39
45
|
// Load existing metadata to preserve a previously-set displayName
|
|
@@ -45,8 +51,26 @@ function create({ repoName, repoPath, cwd, root, worktreeName, displayName, comm
|
|
|
45
51
|
writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: createdAt });
|
|
46
52
|
}
|
|
47
53
|
let metaFlushTimer = null;
|
|
54
|
+
let idleTimer = null;
|
|
55
|
+
function resetIdleTimer() {
|
|
56
|
+
if (session.idle) {
|
|
57
|
+
session.idle = false;
|
|
58
|
+
if (idleChangeCallback)
|
|
59
|
+
idleChangeCallback(session.id, false);
|
|
60
|
+
}
|
|
61
|
+
if (idleTimer)
|
|
62
|
+
clearTimeout(idleTimer);
|
|
63
|
+
idleTimer = setTimeout(() => {
|
|
64
|
+
if (!session.idle) {
|
|
65
|
+
session.idle = true;
|
|
66
|
+
if (idleChangeCallback)
|
|
67
|
+
idleChangeCallback(session.id, true);
|
|
68
|
+
}
|
|
69
|
+
}, IDLE_TIMEOUT_MS);
|
|
70
|
+
}
|
|
48
71
|
ptyProcess.onData((data) => {
|
|
49
72
|
session.lastActivity = new Date().toISOString();
|
|
73
|
+
resetIdleTimer();
|
|
50
74
|
scrollback.push(data);
|
|
51
75
|
scrollbackBytes += data.length;
|
|
52
76
|
// Trim oldest entries if over limit
|
|
@@ -61,6 +85,8 @@ function create({ repoName, repoPath, cwd, root, worktreeName, displayName, comm
|
|
|
61
85
|
}
|
|
62
86
|
});
|
|
63
87
|
ptyProcess.onExit(() => {
|
|
88
|
+
if (idleTimer)
|
|
89
|
+
clearTimeout(idleTimer);
|
|
64
90
|
if (metaFlushTimer)
|
|
65
91
|
clearTimeout(metaFlushTimer);
|
|
66
92
|
if (configPath && worktreeName) {
|
|
@@ -70,14 +96,14 @@ function create({ repoName, repoPath, cwd, root, worktreeName, displayName, comm
|
|
|
70
96
|
const tmpDir = path.join(os.tmpdir(), 'claude-remote-cli', id);
|
|
71
97
|
fs.rm(tmpDir, { recursive: true, force: true }, () => { });
|
|
72
98
|
});
|
|
73
|
-
return { id, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, displayName: session.displayName, pid: ptyProcess.pid, createdAt, lastActivity: createdAt };
|
|
99
|
+
return { id, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, displayName: session.displayName, pid: ptyProcess.pid, createdAt, lastActivity: createdAt, idle: false };
|
|
74
100
|
}
|
|
75
101
|
function get(id) {
|
|
76
102
|
return sessions.get(id);
|
|
77
103
|
}
|
|
78
104
|
function list() {
|
|
79
105
|
return Array.from(sessions.values())
|
|
80
|
-
.map(({ id, root, repoName, repoPath, worktreeName, displayName, createdAt, lastActivity }) => ({
|
|
106
|
+
.map(({ id, root, repoName, repoPath, worktreeName, displayName, createdAt, lastActivity, idle }) => ({
|
|
81
107
|
id,
|
|
82
108
|
root,
|
|
83
109
|
repoName,
|
|
@@ -86,6 +112,7 @@ function list() {
|
|
|
86
112
|
displayName,
|
|
87
113
|
createdAt,
|
|
88
114
|
lastActivity,
|
|
115
|
+
idle,
|
|
89
116
|
}))
|
|
90
117
|
.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
|
|
91
118
|
}
|
|
@@ -118,4 +145,4 @@ function write(id, data) {
|
|
|
118
145
|
}
|
|
119
146
|
session.pty.write(data);
|
|
120
147
|
}
|
|
121
|
-
export { create, get, list, kill, resize, updateDisplayName, write };
|
|
148
|
+
export { create, get, list, kill, resize, updateDisplayName, write, onIdleChange };
|
package/dist/server/ws.js
CHANGED
|
@@ -17,8 +17,8 @@ function parseCookies(cookieHeader) {
|
|
|
17
17
|
function setupWebSocket(server, authenticatedTokens, watcher) {
|
|
18
18
|
const wss = new WebSocketServer({ noServer: true });
|
|
19
19
|
const eventClients = new Set();
|
|
20
|
-
function broadcastEvent(type) {
|
|
21
|
-
const msg = JSON.stringify({ type });
|
|
20
|
+
function broadcastEvent(type, data) {
|
|
21
|
+
const msg = JSON.stringify({ type, ...data });
|
|
22
22
|
for (const client of eventClients) {
|
|
23
23
|
if (client.readyState === client.OPEN) {
|
|
24
24
|
client.send(msg);
|
|
@@ -40,8 +40,10 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
|
|
|
40
40
|
// Event channel: /ws/events
|
|
41
41
|
if (request.url === '/ws/events') {
|
|
42
42
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
43
|
+
const cleanup = () => { eventClients.delete(ws); };
|
|
43
44
|
eventClients.add(ws);
|
|
44
|
-
ws.on('close',
|
|
45
|
+
ws.on('close', cleanup);
|
|
46
|
+
ws.on('error', cleanup);
|
|
45
47
|
});
|
|
46
48
|
return;
|
|
47
49
|
}
|
|
@@ -99,6 +101,9 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
|
|
|
99
101
|
}
|
|
100
102
|
});
|
|
101
103
|
});
|
|
104
|
+
sessions.onIdleChange((sessionId, idle) => {
|
|
105
|
+
broadcastEvent('session-idle-changed', { sessionId, idle });
|
|
106
|
+
});
|
|
102
107
|
return { wss, broadcastEvent };
|
|
103
108
|
}
|
|
104
109
|
export { setupWebSocket };
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
var ws = null;
|
|
7
7
|
var term = null;
|
|
8
8
|
var fitAddon = null;
|
|
9
|
+
var reconnectTimer = null;
|
|
10
|
+
var reconnectAttempt = 0;
|
|
9
11
|
|
|
10
12
|
var wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
11
13
|
|
|
@@ -50,6 +52,13 @@
|
|
|
50
52
|
var imageToastText = document.getElementById('image-toast-text');
|
|
51
53
|
var imageToastInsert = document.getElementById('image-toast-insert');
|
|
52
54
|
var imageToastDismiss = document.getElementById('image-toast-dismiss');
|
|
55
|
+
var imageFileInput = document.getElementById('image-file-input');
|
|
56
|
+
var uploadImageBtn = document.getElementById('upload-image-btn');
|
|
57
|
+
var terminalScrollbar = document.getElementById('terminal-scrollbar');
|
|
58
|
+
var terminalScrollbarThumb = document.getElementById('terminal-scrollbar-thumb');
|
|
59
|
+
var mobileInput = document.getElementById('mobile-input');
|
|
60
|
+
var mobileHeader = document.getElementById('mobile-header');
|
|
61
|
+
var isMobileDevice = 'ontouchstart' in window;
|
|
53
62
|
|
|
54
63
|
// Context menu state
|
|
55
64
|
var contextMenuTarget = null; // stores { worktreePath, repoPath, name }
|
|
@@ -80,6 +89,7 @@
|
|
|
80
89
|
var cachedSessions = [];
|
|
81
90
|
var cachedWorktrees = [];
|
|
82
91
|
var allRepos = [];
|
|
92
|
+
var attentionSessions = {};
|
|
83
93
|
|
|
84
94
|
// ── PIN Auth ────────────────────────────────────────────────────────────────
|
|
85
95
|
|
|
@@ -149,6 +159,9 @@
|
|
|
149
159
|
term.open(terminalContainer);
|
|
150
160
|
fitAddon.fit();
|
|
151
161
|
|
|
162
|
+
term.onScroll(updateScrollbar);
|
|
163
|
+
term.onWriteParsed(updateScrollbar);
|
|
164
|
+
|
|
152
165
|
term.onData(function (data) {
|
|
153
166
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
154
167
|
ws.send(data);
|
|
@@ -212,6 +225,7 @@
|
|
|
212
225
|
var resizeObserver = new ResizeObserver(function () {
|
|
213
226
|
fitAddon.fit();
|
|
214
227
|
sendResize();
|
|
228
|
+
updateScrollbar();
|
|
215
229
|
});
|
|
216
230
|
resizeObserver.observe(terminalContainer);
|
|
217
231
|
}
|
|
@@ -222,41 +236,171 @@
|
|
|
222
236
|
}
|
|
223
237
|
}
|
|
224
238
|
|
|
239
|
+
// ── Terminal Scrollbar ──────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
var scrollbarDragging = false;
|
|
242
|
+
var scrollbarDragStartY = 0;
|
|
243
|
+
var scrollbarDragStartTop = 0;
|
|
244
|
+
|
|
245
|
+
function updateScrollbar() {
|
|
246
|
+
if (!term || !terminalScrollbar || terminalScrollbar.style.display === 'none') return;
|
|
247
|
+
var buf = term.buffer.active;
|
|
248
|
+
var totalLines = buf.baseY + term.rows;
|
|
249
|
+
var viewportTop = buf.viewportY;
|
|
250
|
+
var trackHeight = terminalScrollbar.clientHeight;
|
|
251
|
+
|
|
252
|
+
if (totalLines <= term.rows) {
|
|
253
|
+
terminalScrollbarThumb.style.display = 'none';
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
terminalScrollbarThumb.style.display = 'block';
|
|
258
|
+
var thumbHeight = Math.max(20, (term.rows / totalLines) * trackHeight);
|
|
259
|
+
var thumbTop = (viewportTop / (totalLines - term.rows)) * (trackHeight - thumbHeight);
|
|
260
|
+
|
|
261
|
+
terminalScrollbarThumb.style.height = thumbHeight + 'px';
|
|
262
|
+
terminalScrollbarThumb.style.top = thumbTop + 'px';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function scrollbarScrollToY(clientY) {
|
|
266
|
+
var rect = terminalScrollbar.getBoundingClientRect();
|
|
267
|
+
var buf = term.buffer.active;
|
|
268
|
+
var totalLines = buf.baseY + term.rows;
|
|
269
|
+
if (totalLines <= term.rows) return;
|
|
270
|
+
|
|
271
|
+
var thumbHeight = Math.max(20, (term.rows / totalLines) * terminalScrollbar.clientHeight);
|
|
272
|
+
var trackUsable = terminalScrollbar.clientHeight - thumbHeight;
|
|
273
|
+
var relativeY = clientY - rect.top - thumbHeight / 2;
|
|
274
|
+
var ratio = Math.max(0, Math.min(1, relativeY / trackUsable));
|
|
275
|
+
var targetLine = Math.round(ratio * (totalLines - term.rows));
|
|
276
|
+
|
|
277
|
+
term.scrollToLine(targetLine);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
terminalScrollbarThumb.addEventListener('touchstart', function (e) {
|
|
281
|
+
e.preventDefault();
|
|
282
|
+
scrollbarDragging = true;
|
|
283
|
+
scrollbarDragStartY = e.touches[0].clientY;
|
|
284
|
+
scrollbarDragStartTop = parseInt(terminalScrollbarThumb.style.top, 10) || 0;
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (isMobileDevice) {
|
|
288
|
+
document.addEventListener('touchmove', function (e) {
|
|
289
|
+
if (!scrollbarDragging) return;
|
|
290
|
+
e.preventDefault();
|
|
291
|
+
var deltaY = e.touches[0].clientY - scrollbarDragStartY;
|
|
292
|
+
var buf = term.buffer.active;
|
|
293
|
+
var totalLines = buf.baseY + term.rows;
|
|
294
|
+
if (totalLines <= term.rows) return;
|
|
295
|
+
|
|
296
|
+
var thumbHeight = Math.max(20, (term.rows / totalLines) * terminalScrollbar.clientHeight);
|
|
297
|
+
var trackUsable = terminalScrollbar.clientHeight - thumbHeight;
|
|
298
|
+
var newTop = Math.max(0, Math.min(trackUsable, scrollbarDragStartTop + deltaY));
|
|
299
|
+
var ratio = newTop / trackUsable;
|
|
300
|
+
var targetLine = Math.round(ratio * (totalLines - term.rows));
|
|
301
|
+
|
|
302
|
+
term.scrollToLine(targetLine);
|
|
303
|
+
}, { passive: false });
|
|
304
|
+
|
|
305
|
+
document.addEventListener('touchend', function () {
|
|
306
|
+
scrollbarDragging = false;
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
terminalScrollbar.addEventListener('click', function (e) {
|
|
311
|
+
if (e.target === terminalScrollbarThumb) return;
|
|
312
|
+
scrollbarScrollToY(e.clientY);
|
|
313
|
+
});
|
|
314
|
+
|
|
225
315
|
// ── WebSocket / Session Connection ──────────────────────────────────────────
|
|
226
316
|
|
|
227
317
|
function connectToSession(sessionId) {
|
|
318
|
+
if (reconnectTimer) {
|
|
319
|
+
clearTimeout(reconnectTimer);
|
|
320
|
+
reconnectTimer = null;
|
|
321
|
+
}
|
|
322
|
+
reconnectAttempt = 0;
|
|
323
|
+
|
|
228
324
|
if (ws) {
|
|
325
|
+
ws.onclose = null;
|
|
229
326
|
ws.close();
|
|
230
327
|
ws = null;
|
|
231
328
|
}
|
|
232
329
|
|
|
233
330
|
activeSessionId = sessionId;
|
|
331
|
+
delete attentionSessions[sessionId];
|
|
234
332
|
noSessionMsg.hidden = true;
|
|
235
333
|
term.clear();
|
|
236
334
|
term.focus();
|
|
237
335
|
closeSidebar();
|
|
238
336
|
updateSessionTitle();
|
|
337
|
+
highlightActiveSession();
|
|
239
338
|
|
|
339
|
+
openPtyWebSocket(sessionId);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function openPtyWebSocket(sessionId) {
|
|
240
343
|
var url = wsProtocol + '//' + location.host + '/ws/' + sessionId;
|
|
241
|
-
|
|
344
|
+
var socket = new WebSocket(url);
|
|
242
345
|
|
|
243
|
-
|
|
346
|
+
socket.onopen = function () {
|
|
347
|
+
ws = socket;
|
|
348
|
+
reconnectAttempt = 0;
|
|
244
349
|
sendResize();
|
|
245
350
|
};
|
|
246
351
|
|
|
247
|
-
|
|
352
|
+
socket.onmessage = function (event) {
|
|
248
353
|
term.write(event.data);
|
|
249
354
|
};
|
|
250
355
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
356
|
+
socket.onclose = function (event) {
|
|
357
|
+
if (event.code === 1000) {
|
|
358
|
+
term.write('\r\n[Session ended]\r\n');
|
|
359
|
+
ws = null;
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
254
362
|
|
|
255
|
-
|
|
256
|
-
|
|
363
|
+
if (activeSessionId !== sessionId) return;
|
|
364
|
+
|
|
365
|
+
ws = null;
|
|
366
|
+
if (reconnectAttempt === 0) {
|
|
367
|
+
term.write('\r\n[Reconnecting...]\r\n');
|
|
368
|
+
}
|
|
369
|
+
scheduleReconnect(sessionId);
|
|
257
370
|
};
|
|
258
371
|
|
|
259
|
-
|
|
372
|
+
socket.onerror = function () {};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
var MAX_RECONNECT_ATTEMPTS = 30;
|
|
376
|
+
|
|
377
|
+
function scheduleReconnect(sessionId) {
|
|
378
|
+
if (reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
|
|
379
|
+
term.write('\r\n[Gave up reconnecting after ' + MAX_RECONNECT_ATTEMPTS + ' attempts]\r\n');
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
var delay = Math.min(1000 * Math.pow(2, reconnectAttempt), 10000);
|
|
383
|
+
reconnectAttempt++;
|
|
384
|
+
|
|
385
|
+
reconnectTimer = setTimeout(function () {
|
|
386
|
+
reconnectTimer = null;
|
|
387
|
+
if (activeSessionId !== sessionId) return;
|
|
388
|
+
fetch('/sessions').then(function (res) {
|
|
389
|
+
return res.json();
|
|
390
|
+
}).then(function (sessions) {
|
|
391
|
+
var exists = sessions.some(function (s) { return s.id === sessionId; });
|
|
392
|
+
if (!exists || activeSessionId !== sessionId) {
|
|
393
|
+
term.write('\r\n[Session ended]\r\n');
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
term.clear();
|
|
397
|
+
openPtyWebSocket(sessionId);
|
|
398
|
+
}).catch(function () {
|
|
399
|
+
if (activeSessionId === sessionId) {
|
|
400
|
+
scheduleReconnect(sessionId);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
}, delay);
|
|
260
404
|
}
|
|
261
405
|
|
|
262
406
|
// ── Sessions & Worktrees ────────────────────────────────────────────────────
|
|
@@ -278,6 +422,14 @@
|
|
|
278
422
|
if (msg.type === 'worktrees-changed') {
|
|
279
423
|
loadRepos();
|
|
280
424
|
refreshAll();
|
|
425
|
+
} else if (msg.type === 'session-idle-changed') {
|
|
426
|
+
if (msg.idle && msg.sessionId !== activeSessionId) {
|
|
427
|
+
attentionSessions[msg.sessionId] = true;
|
|
428
|
+
}
|
|
429
|
+
if (!msg.idle) {
|
|
430
|
+
delete attentionSessions[msg.sessionId];
|
|
431
|
+
}
|
|
432
|
+
renderUnifiedList();
|
|
281
433
|
}
|
|
282
434
|
} catch (_) {}
|
|
283
435
|
};
|
|
@@ -299,6 +451,14 @@
|
|
|
299
451
|
.then(function (results) {
|
|
300
452
|
cachedSessions = results[0] || [];
|
|
301
453
|
cachedWorktrees = results[1] || [];
|
|
454
|
+
|
|
455
|
+
// Prune attention flags for sessions that no longer exist
|
|
456
|
+
var activeIds = {};
|
|
457
|
+
cachedSessions.forEach(function (s) { activeIds[s.id] = true; });
|
|
458
|
+
Object.keys(attentionSessions).forEach(function (id) {
|
|
459
|
+
if (!activeIds[id]) delete attentionSessions[id];
|
|
460
|
+
});
|
|
461
|
+
|
|
302
462
|
populateSidebarFilters();
|
|
303
463
|
renderUnifiedList();
|
|
304
464
|
})
|
|
@@ -447,6 +607,12 @@
|
|
|
447
607
|
highlightActiveSession();
|
|
448
608
|
}
|
|
449
609
|
|
|
610
|
+
function getSessionStatus(session) {
|
|
611
|
+
if (attentionSessions[session.id]) return 'attention';
|
|
612
|
+
if (session.idle) return 'idle';
|
|
613
|
+
return 'running';
|
|
614
|
+
}
|
|
615
|
+
|
|
450
616
|
function createActiveSessionLi(session) {
|
|
451
617
|
var li = document.createElement('li');
|
|
452
618
|
li.className = 'active-session';
|
|
@@ -465,6 +631,10 @@
|
|
|
465
631
|
subSpan.className = 'session-sub';
|
|
466
632
|
subSpan.textContent = (session.root ? rootShortName(session.root) : '') + ' · ' + (session.repoName || '');
|
|
467
633
|
|
|
634
|
+
var status = getSessionStatus(session);
|
|
635
|
+
var dot = document.createElement('span');
|
|
636
|
+
dot.className = 'status-dot status-dot--' + status;
|
|
637
|
+
infoDiv.appendChild(dot);
|
|
468
638
|
infoDiv.appendChild(nameSpan);
|
|
469
639
|
infoDiv.appendChild(subSpan);
|
|
470
640
|
|
|
@@ -524,6 +694,9 @@
|
|
|
524
694
|
subSpan.className = 'session-sub';
|
|
525
695
|
subSpan.textContent = (wt.root ? rootShortName(wt.root) : '') + ' · ' + (wt.repoName || '');
|
|
526
696
|
|
|
697
|
+
var dot = document.createElement('span');
|
|
698
|
+
dot.className = 'status-dot status-dot--inactive';
|
|
699
|
+
infoDiv.appendChild(dot);
|
|
527
700
|
infoDiv.appendChild(nameSpan);
|
|
528
701
|
infoDiv.appendChild(subSpan);
|
|
529
702
|
|
|
@@ -922,9 +1095,13 @@
|
|
|
922
1095
|
|
|
923
1096
|
// ── Touch Toolbar ───────────────────────────────────────────────────────────
|
|
924
1097
|
|
|
925
|
-
|
|
1098
|
+
function handleToolbarButton(e) {
|
|
926
1099
|
var btn = e.target.closest('button');
|
|
927
1100
|
if (!btn) return;
|
|
1101
|
+
// Skip the upload button (handled separately)
|
|
1102
|
+
if (btn.id === 'upload-image-btn') return;
|
|
1103
|
+
|
|
1104
|
+
e.preventDefault();
|
|
928
1105
|
|
|
929
1106
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
930
1107
|
|
|
@@ -936,6 +1113,19 @@
|
|
|
936
1113
|
} else if (key !== undefined) {
|
|
937
1114
|
ws.send(key);
|
|
938
1115
|
}
|
|
1116
|
+
|
|
1117
|
+
// Re-focus the mobile input to keep keyboard open
|
|
1118
|
+
if (isMobileDevice) {
|
|
1119
|
+
mobileInput.focus();
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
toolbar.addEventListener('touchstart', handleToolbarButton, { passive: false });
|
|
1124
|
+
|
|
1125
|
+
toolbar.addEventListener('click', function (e) {
|
|
1126
|
+
// On non-touch devices, handle normally
|
|
1127
|
+
if (isMobileDevice) return; // already handled by touchstart
|
|
1128
|
+
handleToolbarButton(e);
|
|
939
1129
|
});
|
|
940
1130
|
|
|
941
1131
|
// ── Image Paste Handling ─────────────────────────────────────────────────────
|
|
@@ -1062,6 +1252,162 @@
|
|
|
1062
1252
|
hideImageToast();
|
|
1063
1253
|
});
|
|
1064
1254
|
|
|
1255
|
+
// ── Image Upload Button (mobile) ──────────────────────────────────────────
|
|
1256
|
+
|
|
1257
|
+
uploadImageBtn.addEventListener('click', function (e) {
|
|
1258
|
+
e.preventDefault();
|
|
1259
|
+
if (!activeSessionId) return;
|
|
1260
|
+
imageFileInput.click();
|
|
1261
|
+
if (isMobileDevice) {
|
|
1262
|
+
mobileInput.focus();
|
|
1263
|
+
}
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
imageFileInput.addEventListener('change', function () {
|
|
1267
|
+
var file = imageFileInput.files[0];
|
|
1268
|
+
if (file && file.type.indexOf('image/') === 0) {
|
|
1269
|
+
uploadImage(file, file.type);
|
|
1270
|
+
}
|
|
1271
|
+
imageFileInput.value = '';
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
// ── Mobile Input Proxy ──────────────────────────────────────────────────────
|
|
1275
|
+
|
|
1276
|
+
(function () {
|
|
1277
|
+
if (!isMobileDevice) return;
|
|
1278
|
+
|
|
1279
|
+
var lastInputValue = '';
|
|
1280
|
+
|
|
1281
|
+
function focusMobileInput() {
|
|
1282
|
+
if (document.activeElement !== mobileInput) {
|
|
1283
|
+
mobileInput.focus();
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// Tap on terminal area focuses the hidden input (opens keyboard)
|
|
1288
|
+
terminalContainer.addEventListener('touchend', function (e) {
|
|
1289
|
+
// Don't interfere with scrollbar drag or selection
|
|
1290
|
+
if (scrollbarDragging) return;
|
|
1291
|
+
if (e.target === terminalScrollbarThumb || e.target === terminalScrollbar) return;
|
|
1292
|
+
focusMobileInput();
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
// When xterm would receive focus, redirect to hidden input
|
|
1296
|
+
terminalContainer.addEventListener('focus', function () {
|
|
1297
|
+
focusMobileInput();
|
|
1298
|
+
}, true);
|
|
1299
|
+
|
|
1300
|
+
// Compute the common prefix length between two strings
|
|
1301
|
+
function commonPrefixLength(a, b) {
|
|
1302
|
+
var len = 0;
|
|
1303
|
+
while (len < a.length && len < b.length && a[len] === b[len]) {
|
|
1304
|
+
len++;
|
|
1305
|
+
}
|
|
1306
|
+
return len;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// Count Unicode code points in a string (handles surrogate pairs)
|
|
1310
|
+
function codepointCount(str) {
|
|
1311
|
+
var count = 0;
|
|
1312
|
+
for (var i = 0; i < str.length; i++) {
|
|
1313
|
+
count++;
|
|
1314
|
+
if (str.charCodeAt(i) >= 0xD800 && str.charCodeAt(i) <= 0xDBFF) {
|
|
1315
|
+
i++; // skip low surrogate
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
return count;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// Send the diff between lastInputValue and currentValue to the terminal.
|
|
1322
|
+
// Handles autocorrect expansions, deletions, and same-length replacements.
|
|
1323
|
+
function sendInputDiff(currentValue) {
|
|
1324
|
+
if (currentValue === lastInputValue) return;
|
|
1325
|
+
|
|
1326
|
+
var commonLen = commonPrefixLength(lastInputValue, currentValue);
|
|
1327
|
+
var deletedSlice = lastInputValue.slice(commonLen);
|
|
1328
|
+
var charsToDelete = codepointCount(deletedSlice);
|
|
1329
|
+
var newChars = currentValue.slice(commonLen);
|
|
1330
|
+
|
|
1331
|
+
for (var i = 0; i < charsToDelete; i++) {
|
|
1332
|
+
ws.send('\x7f'); // backspace
|
|
1333
|
+
}
|
|
1334
|
+
if (newChars) {
|
|
1335
|
+
ws.send(newChars);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// Handle text input with autocorrect
|
|
1340
|
+
var clearTimer = null;
|
|
1341
|
+
mobileInput.addEventListener('input', function () {
|
|
1342
|
+
// Reset the auto-clear timer to prevent unbounded growth
|
|
1343
|
+
if (clearTimer) clearTimeout(clearTimer);
|
|
1344
|
+
clearTimer = setTimeout(function () {
|
|
1345
|
+
mobileInput.value = '';
|
|
1346
|
+
lastInputValue = '';
|
|
1347
|
+
}, 5000);
|
|
1348
|
+
|
|
1349
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
1350
|
+
|
|
1351
|
+
var currentValue = mobileInput.value;
|
|
1352
|
+
sendInputDiff(currentValue);
|
|
1353
|
+
lastInputValue = currentValue;
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
// Handle special keys (Enter, Backspace, Escape, arrows, Tab)
|
|
1357
|
+
mobileInput.addEventListener('keydown', function (e) {
|
|
1358
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
1359
|
+
|
|
1360
|
+
var handled = true;
|
|
1361
|
+
|
|
1362
|
+
switch (e.key) {
|
|
1363
|
+
case 'Enter':
|
|
1364
|
+
ws.send('\r');
|
|
1365
|
+
mobileInput.value = '';
|
|
1366
|
+
lastInputValue = '';
|
|
1367
|
+
break;
|
|
1368
|
+
case 'Backspace':
|
|
1369
|
+
if (mobileInput.value.length === 0) {
|
|
1370
|
+
// Input is empty, send backspace directly
|
|
1371
|
+
ws.send('\x7f');
|
|
1372
|
+
}
|
|
1373
|
+
// Otherwise, let the input event handle it via diff
|
|
1374
|
+
handled = false;
|
|
1375
|
+
break;
|
|
1376
|
+
case 'Escape':
|
|
1377
|
+
ws.send('\x1b');
|
|
1378
|
+
mobileInput.value = '';
|
|
1379
|
+
lastInputValue = '';
|
|
1380
|
+
break;
|
|
1381
|
+
case 'Tab':
|
|
1382
|
+
e.preventDefault();
|
|
1383
|
+
ws.send('\t');
|
|
1384
|
+
break;
|
|
1385
|
+
case 'ArrowUp':
|
|
1386
|
+
e.preventDefault();
|
|
1387
|
+
ws.send('\x1b[A');
|
|
1388
|
+
break;
|
|
1389
|
+
case 'ArrowDown':
|
|
1390
|
+
e.preventDefault();
|
|
1391
|
+
ws.send('\x1b[B');
|
|
1392
|
+
break;
|
|
1393
|
+
case 'ArrowLeft':
|
|
1394
|
+
// Let input handle cursor movement for autocorrect
|
|
1395
|
+
handled = false;
|
|
1396
|
+
break;
|
|
1397
|
+
case 'ArrowRight':
|
|
1398
|
+
handled = false;
|
|
1399
|
+
break;
|
|
1400
|
+
default:
|
|
1401
|
+
handled = false;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
if (handled) {
|
|
1405
|
+
e.preventDefault();
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
})();
|
|
1410
|
+
|
|
1065
1411
|
// ── Keyboard-Aware Viewport ─────────────────────────────────────────────────
|
|
1066
1412
|
|
|
1067
1413
|
(function () {
|
|
@@ -1073,8 +1419,10 @@
|
|
|
1073
1419
|
var keyboardHeight = window.innerHeight - vv.height;
|
|
1074
1420
|
if (keyboardHeight > 50) {
|
|
1075
1421
|
mainApp.style.height = vv.height + 'px';
|
|
1422
|
+
mobileHeader.style.display = 'none';
|
|
1076
1423
|
} else {
|
|
1077
1424
|
mainApp.style.height = '';
|
|
1425
|
+
mobileHeader.style.display = '';
|
|
1078
1426
|
}
|
|
1079
1427
|
if (fitAddon) {
|
|
1080
1428
|
fitAddon.fit();
|
package/public/index.html
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
|
6
6
|
<title>Claude Remote CLI</title>
|
|
7
|
+
<link rel="manifest" href="/manifest.json" />
|
|
8
|
+
<meta name="mobile-web-app-capable" content="yes" />
|
|
9
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
10
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
11
|
+
<meta name="theme-color" content="#1a1a1a" />
|
|
7
12
|
<link rel="stylesheet" href="/vendor/xterm.css" />
|
|
8
13
|
<link rel="stylesheet" href="/style.css" />
|
|
9
14
|
</head>
|
|
@@ -59,20 +64,23 @@
|
|
|
59
64
|
<button id="menu-btn" class="icon-btn" aria-label="Open sessions menu">☰</button>
|
|
60
65
|
<span id="session-title" class="mobile-title">No session</span>
|
|
61
66
|
</div>
|
|
67
|
+
<input type="text" id="mobile-input" autocomplete="on" autocorrect="on" autocapitalize="sentences" spellcheck="true" aria-label="Terminal input" />
|
|
62
68
|
<div id="terminal-container"></div>
|
|
69
|
+
<div id="terminal-scrollbar"><div id="terminal-scrollbar-thumb"></div></div>
|
|
63
70
|
<div id="no-session-msg">No active session. Create or select a session to begin.</div>
|
|
64
71
|
|
|
65
72
|
<!-- Touch Toolbar -->
|
|
66
73
|
<div id="toolbar">
|
|
67
74
|
<div class="toolbar-grid">
|
|
68
75
|
<button class="tb-btn" data-key="	" aria-label="Tab">Tab</button>
|
|
69
|
-
<button class="tb-btn tb-arrow" data-key="[A" aria-label="Up arrow">↑</button>
|
|
70
76
|
<button class="tb-btn" data-key="[Z" aria-label="Shift+Tab">⇧Tab</button>
|
|
77
|
+
<button class="tb-btn tb-arrow" data-key="[A" aria-label="Up arrow">↑</button>
|
|
71
78
|
<button class="tb-btn" data-key="" aria-label="Escape">Esc</button>
|
|
79
|
+
<button class="tb-btn" id="upload-image-btn" aria-label="Upload image">📷</button>
|
|
80
|
+
<button class="tb-btn" data-key="" aria-label="Ctrl+C">^C</button>
|
|
72
81
|
<button class="tb-btn tb-arrow" data-key="[D" aria-label="Left arrow">←</button>
|
|
73
82
|
<button class="tb-btn tb-arrow" data-key="[B" aria-label="Down arrow">↓</button>
|
|
74
83
|
<button class="tb-btn tb-arrow" data-key="[C" aria-label="Right arrow">→</button>
|
|
75
|
-
<button class="tb-btn" data-key="" aria-label="Ctrl+C">^C</button>
|
|
76
84
|
<button class="tb-btn tb-enter" data-key="
" aria-label="Enter">⏎</button>
|
|
77
85
|
</div>
|
|
78
86
|
</div>
|
|
@@ -89,6 +97,9 @@
|
|
|
89
97
|
</div>
|
|
90
98
|
</div>
|
|
91
99
|
|
|
100
|
+
<!-- Hidden file input for mobile image upload -->
|
|
101
|
+
<input type="file" id="image-file-input" accept="image/*" hidden />
|
|
102
|
+
|
|
92
103
|
<!-- Image Paste Toast -->
|
|
93
104
|
<div id="image-toast" hidden>
|
|
94
105
|
<div id="image-toast-content">
|
package/public/style.css
CHANGED
|
@@ -261,10 +261,12 @@ html, body {
|
|
|
261
261
|
|
|
262
262
|
.session-info {
|
|
263
263
|
display: flex;
|
|
264
|
-
flex-direction:
|
|
264
|
+
flex-direction: row;
|
|
265
|
+
flex-wrap: wrap;
|
|
265
266
|
gap: 2px;
|
|
266
267
|
min-width: 0;
|
|
267
268
|
flex: 1;
|
|
269
|
+
align-items: center;
|
|
268
270
|
}
|
|
269
271
|
|
|
270
272
|
.session-name {
|
|
@@ -276,6 +278,39 @@ html, body {
|
|
|
276
278
|
color: var(--text);
|
|
277
279
|
}
|
|
278
280
|
|
|
281
|
+
.status-dot {
|
|
282
|
+
display: inline-block;
|
|
283
|
+
width: 8px;
|
|
284
|
+
height: 8px;
|
|
285
|
+
border-radius: 50%;
|
|
286
|
+
flex-shrink: 0;
|
|
287
|
+
margin-right: 8px;
|
|
288
|
+
margin-top: 2px;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.status-dot--running {
|
|
292
|
+
background: #4ade80;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.status-dot--idle {
|
|
296
|
+
background: #60a5fa;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.status-dot--attention {
|
|
300
|
+
background: #f59e0b;
|
|
301
|
+
box-shadow: 0 0 6px 2px rgba(245, 158, 11, 0.5);
|
|
302
|
+
animation: attention-glow 2s ease-in-out infinite;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
@keyframes attention-glow {
|
|
306
|
+
0%, 100% { box-shadow: 0 0 4px 1px rgba(245, 158, 11, 0.3); }
|
|
307
|
+
50% { box-shadow: 0 0 8px 3px rgba(245, 158, 11, 0.6); }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.status-dot--inactive {
|
|
311
|
+
background: #6b7280;
|
|
312
|
+
}
|
|
313
|
+
|
|
279
314
|
.session-sub {
|
|
280
315
|
font-size: 0.7rem;
|
|
281
316
|
color: var(--text-muted);
|
|
@@ -293,6 +328,11 @@ html, body {
|
|
|
293
328
|
white-space: nowrap;
|
|
294
329
|
}
|
|
295
330
|
|
|
331
|
+
.session-sub, .session-time {
|
|
332
|
+
width: 100%;
|
|
333
|
+
padding-left: 16px; /* aligns with name text: status-dot 8px + margin-right 8px */
|
|
334
|
+
}
|
|
335
|
+
|
|
296
336
|
.session-actions {
|
|
297
337
|
display: flex;
|
|
298
338
|
align-items: center;
|
|
@@ -428,6 +468,40 @@ html, body {
|
|
|
428
468
|
pointer-events: none;
|
|
429
469
|
}
|
|
430
470
|
|
|
471
|
+
#mobile-input {
|
|
472
|
+
position: absolute;
|
|
473
|
+
top: -9999px;
|
|
474
|
+
left: -9999px;
|
|
475
|
+
width: 1px;
|
|
476
|
+
height: 1px;
|
|
477
|
+
opacity: 0;
|
|
478
|
+
font-size: 16px; /* prevents iOS zoom on focus */
|
|
479
|
+
z-index: -1;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/* ===== Terminal Scrollbar ===== */
|
|
483
|
+
#terminal-scrollbar {
|
|
484
|
+
display: none;
|
|
485
|
+
position: absolute;
|
|
486
|
+
top: 0;
|
|
487
|
+
right: 2px;
|
|
488
|
+
bottom: 0;
|
|
489
|
+
width: 6px;
|
|
490
|
+
z-index: 10;
|
|
491
|
+
pointer-events: auto;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
#terminal-scrollbar-thumb {
|
|
495
|
+
position: absolute;
|
|
496
|
+
right: 0;
|
|
497
|
+
width: 6px;
|
|
498
|
+
min-height: 20px;
|
|
499
|
+
background: var(--border);
|
|
500
|
+
border-radius: 3px;
|
|
501
|
+
opacity: 0.7;
|
|
502
|
+
touch-action: none;
|
|
503
|
+
}
|
|
504
|
+
|
|
431
505
|
/* ===== Touch Toolbar ===== */
|
|
432
506
|
#toolbar {
|
|
433
507
|
display: none;
|
|
@@ -440,10 +514,10 @@ html, body {
|
|
|
440
514
|
|
|
441
515
|
.toolbar-grid {
|
|
442
516
|
display: grid;
|
|
443
|
-
grid-template-columns:
|
|
444
|
-
grid-template-rows: auto auto
|
|
517
|
+
grid-template-columns: repeat(5, 1fr);
|
|
518
|
+
grid-template-rows: auto auto;
|
|
445
519
|
gap: 4px;
|
|
446
|
-
max-width:
|
|
520
|
+
max-width: 500px;
|
|
447
521
|
margin: 0 auto;
|
|
448
522
|
}
|
|
449
523
|
|
|
@@ -473,12 +547,6 @@ html, body {
|
|
|
473
547
|
font-size: 1.1rem;
|
|
474
548
|
}
|
|
475
549
|
|
|
476
|
-
/* Enter button: bottom-right cell only */
|
|
477
|
-
.tb-btn.tb-enter {
|
|
478
|
-
grid-column: 4;
|
|
479
|
-
grid-row: 3;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
550
|
/* ===== Dialog ===== */
|
|
483
551
|
dialog#new-session-dialog {
|
|
484
552
|
background: var(--surface);
|
|
@@ -999,18 +1067,22 @@ dialog#delete-worktree-dialog h2 {
|
|
|
999
1067
|
}
|
|
1000
1068
|
|
|
1001
1069
|
.tb-btn {
|
|
1002
|
-
padding:
|
|
1003
|
-
font-size: 0.8rem;
|
|
1004
|
-
min-height:
|
|
1070
|
+
padding: 14px 2px;
|
|
1071
|
+
font-size: min(0.8rem, 3.5vw);
|
|
1072
|
+
min-height: 44px;
|
|
1005
1073
|
}
|
|
1006
1074
|
|
|
1007
1075
|
.tb-btn.tb-arrow {
|
|
1008
|
-
font-size: 1.
|
|
1076
|
+
font-size: min(1.1rem, 4.5vw);
|
|
1009
1077
|
}
|
|
1010
1078
|
|
|
1011
1079
|
#toolbar {
|
|
1012
1080
|
display: block;
|
|
1013
1081
|
}
|
|
1082
|
+
|
|
1083
|
+
#terminal-scrollbar {
|
|
1084
|
+
display: block;
|
|
1085
|
+
}
|
|
1014
1086
|
}
|
|
1015
1087
|
|
|
1016
1088
|
/* ===== Scrollbar ===== */
|