claude-remote-cli 1.8.0 → 1.9.1

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.8.0",
3
+ "version": "1.9.1",
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
 
@@ -87,6 +89,7 @@
87
89
  var cachedSessions = [];
88
90
  var cachedWorktrees = [];
89
91
  var allRepos = [];
92
+ var attentionSessions = {};
90
93
 
91
94
  // ── PIN Auth ────────────────────────────────────────────────────────────────
92
95
 
@@ -159,65 +162,59 @@
159
162
  term.onScroll(updateScrollbar);
160
163
  term.onWriteParsed(updateScrollbar);
161
164
 
162
- term.onData(function (data) {
163
- if (ws && ws.readyState === WebSocket.OPEN) {
164
- ws.send(data);
165
+ var isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || '');
166
+ term.attachCustomKeyEventHandler(function (e) {
167
+ if (isMobileDevice) {
168
+ return false;
165
169
  }
166
- });
167
170
 
168
- // On Windows/Linux, Ctrl+V is the paste shortcut but xterm.js intercepts it
169
- // internally without firing a native paste event, so our image paste handler
170
- // on terminalContainer never runs. Intercept Ctrl+V here to check for images.
171
- // On macOS, Ctrl+V sends a raw \x16 to the terminal (used by vim etc.), so
172
- // we only intercept on non-Mac platforms.
173
- var isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || '');
174
- if (!isMac) {
175
- term.attachCustomKeyEventHandler(function (e) {
176
- if (e.type === 'keydown' && e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey &&
177
- (e.key === 'v' || e.key === 'V')) {
178
- if (navigator.clipboard && navigator.clipboard.read) {
179
- navigator.clipboard.read().then(function (clipboardItems) {
180
- var imageBlob = null;
181
- var imageType = null;
182
-
183
- for (var i = 0; i < clipboardItems.length; i++) {
184
- var types = clipboardItems[i].types;
185
- for (var j = 0; j < types.length; j++) {
186
- if (types[j].indexOf('image/') === 0) {
187
- imageType = types[j];
188
- imageBlob = clipboardItems[i];
189
- break;
190
- }
171
+ if (!isMac && e.type === 'keydown' && e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey &&
172
+ (e.key === 'v' || e.key === 'V')) {
173
+ if (navigator.clipboard && navigator.clipboard.read) {
174
+ navigator.clipboard.read().then(function (clipboardItems) {
175
+ var imageBlob = null;
176
+ var imageType = null;
177
+
178
+ for (var i = 0; i < clipboardItems.length; i++) {
179
+ var types = clipboardItems[i].types;
180
+ for (var j = 0; j < types.length; j++) {
181
+ if (types[j].indexOf('image/') === 0) {
182
+ imageType = types[j];
183
+ imageBlob = clipboardItems[i];
184
+ break;
191
185
  }
192
- if (imageBlob) break;
193
186
  }
187
+ if (imageBlob) break;
188
+ }
194
189
 
195
- if (imageBlob) {
196
- imageBlob.getType(imageType).then(function (blob) {
197
- uploadImage(blob, imageType);
198
- });
199
- } else {
200
- navigator.clipboard.readText().then(function (text) {
201
- if (text) term.paste(text);
202
- });
203
- }
204
- }).catch(function () {
205
- // Clipboard read failed (permission denied, etc.) — fall back to text.
206
- // If readText also fails, paste is lost for this keypress; this only
207
- // happens when clipboard permission is fully denied, which is rare
208
- // for user-gesture-triggered reads on HTTPS origins.
209
- if (navigator.clipboard.readText) {
210
- navigator.clipboard.readText().then(function (text) {
211
- if (text) term.paste(text);
212
- }).catch(function () {});
213
- }
214
- });
215
- return false; // Prevent xterm from handling Ctrl+V
216
- }
190
+ if (imageBlob) {
191
+ imageBlob.getType(imageType).then(function (blob) {
192
+ uploadImage(blob, imageType);
193
+ });
194
+ } else {
195
+ navigator.clipboard.readText().then(function (text) {
196
+ if (text) term.paste(text);
197
+ });
198
+ }
199
+ }).catch(function () {
200
+ if (navigator.clipboard.readText) {
201
+ navigator.clipboard.readText().then(function (text) {
202
+ if (text) term.paste(text);
203
+ }).catch(function () {});
204
+ }
205
+ });
206
+ return false;
217
207
  }
218
- return true; // Let xterm handle all other keys
219
- });
220
- }
208
+ }
209
+
210
+ return true;
211
+ });
212
+
213
+ term.onData(function (data) {
214
+ if (ws && ws.readyState === WebSocket.OPEN) {
215
+ ws.send(data);
216
+ }
217
+ });
221
218
 
222
219
  var resizeObserver = new ResizeObserver(function () {
223
220
  fitAddon.fit();
@@ -312,38 +309,98 @@
312
309
  // ── WebSocket / Session Connection ──────────────────────────────────────────
313
310
 
314
311
  function connectToSession(sessionId) {
312
+ if (reconnectTimer) {
313
+ clearTimeout(reconnectTimer);
314
+ reconnectTimer = null;
315
+ }
316
+ reconnectAttempt = 0;
317
+
315
318
  if (ws) {
319
+ ws.onclose = null;
316
320
  ws.close();
317
321
  ws = null;
318
322
  }
319
323
 
320
324
  activeSessionId = sessionId;
325
+ delete attentionSessions[sessionId];
321
326
  noSessionMsg.hidden = true;
322
327
  term.clear();
323
- term.focus();
328
+ if (isMobileDevice) {
329
+ mobileInput.value = '';
330
+ mobileInput.dispatchEvent(new Event('sessionchange'));
331
+ mobileInput.focus();
332
+ } else {
333
+ term.focus();
334
+ }
324
335
  closeSidebar();
325
336
  updateSessionTitle();
337
+ highlightActiveSession();
326
338
 
339
+ openPtyWebSocket(sessionId);
340
+ }
341
+
342
+ function openPtyWebSocket(sessionId) {
327
343
  var url = wsProtocol + '//' + location.host + '/ws/' + sessionId;
328
- ws = new WebSocket(url);
344
+ var socket = new WebSocket(url);
329
345
 
330
- ws.onopen = function () {
346
+ socket.onopen = function () {
347
+ ws = socket;
348
+ reconnectAttempt = 0;
331
349
  sendResize();
332
350
  };
333
351
 
334
- ws.onmessage = function (event) {
352
+ socket.onmessage = function (event) {
335
353
  term.write(event.data);
336
354
  };
337
355
 
338
- ws.onclose = function () {
339
- term.write('\r\n[Connection closed]\r\n');
340
- };
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
+ }
362
+
363
+ if (activeSessionId !== sessionId) return;
341
364
 
342
- ws.onerror = function () {
343
- term.write('\r\n[WebSocket error]\r\n');
365
+ ws = null;
366
+ if (reconnectAttempt === 0) {
367
+ term.write('\r\n[Reconnecting...]\r\n');
368
+ }
369
+ scheduleReconnect(sessionId);
344
370
  };
345
371
 
346
- 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);
347
404
  }
348
405
 
349
406
  // ── Sessions & Worktrees ────────────────────────────────────────────────────
@@ -365,6 +422,14 @@
365
422
  if (msg.type === 'worktrees-changed') {
366
423
  loadRepos();
367
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();
368
433
  }
369
434
  } catch (_) {}
370
435
  };
@@ -386,6 +451,14 @@
386
451
  .then(function (results) {
387
452
  cachedSessions = results[0] || [];
388
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
+
389
462
  populateSidebarFilters();
390
463
  renderUnifiedList();
391
464
  })
@@ -534,6 +607,12 @@
534
607
  highlightActiveSession();
535
608
  }
536
609
 
610
+ function getSessionStatus(session) {
611
+ if (attentionSessions[session.id]) return 'attention';
612
+ if (session.idle) return 'idle';
613
+ return 'running';
614
+ }
615
+
537
616
  function createActiveSessionLi(session) {
538
617
  var li = document.createElement('li');
539
618
  li.className = 'active-session';
@@ -552,6 +631,10 @@
552
631
  subSpan.className = 'session-sub';
553
632
  subSpan.textContent = (session.root ? rootShortName(session.root) : '') + ' · ' + (session.repoName || '');
554
633
 
634
+ var status = getSessionStatus(session);
635
+ var dot = document.createElement('span');
636
+ dot.className = 'status-dot status-dot--' + status;
637
+ infoDiv.appendChild(dot);
555
638
  infoDiv.appendChild(nameSpan);
556
639
  infoDiv.appendChild(subSpan);
557
640
 
@@ -611,6 +694,9 @@
611
694
  subSpan.className = 'session-sub';
612
695
  subSpan.textContent = (wt.root ? rootShortName(wt.root) : '') + ' · ' + (wt.repoName || '');
613
696
 
697
+ var dot = document.createElement('span');
698
+ dot.className = 'status-dot status-dot--inactive';
699
+ infoDiv.appendChild(dot);
614
700
  infoDiv.appendChild(nameSpan);
615
701
  infoDiv.appendChild(subSpan);
616
702
 
@@ -1191,6 +1277,7 @@
1191
1277
  if (!isMobileDevice) return;
1192
1278
 
1193
1279
  var lastInputValue = '';
1280
+ var isComposing = false;
1194
1281
 
1195
1282
  function focusMobileInput() {
1196
1283
  if (document.activeElement !== mobileInput) {
@@ -1250,6 +1337,31 @@
1250
1337
  }
1251
1338
  }
1252
1339
 
1340
+ mobileInput.addEventListener('compositionstart', function () {
1341
+ isComposing = true;
1342
+ });
1343
+
1344
+ mobileInput.addEventListener('compositionend', function () {
1345
+ if (ws && ws.readyState === WebSocket.OPEN) {
1346
+ var currentValue = mobileInput.value;
1347
+ sendInputDiff(currentValue);
1348
+ lastInputValue = currentValue;
1349
+ }
1350
+ setTimeout(function () { isComposing = false; }, 0);
1351
+ });
1352
+
1353
+ mobileInput.addEventListener('blur', function () {
1354
+ if (isComposing) {
1355
+ isComposing = false;
1356
+ lastInputValue = mobileInput.value;
1357
+ }
1358
+ });
1359
+
1360
+ mobileInput.addEventListener('sessionchange', function () {
1361
+ isComposing = false;
1362
+ lastInputValue = '';
1363
+ });
1364
+
1253
1365
  // Handle text input with autocorrect
1254
1366
  var clearTimer = null;
1255
1367
  mobileInput.addEventListener('input', function () {
@@ -1262,6 +1374,8 @@
1262
1374
 
1263
1375
  if (!ws || ws.readyState !== WebSocket.OPEN) return;
1264
1376
 
1377
+ if (isComposing) return;
1378
+
1265
1379
  var currentValue = mobileInput.value;
1266
1380
  sendInputDiff(currentValue);
1267
1381
  lastInputValue = currentValue;
@@ -1293,24 +1407,14 @@
1293
1407
  lastInputValue = '';
1294
1408
  break;
1295
1409
  case 'Tab':
1296
- e.preventDefault();
1297
1410
  ws.send('\t');
1298
1411
  break;
1299
1412
  case 'ArrowUp':
1300
- e.preventDefault();
1301
1413
  ws.send('\x1b[A');
1302
1414
  break;
1303
1415
  case 'ArrowDown':
1304
- e.preventDefault();
1305
1416
  ws.send('\x1b[B');
1306
1417
  break;
1307
- case 'ArrowLeft':
1308
- // Let input handle cursor movement for autocorrect
1309
- handled = false;
1310
- break;
1311
- case 'ArrowRight':
1312
- handled = false;
1313
- break;
1314
1418
  default:
1315
1419
  handled = false;
1316
1420
  }
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2
+ <rect width="512" height="512" rx="64" fill="#1a1a1a"/>
3
+ <text x="256" y="200" text-anchor="middle" font-family="monospace" font-size="72" font-weight="bold" fill="#d97757">&gt;_</text>
4
+ <text x="256" y="320" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="56" font-weight="600" fill="#ececec">Claude</text>
5
+ <text x="256" y="390" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="40" fill="#9b9b9b">Remote CLI</text>
6
+ </svg>
package/public/index.html CHANGED
@@ -5,6 +5,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
7
  <link rel="manifest" href="/manifest.json" />
8
+ <link rel="icon" href="/icon.svg" type="image/svg+xml" />
9
+ <link rel="apple-touch-icon" href="/icon.svg" />
8
10
  <meta name="mobile-web-app-capable" content="yes" />
9
11
  <meta name="apple-mobile-web-app-capable" content="yes" />
10
12
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
@@ -64,7 +66,7 @@
64
66
  <button id="menu-btn" class="icon-btn" aria-label="Open sessions menu">&#9776;</button>
65
67
  <span id="session-title" class="mobile-title">No session</span>
66
68
  </div>
67
- <input type="text" id="mobile-input" autocomplete="on" autocorrect="on" autocapitalize="sentences" spellcheck="true" aria-label="Terminal input" />
69
+ <input type="text" id="mobile-input" dir="ltr" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" aria-label="Terminal input" />
68
70
  <div id="terminal-container"></div>
69
71
  <div id="terminal-scrollbar"><div id="terminal-scrollbar-thumb"></div></div>
70
72
  <div id="no-session-msg">No active session. Create or select a session to begin.</div>
@@ -4,5 +4,13 @@
4
4
  "display": "standalone",
5
5
  "start_url": "/",
6
6
  "background_color": "#1a1a1a",
7
- "theme_color": "#1a1a1a"
7
+ "theme_color": "#1a1a1a",
8
+ "icons": [
9
+ {
10
+ "src": "/icon.svg",
11
+ "sizes": "any",
12
+ "type": "image/svg+xml",
13
+ "purpose": "any"
14
+ }
15
+ ]
8
16
  }
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;
@@ -430,13 +470,20 @@ html, body {
430
470
 
431
471
  #mobile-input {
432
472
  position: absolute;
433
- top: -9999px;
434
- left: -9999px;
473
+ left: 0;
474
+ top: 0;
435
475
  width: 1px;
436
476
  height: 1px;
437
477
  opacity: 0;
438
478
  font-size: 16px; /* prevents iOS zoom on focus */
439
479
  z-index: -1;
480
+ border: 0;
481
+ padding: 0;
482
+ margin: 0;
483
+ outline: none;
484
+ color: transparent;
485
+ caret-color: transparent;
486
+ background: transparent;
440
487
  }
441
488
 
442
489
  /* ===== Terminal Scrollbar ===== */