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.
@@ -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', () => { eventClients.delete(ws); });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "1.7.2",
3
+ "version": "1.9.0",
4
4
  "description": "Remote web interface for Claude Code CLI sessions",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",
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
- ws = new WebSocket(url);
344
+ var socket = new WebSocket(url);
242
345
 
243
- ws.onopen = function () {
346
+ socket.onopen = function () {
347
+ ws = socket;
348
+ reconnectAttempt = 0;
244
349
  sendResize();
245
350
  };
246
351
 
247
- ws.onmessage = function (event) {
352
+ socket.onmessage = function (event) {
248
353
  term.write(event.data);
249
354
  };
250
355
 
251
- ws.onclose = function () {
252
- term.write('\r\n[Connection closed]\r\n');
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
- ws.onerror = function () {
256
- term.write('\r\n[WebSocket error]\r\n');
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
- highlightActiveSession();
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
- toolbar.addEventListener('click', function (e) {
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">&#9776;</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="&#x09;" aria-label="Tab">Tab</button>
69
- <button class="tb-btn tb-arrow" data-key="&#x1b;[A" aria-label="Up arrow">&#8593;</button>
70
76
  <button class="tb-btn" data-key="&#x1b;[Z" aria-label="Shift+Tab">&#8679;Tab</button>
77
+ <button class="tb-btn tb-arrow" data-key="&#x1b;[A" aria-label="Up arrow">&#8593;</button>
71
78
  <button class="tb-btn" data-key="&#x1b;" aria-label="Escape">Esc</button>
79
+ <button class="tb-btn" id="upload-image-btn" aria-label="Upload image">&#128247;</button>
80
+ <button class="tb-btn" data-key="&#x03;" aria-label="Ctrl+C">^C</button>
72
81
  <button class="tb-btn tb-arrow" data-key="&#x1b;[D" aria-label="Left arrow">&#8592;</button>
73
82
  <button class="tb-btn tb-arrow" data-key="&#x1b;[B" aria-label="Down arrow">&#8595;</button>
74
83
  <button class="tb-btn tb-arrow" data-key="&#x1b;[C" aria-label="Right arrow">&#8594;</button>
75
- <button class="tb-btn" data-key="&#x03;" aria-label="Ctrl+C">^C</button>
76
84
  <button class="tb-btn tb-enter" data-key="&#x0d;" aria-label="Enter">&#9166;</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">
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "Claude Remote CLI",
3
+ "short_name": "Claude CLI",
4
+ "display": "standalone",
5
+ "start_url": "/",
6
+ "background_color": "#1a1a1a",
7
+ "theme_color": "#1a1a1a"
8
+ }
package/public/style.css CHANGED
@@ -261,10 +261,12 @@ html, body {
261
261
 
262
262
  .session-info {
263
263
  display: flex;
264
- flex-direction: column;
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: 1fr 1fr 1fr 1fr;
444
- grid-template-rows: auto auto auto;
517
+ grid-template-columns: repeat(5, 1fr);
518
+ grid-template-rows: auto auto;
445
519
  gap: 4px;
446
- max-width: 400px;
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: 16px 2px;
1003
- font-size: 0.8rem;
1004
- min-height: 48px;
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.2rem;
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 ===== */