claude-remote-cli 1.7.1 → 1.8.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/README.md CHANGED
@@ -17,14 +17,14 @@ claude-remote-cli
17
17
  git clone https://github.com/donovan-yohan/claude-remote-cli.git
18
18
  cd claude-remote-cli
19
19
  npm install
20
- node server/index.js
20
+ npm start
21
21
  ```
22
22
 
23
23
  On first launch you'll be prompted to set a PIN. Then open `http://localhost:3456` in your browser.
24
24
 
25
25
  ## Prerequisites
26
26
 
27
- - **Node.js 20+**
27
+ - **Node.js 24+**
28
28
  - **Claude Code CLI** installed and available in your PATH (or configure `claudeCommand` in config)
29
29
 
30
30
  ## Platform Support
@@ -38,6 +38,7 @@ Usage: claude-remote-cli [options]
38
38
  claude-remote-cli <command>
39
39
 
40
40
  Commands:
41
+ update Update to the latest version from npm
41
42
  install Install as a background service (survives reboot)
42
43
  uninstall Stop and remove the background service
43
44
  status Show whether the service is running
@@ -106,32 +107,45 @@ The PIN hash is stored in config under `pinHash`. To reset:
106
107
 
107
108
  - **PIN-protected access** with rate limiting
108
109
  - **Worktree isolation** — each session runs in its own Claude Code `--worktree`
109
- - **Resume sessions** — click inactive worktrees to reconnect
110
+ - **Resume sessions** — click inactive worktrees to reconnect with `--continue`
111
+ - **Persistent session names** — display names and timestamps survive server restarts
112
+ - **Clipboard image paste** — paste screenshots directly into remote terminal sessions (macOS clipboard + xclip on Linux)
113
+ - **Yolo mode** — skip permission prompts with `--dangerously-skip-permissions` (per-session checkbox or context menu)
114
+ - **Worktree cleanup** — delete inactive worktrees from the context menu (removes worktree, prunes refs, deletes branch)
110
115
  - **Sidebar filters** — filter by root directory, repo, or text search
111
- - **Inline rename** — rename sessions with the pencil icon (syncs with Claude Code's `/rename`)
116
+ - **Inline rename** — rename sessions with the pencil icon
112
117
  - **Scrollback buffer** — reconnect to a session and see prior output
113
- - **Touch toolbar** — mobile-friendly buttons for special keys (arrows, Enter, Escape, Ctrl+C, Tab, y/n)
118
+ - **Touch toolbar** — mobile-friendly buttons for special keys (hidden on desktop)
114
119
  - **Responsive layout** — works on desktop and mobile with slide-out sidebar
115
120
  - **Real-time updates** — worktree changes on disk are pushed to the browser instantly via WebSocket
121
+ - **Update notifications** — toast notification when a new version is available, with one-click update
122
+ - **CLI self-update** — `claude-remote-cli update` to update from npm
116
123
 
117
124
  ## Architecture
118
125
 
126
+ TypeScript + ESM backend compiled to `dist/`. Vanilla JS frontend (no build step).
127
+
119
128
  ```
120
129
  claude-remote-cli/
121
130
  ├── bin/
122
- │ └── claude-remote-cli.js # CLI entry point
131
+ │ └── claude-remote-cli.ts # CLI entry point
123
132
  ├── server/
124
- │ ├── index.js # Express server, REST API routes
125
- │ ├── sessions.js # PTY session manager (node-pty)
126
- │ ├── ws.js # WebSocket relay (PTY ↔ browser)
127
- │ ├── watcher.js # File watcher for .claude/worktrees/ changes
128
- │ ├── auth.js # PIN hashing, verification, rate limiting
129
- └── config.js # Config loading/saving
133
+ │ ├── index.ts # Express server, REST API routes
134
+ │ ├── sessions.ts # PTY session manager (node-pty)
135
+ │ ├── ws.ts # WebSocket relay (PTY ↔ browser)
136
+ │ ├── watcher.ts # File watcher for .claude/worktrees/ changes
137
+ │ ├── auth.ts # PIN hashing, verification, rate limiting
138
+ ├── config.ts # Config loading/saving, worktree metadata
139
+ │ ├── clipboard.ts # System clipboard operations (image paste)
140
+ │ ├── service.ts # Background service management (launchd/systemd)
141
+ │ └── types.ts # Shared TypeScript interfaces
130
142
  ├── public/
131
- │ ├── index.html # Single-page app
132
- │ ├── app.js # Frontend logic
133
- │ ├── style.css # Styles (dark theme)
134
- │ └── vendor/ # Self-hosted xterm.js + addon-fit
143
+ │ ├── index.html # Single-page app
144
+ │ ├── app.js # Frontend logic (ES5, no build step)
145
+ │ ├── style.css # Styles (dark theme)
146
+ │ └── vendor/ # Self-hosted xterm.js + addon-fit
147
+ ├── test/ # Unit tests (node:test)
148
+ ├── dist/ # Compiled output (gitignored)
135
149
  ├── config.example.json
136
150
  └── package.json
137
151
  ```
@@ -332,21 +332,26 @@ async function main() {
332
332
  let args;
333
333
  let cwd;
334
334
  let worktreeName;
335
+ let sessionRepoPath;
335
336
  if (worktreePath) {
336
337
  // Resume existing worktree — run claude --continue inside the worktree directory
337
338
  args = ['--continue', ...baseArgs];
338
339
  cwd = worktreePath;
340
+ sessionRepoPath = worktreePath;
339
341
  worktreeName = worktreePath.split('/').pop() || '';
340
342
  }
341
343
  else {
342
- // New worktree
344
+ // New worktree — PTY spawns in the repo root (so `claude --worktree X` works),
345
+ // but repoPath points to the expected worktree dir for identity/metadata matching
343
346
  worktreeName = 'mobile-' + name + '-' + Date.now().toString(36);
344
347
  args = ['--worktree', worktreeName, ...baseArgs];
345
348
  cwd = repoPath;
349
+ sessionRepoPath = path.join(repoPath, '.claude', 'worktrees', worktreeName);
346
350
  }
347
351
  const session = sessions.create({
348
352
  repoName: name,
349
- repoPath: cwd,
353
+ repoPath: sessionRepoPath,
354
+ cwd,
350
355
  root,
351
356
  worktreeName,
352
357
  displayName: worktreeName,
@@ -6,7 +6,7 @@ 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
- function create({ repoName, repoPath, root, worktreeName, displayName, command, args = [], cols = 80, rows = 24, configPath }) {
9
+ function create({ repoName, repoPath, cwd, root, worktreeName, displayName, command, args = [], cols = 80, rows = 24, configPath }) {
10
10
  const id = crypto.randomBytes(8).toString('hex');
11
11
  const createdAt = new Date().toISOString();
12
12
  // Strip CLAUDECODE env var to allow spawning claude inside a claude-managed server
@@ -16,7 +16,7 @@ function create({ repoName, repoPath, root, worktreeName, displayName, command,
16
16
  name: 'xterm-256color',
17
17
  cols,
18
18
  rows,
19
- cwd: repoPath,
19
+ cwd: cwd || repoPath,
20
20
  env,
21
21
  });
22
22
  // Scrollback buffer: stores all PTY output so we can replay on WebSocket (re)connect
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "1.7.1",
3
+ "version": "1.8.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
@@ -50,6 +50,13 @@
50
50
  var imageToastText = document.getElementById('image-toast-text');
51
51
  var imageToastInsert = document.getElementById('image-toast-insert');
52
52
  var imageToastDismiss = document.getElementById('image-toast-dismiss');
53
+ var imageFileInput = document.getElementById('image-file-input');
54
+ var uploadImageBtn = document.getElementById('upload-image-btn');
55
+ var terminalScrollbar = document.getElementById('terminal-scrollbar');
56
+ var terminalScrollbarThumb = document.getElementById('terminal-scrollbar-thumb');
57
+ var mobileInput = document.getElementById('mobile-input');
58
+ var mobileHeader = document.getElementById('mobile-header');
59
+ var isMobileDevice = 'ontouchstart' in window;
53
60
 
54
61
  // Context menu state
55
62
  var contextMenuTarget = null; // stores { worktreePath, repoPath, name }
@@ -149,6 +156,9 @@
149
156
  term.open(terminalContainer);
150
157
  fitAddon.fit();
151
158
 
159
+ term.onScroll(updateScrollbar);
160
+ term.onWriteParsed(updateScrollbar);
161
+
152
162
  term.onData(function (data) {
153
163
  if (ws && ws.readyState === WebSocket.OPEN) {
154
164
  ws.send(data);
@@ -212,6 +222,7 @@
212
222
  var resizeObserver = new ResizeObserver(function () {
213
223
  fitAddon.fit();
214
224
  sendResize();
225
+ updateScrollbar();
215
226
  });
216
227
  resizeObserver.observe(terminalContainer);
217
228
  }
@@ -222,6 +233,82 @@
222
233
  }
223
234
  }
224
235
 
236
+ // ── Terminal Scrollbar ──────────────────────────────────────────────────────
237
+
238
+ var scrollbarDragging = false;
239
+ var scrollbarDragStartY = 0;
240
+ var scrollbarDragStartTop = 0;
241
+
242
+ function updateScrollbar() {
243
+ if (!term || !terminalScrollbar || terminalScrollbar.style.display === 'none') return;
244
+ var buf = term.buffer.active;
245
+ var totalLines = buf.baseY + term.rows;
246
+ var viewportTop = buf.viewportY;
247
+ var trackHeight = terminalScrollbar.clientHeight;
248
+
249
+ if (totalLines <= term.rows) {
250
+ terminalScrollbarThumb.style.display = 'none';
251
+ return;
252
+ }
253
+
254
+ terminalScrollbarThumb.style.display = 'block';
255
+ var thumbHeight = Math.max(20, (term.rows / totalLines) * trackHeight);
256
+ var thumbTop = (viewportTop / (totalLines - term.rows)) * (trackHeight - thumbHeight);
257
+
258
+ terminalScrollbarThumb.style.height = thumbHeight + 'px';
259
+ terminalScrollbarThumb.style.top = thumbTop + 'px';
260
+ }
261
+
262
+ function scrollbarScrollToY(clientY) {
263
+ var rect = terminalScrollbar.getBoundingClientRect();
264
+ var buf = term.buffer.active;
265
+ var totalLines = buf.baseY + term.rows;
266
+ if (totalLines <= term.rows) return;
267
+
268
+ var thumbHeight = Math.max(20, (term.rows / totalLines) * terminalScrollbar.clientHeight);
269
+ var trackUsable = terminalScrollbar.clientHeight - thumbHeight;
270
+ var relativeY = clientY - rect.top - thumbHeight / 2;
271
+ var ratio = Math.max(0, Math.min(1, relativeY / trackUsable));
272
+ var targetLine = Math.round(ratio * (totalLines - term.rows));
273
+
274
+ term.scrollToLine(targetLine);
275
+ }
276
+
277
+ terminalScrollbarThumb.addEventListener('touchstart', function (e) {
278
+ e.preventDefault();
279
+ scrollbarDragging = true;
280
+ scrollbarDragStartY = e.touches[0].clientY;
281
+ scrollbarDragStartTop = parseInt(terminalScrollbarThumb.style.top, 10) || 0;
282
+ });
283
+
284
+ if (isMobileDevice) {
285
+ document.addEventListener('touchmove', function (e) {
286
+ if (!scrollbarDragging) return;
287
+ e.preventDefault();
288
+ var deltaY = e.touches[0].clientY - scrollbarDragStartY;
289
+ var buf = term.buffer.active;
290
+ var totalLines = buf.baseY + term.rows;
291
+ if (totalLines <= term.rows) return;
292
+
293
+ var thumbHeight = Math.max(20, (term.rows / totalLines) * terminalScrollbar.clientHeight);
294
+ var trackUsable = terminalScrollbar.clientHeight - thumbHeight;
295
+ var newTop = Math.max(0, Math.min(trackUsable, scrollbarDragStartTop + deltaY));
296
+ var ratio = newTop / trackUsable;
297
+ var targetLine = Math.round(ratio * (totalLines - term.rows));
298
+
299
+ term.scrollToLine(targetLine);
300
+ }, { passive: false });
301
+
302
+ document.addEventListener('touchend', function () {
303
+ scrollbarDragging = false;
304
+ });
305
+ }
306
+
307
+ terminalScrollbar.addEventListener('click', function (e) {
308
+ if (e.target === terminalScrollbarThumb) return;
309
+ scrollbarScrollToY(e.clientY);
310
+ });
311
+
225
312
  // ── WebSocket / Session Connection ──────────────────────────────────────────
226
313
 
227
314
  function connectToSession(sessionId) {
@@ -922,9 +1009,13 @@
922
1009
 
923
1010
  // ── Touch Toolbar ───────────────────────────────────────────────────────────
924
1011
 
925
- toolbar.addEventListener('click', function (e) {
1012
+ function handleToolbarButton(e) {
926
1013
  var btn = e.target.closest('button');
927
1014
  if (!btn) return;
1015
+ // Skip the upload button (handled separately)
1016
+ if (btn.id === 'upload-image-btn') return;
1017
+
1018
+ e.preventDefault();
928
1019
 
929
1020
  if (!ws || ws.readyState !== WebSocket.OPEN) return;
930
1021
 
@@ -936,6 +1027,19 @@
936
1027
  } else if (key !== undefined) {
937
1028
  ws.send(key);
938
1029
  }
1030
+
1031
+ // Re-focus the mobile input to keep keyboard open
1032
+ if (isMobileDevice) {
1033
+ mobileInput.focus();
1034
+ }
1035
+ }
1036
+
1037
+ toolbar.addEventListener('touchstart', handleToolbarButton, { passive: false });
1038
+
1039
+ toolbar.addEventListener('click', function (e) {
1040
+ // On non-touch devices, handle normally
1041
+ if (isMobileDevice) return; // already handled by touchstart
1042
+ handleToolbarButton(e);
939
1043
  });
940
1044
 
941
1045
  // ── Image Paste Handling ─────────────────────────────────────────────────────
@@ -1062,6 +1166,162 @@
1062
1166
  hideImageToast();
1063
1167
  });
1064
1168
 
1169
+ // ── Image Upload Button (mobile) ──────────────────────────────────────────
1170
+
1171
+ uploadImageBtn.addEventListener('click', function (e) {
1172
+ e.preventDefault();
1173
+ if (!activeSessionId) return;
1174
+ imageFileInput.click();
1175
+ if (isMobileDevice) {
1176
+ mobileInput.focus();
1177
+ }
1178
+ });
1179
+
1180
+ imageFileInput.addEventListener('change', function () {
1181
+ var file = imageFileInput.files[0];
1182
+ if (file && file.type.indexOf('image/') === 0) {
1183
+ uploadImage(file, file.type);
1184
+ }
1185
+ imageFileInput.value = '';
1186
+ });
1187
+
1188
+ // ── Mobile Input Proxy ──────────────────────────────────────────────────────
1189
+
1190
+ (function () {
1191
+ if (!isMobileDevice) return;
1192
+
1193
+ var lastInputValue = '';
1194
+
1195
+ function focusMobileInput() {
1196
+ if (document.activeElement !== mobileInput) {
1197
+ mobileInput.focus();
1198
+ }
1199
+ }
1200
+
1201
+ // Tap on terminal area focuses the hidden input (opens keyboard)
1202
+ terminalContainer.addEventListener('touchend', function (e) {
1203
+ // Don't interfere with scrollbar drag or selection
1204
+ if (scrollbarDragging) return;
1205
+ if (e.target === terminalScrollbarThumb || e.target === terminalScrollbar) return;
1206
+ focusMobileInput();
1207
+ });
1208
+
1209
+ // When xterm would receive focus, redirect to hidden input
1210
+ terminalContainer.addEventListener('focus', function () {
1211
+ focusMobileInput();
1212
+ }, true);
1213
+
1214
+ // Compute the common prefix length between two strings
1215
+ function commonPrefixLength(a, b) {
1216
+ var len = 0;
1217
+ while (len < a.length && len < b.length && a[len] === b[len]) {
1218
+ len++;
1219
+ }
1220
+ return len;
1221
+ }
1222
+
1223
+ // Count Unicode code points in a string (handles surrogate pairs)
1224
+ function codepointCount(str) {
1225
+ var count = 0;
1226
+ for (var i = 0; i < str.length; i++) {
1227
+ count++;
1228
+ if (str.charCodeAt(i) >= 0xD800 && str.charCodeAt(i) <= 0xDBFF) {
1229
+ i++; // skip low surrogate
1230
+ }
1231
+ }
1232
+ return count;
1233
+ }
1234
+
1235
+ // Send the diff between lastInputValue and currentValue to the terminal.
1236
+ // Handles autocorrect expansions, deletions, and same-length replacements.
1237
+ function sendInputDiff(currentValue) {
1238
+ if (currentValue === lastInputValue) return;
1239
+
1240
+ var commonLen = commonPrefixLength(lastInputValue, currentValue);
1241
+ var deletedSlice = lastInputValue.slice(commonLen);
1242
+ var charsToDelete = codepointCount(deletedSlice);
1243
+ var newChars = currentValue.slice(commonLen);
1244
+
1245
+ for (var i = 0; i < charsToDelete; i++) {
1246
+ ws.send('\x7f'); // backspace
1247
+ }
1248
+ if (newChars) {
1249
+ ws.send(newChars);
1250
+ }
1251
+ }
1252
+
1253
+ // Handle text input with autocorrect
1254
+ var clearTimer = null;
1255
+ mobileInput.addEventListener('input', function () {
1256
+ // Reset the auto-clear timer to prevent unbounded growth
1257
+ if (clearTimer) clearTimeout(clearTimer);
1258
+ clearTimer = setTimeout(function () {
1259
+ mobileInput.value = '';
1260
+ lastInputValue = '';
1261
+ }, 5000);
1262
+
1263
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
1264
+
1265
+ var currentValue = mobileInput.value;
1266
+ sendInputDiff(currentValue);
1267
+ lastInputValue = currentValue;
1268
+ });
1269
+
1270
+ // Handle special keys (Enter, Backspace, Escape, arrows, Tab)
1271
+ mobileInput.addEventListener('keydown', function (e) {
1272
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
1273
+
1274
+ var handled = true;
1275
+
1276
+ switch (e.key) {
1277
+ case 'Enter':
1278
+ ws.send('\r');
1279
+ mobileInput.value = '';
1280
+ lastInputValue = '';
1281
+ break;
1282
+ case 'Backspace':
1283
+ if (mobileInput.value.length === 0) {
1284
+ // Input is empty, send backspace directly
1285
+ ws.send('\x7f');
1286
+ }
1287
+ // Otherwise, let the input event handle it via diff
1288
+ handled = false;
1289
+ break;
1290
+ case 'Escape':
1291
+ ws.send('\x1b');
1292
+ mobileInput.value = '';
1293
+ lastInputValue = '';
1294
+ break;
1295
+ case 'Tab':
1296
+ e.preventDefault();
1297
+ ws.send('\t');
1298
+ break;
1299
+ case 'ArrowUp':
1300
+ e.preventDefault();
1301
+ ws.send('\x1b[A');
1302
+ break;
1303
+ case 'ArrowDown':
1304
+ e.preventDefault();
1305
+ ws.send('\x1b[B');
1306
+ 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
+ default:
1315
+ handled = false;
1316
+ }
1317
+
1318
+ if (handled) {
1319
+ e.preventDefault();
1320
+ }
1321
+ });
1322
+
1323
+ })();
1324
+
1065
1325
  // ── Keyboard-Aware Viewport ─────────────────────────────────────────────────
1066
1326
 
1067
1327
  (function () {
@@ -1073,8 +1333,10 @@
1073
1333
  var keyboardHeight = window.innerHeight - vv.height;
1074
1334
  if (keyboardHeight > 50) {
1075
1335
  mainApp.style.height = vv.height + 'px';
1336
+ mobileHeader.style.display = 'none';
1076
1337
  } else {
1077
1338
  mainApp.style.height = '';
1339
+ mobileHeader.style.display = '';
1078
1340
  }
1079
1341
  if (fitAddon) {
1080
1342
  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
@@ -428,6 +428,40 @@ html, body {
428
428
  pointer-events: none;
429
429
  }
430
430
 
431
+ #mobile-input {
432
+ position: absolute;
433
+ top: -9999px;
434
+ left: -9999px;
435
+ width: 1px;
436
+ height: 1px;
437
+ opacity: 0;
438
+ font-size: 16px; /* prevents iOS zoom on focus */
439
+ z-index: -1;
440
+ }
441
+
442
+ /* ===== Terminal Scrollbar ===== */
443
+ #terminal-scrollbar {
444
+ display: none;
445
+ position: absolute;
446
+ top: 0;
447
+ right: 2px;
448
+ bottom: 0;
449
+ width: 6px;
450
+ z-index: 10;
451
+ pointer-events: auto;
452
+ }
453
+
454
+ #terminal-scrollbar-thumb {
455
+ position: absolute;
456
+ right: 0;
457
+ width: 6px;
458
+ min-height: 20px;
459
+ background: var(--border);
460
+ border-radius: 3px;
461
+ opacity: 0.7;
462
+ touch-action: none;
463
+ }
464
+
431
465
  /* ===== Touch Toolbar ===== */
432
466
  #toolbar {
433
467
  display: none;
@@ -440,10 +474,10 @@ html, body {
440
474
 
441
475
  .toolbar-grid {
442
476
  display: grid;
443
- grid-template-columns: 1fr 1fr 1fr 1fr;
444
- grid-template-rows: auto auto auto;
477
+ grid-template-columns: repeat(5, 1fr);
478
+ grid-template-rows: auto auto;
445
479
  gap: 4px;
446
- max-width: 400px;
480
+ max-width: 500px;
447
481
  margin: 0 auto;
448
482
  }
449
483
 
@@ -473,12 +507,6 @@ html, body {
473
507
  font-size: 1.1rem;
474
508
  }
475
509
 
476
- /* Enter button: bottom-right cell only */
477
- .tb-btn.tb-enter {
478
- grid-column: 4;
479
- grid-row: 3;
480
- }
481
-
482
510
  /* ===== Dialog ===== */
483
511
  dialog#new-session-dialog {
484
512
  background: var(--surface);
@@ -999,18 +1027,22 @@ dialog#delete-worktree-dialog h2 {
999
1027
  }
1000
1028
 
1001
1029
  .tb-btn {
1002
- padding: 16px 2px;
1003
- font-size: 0.8rem;
1004
- min-height: 48px;
1030
+ padding: 14px 2px;
1031
+ font-size: min(0.8rem, 3.5vw);
1032
+ min-height: 44px;
1005
1033
  }
1006
1034
 
1007
1035
  .tb-btn.tb-arrow {
1008
- font-size: 1.2rem;
1036
+ font-size: min(1.1rem, 4.5vw);
1009
1037
  }
1010
1038
 
1011
1039
  #toolbar {
1012
1040
  display: block;
1013
1041
  }
1042
+
1043
+ #terminal-scrollbar {
1044
+ display: block;
1045
+ }
1014
1046
  }
1015
1047
 
1016
1048
  /* ===== Scrollbar ===== */