@hmduc16031996/claude-mb-bridge 2.3.5 → 2.4.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/index.js CHANGED
@@ -6,7 +6,7 @@ const program = new Command();
6
6
  program
7
7
  .name('claude-mobile-bridge')
8
8
  .description('Bridge Claude Code CLI to mobile via WebView')
9
- .version('2.3.5')
9
+ .version('2.4.0')
10
10
  .option('--token <token>', 'Pairing token from mobile app')
11
11
  .option('--server <url>', 'Backend server URL', 'http://127.0.0.1:3110')
12
12
  .option('--path <path>', 'Working directory', process.cwd())
package/dist/server.js CHANGED
@@ -1,81 +1,250 @@
1
1
  import express from 'express';
2
2
  import { createServer } from 'http';
3
- import { WebSocketServer } from 'ws';
3
+ import { WebSocketServer, WebSocket } from 'ws';
4
4
  import path from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { createRequire } from 'module';
7
+ import * as fs from 'fs';
8
+ import * as os from 'os';
9
+ import { randomUUID } from 'crypto';
7
10
  const require = createRequire(import.meta.url);
8
11
  const pty = require('node-pty');
9
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ class Session {
14
+ term;
15
+ id;
16
+ cwd;
17
+ createdAt;
18
+ constructor(id, cwd, shell) {
19
+ this.id = id;
20
+ this.cwd = cwd;
21
+ this.createdAt = new Date().toISOString();
22
+ this.term = pty.spawn(shell, [], {
23
+ name: 'xterm-256color',
24
+ cols: 120,
25
+ rows: 40,
26
+ cwd: cwd,
27
+ env: {
28
+ ...process.env,
29
+ TERM: 'xterm-256color',
30
+ FORCE_COLOR: '1',
31
+ }
32
+ });
33
+ this.term.write('claude\r');
34
+ }
35
+ getInfo() {
36
+ return {
37
+ id: this.id,
38
+ cwd: this.cwd,
39
+ createdAt: this.createdAt,
40
+ status: 'running'
41
+ };
42
+ }
43
+ kill() {
44
+ try {
45
+ this.term.kill();
46
+ }
47
+ catch (e) {
48
+ console.warn(`Failed to kill pty ${this.id}:`, e);
49
+ }
50
+ }
51
+ }
52
+ class SessionManager {
53
+ sessions = new Map();
54
+ createSession(cwd) {
55
+ const id = randomUUID().slice(0, 8);
56
+ let shell = '/bin/bash';
57
+ if (process.platform === 'win32') {
58
+ shell = 'powershell.exe';
59
+ }
60
+ else if (process.platform === 'darwin') {
61
+ shell = fs.existsSync('/bin/zsh') ? '/bin/zsh' : '/bin/bash';
62
+ }
63
+ const session = new Session(id, cwd, shell);
64
+ this.sessions.set(id, session);
65
+ return session;
66
+ }
67
+ getSession(id) {
68
+ return this.sessions.get(id);
69
+ }
70
+ removeSession(id) {
71
+ const session = this.sessions.get(id);
72
+ if (session) {
73
+ session.kill();
74
+ this.sessions.delete(id);
75
+ }
76
+ }
77
+ listSessions() {
78
+ return Array.from(this.sessions.values()).map(s => s.getInfo());
79
+ }
80
+ }
10
81
  export function startTerminalServer(port, workingDir, onDisconnect) {
11
82
  const app = express();
12
83
  const server = createServer(app);
13
84
  const wss = new WebSocketServer({ server });
85
+ const sessionManager = new SessionManager();
14
86
  const publicPath = path.join(__dirname, '../public');
15
87
  app.use(express.static(publicPath));
16
88
  app.get('/health', (req, res) => {
17
89
  res.json({ status: 'ok' });
18
90
  });
19
- wss.on('connection', (ws, req) => {
20
- console.log(`📡 New connection attempt from ${req.socket.remoteAddress}`);
21
- console.log('✅ Connection authorized');
22
- // Use absolute paths for shells to avoid posix_spawnp failure
23
- let shell = '/bin/bash';
24
- if (process.platform === 'win32') {
25
- shell = 'powershell.exe';
26
- }
27
- else if (process.platform === 'darwin') {
28
- // Prefer zsh on macOS as it's the modern default, fallback to bash
29
- shell = require('fs').existsSync('/bin/zsh') ? '/bin/zsh' : '/bin/bash';
30
- }
31
- console.log(`🚀 Spawning shell: ${shell} in ${workingDir}`);
32
- let term;
91
+ // Directory listing for autocomplete
92
+ app.get('/api/dirs', (req, res) => {
33
93
  try {
34
- // Ensure working directory exists
35
- if (!require('fs').existsSync(workingDir)) {
36
- console.error(`❌ Working directory does not exist: ${workingDir}`);
37
- ws.send('\r\n\x1b[31mError: Working directory does not exist\x1b[0m\r\n');
38
- ws.close();
39
- return;
94
+ let inputPath = req.query.path || '';
95
+ if (inputPath.startsWith('~/')) {
96
+ inputPath = inputPath.replace('~/', `${os.homedir()}/`);
97
+ }
98
+ else if (inputPath === '~') {
99
+ inputPath = os.homedir();
100
+ }
101
+ let dirToRead;
102
+ let prefix;
103
+ if (inputPath.endsWith('/')) {
104
+ dirToRead = inputPath;
105
+ prefix = '';
106
+ }
107
+ else {
108
+ dirToRead = path.dirname(inputPath) || '/';
109
+ prefix = path.basename(inputPath).toLowerCase();
110
+ }
111
+ if (!path.isAbsolute(dirToRead)) {
112
+ dirToRead = path.resolve(process.cwd(), dirToRead);
40
113
  }
41
- term = pty.spawn(shell, [], {
42
- name: 'xterm-256color',
43
- cols: 80,
44
- rows: 24,
45
- cwd: workingDir,
46
- env: process.env
114
+ const entries = fs.readdirSync(dirToRead, { withFileTypes: true });
115
+ const dirs = entries
116
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
117
+ .filter(e => !prefix || e.name.toLowerCase().startsWith(prefix))
118
+ .slice(0, 20)
119
+ .map(e => {
120
+ const fullPath = path.join(dirToRead, e.name);
121
+ const displayPath = fullPath.startsWith(os.homedir())
122
+ ? fullPath.replace(os.homedir(), '~')
123
+ : fullPath;
124
+ return { name: e.name, path: displayPath + '/' };
47
125
  });
126
+ res.json(dirs);
48
127
  }
49
- catch (err) {
50
- console.error(`❌ Failed to spawn terminal: ${err.message}`);
51
- ws.send(`\r\n\x1b[31mError: Failed to spawn terminal (${err.message})\x1b[0m\r\n`);
52
- ws.close();
53
- return;
128
+ catch {
129
+ res.json([]);
54
130
  }
55
- term.write('claude\r');
56
- term.onData((data) => {
57
- ws.send(data);
58
- });
59
- ws.on('message', (msg) => {
60
- const data = msg.toString();
61
- try {
62
- const json = JSON.parse(data);
63
- if (json.type === 'resize' && json.cols && json.rows) {
64
- term.resize(json.cols, json.rows);
131
+ });
132
+ wss.on('connection', (ws, req) => {
133
+ console.log(`📡 New connection from ${req.socket.remoteAddress}`);
134
+ let activeSessionId = null;
135
+ let outputHandler = null;
136
+ const sendControl = (message) => {
137
+ if (ws.readyState === WebSocket.OPEN) {
138
+ ws.send(Buffer.from(JSON.stringify(message)));
139
+ }
140
+ };
141
+ const attachToSession = (sessionId) => {
142
+ const session = sessionManager.getSession(sessionId);
143
+ if (!session) {
144
+ sendControl({ type: 'error', error: 'Session not found' });
145
+ return;
146
+ }
147
+ // Cleanup old handler
148
+ if (activeSessionId && outputHandler) {
149
+ const oldSession = sessionManager.getSession(activeSessionId);
150
+ if (oldSession) {
151
+ oldSession.term.off('data', outputHandler);
152
+ }
153
+ }
154
+ activeSessionId = sessionId;
155
+ outputHandler = (data) => {
156
+ if (ws.readyState === WebSocket.OPEN) {
157
+ ws.send(data); // Send as text
158
+ }
159
+ };
160
+ session.term.on('data', outputHandler);
161
+ sendControl({ type: 'session:attached', session: session.getInfo() });
162
+ };
163
+ // Auto-create initial session if none exist or just use workingDir
164
+ const initialSession = sessionManager.createSession(workingDir);
165
+ attachToSession(initialSession.id);
166
+ ws.on('message', (msg, isBinary) => {
167
+ if (isBinary) {
168
+ let message;
169
+ try {
170
+ message = JSON.parse(msg.toString());
171
+ }
172
+ catch {
173
+ sendControl({ type: 'error', error: 'Invalid control message' });
174
+ return;
65
175
  }
66
- else {
67
- term.write(data);
176
+ switch (message.type) {
177
+ case 'session:create': {
178
+ let cwd = message.cwd || workingDir;
179
+ if (cwd.startsWith('~/')) {
180
+ cwd = cwd.replace('~/', `${os.homedir()}/`);
181
+ }
182
+ if (!fs.existsSync(cwd)) {
183
+ sendControl({ type: 'error', error: 'Directory not found' });
184
+ break;
185
+ }
186
+ const session = sessionManager.createSession(cwd);
187
+ sendControl({ type: 'session:created', session: session.getInfo() });
188
+ attachToSession(session.id);
189
+ break;
190
+ }
191
+ case 'session:attach': {
192
+ if (message.sessionId) {
193
+ attachToSession(message.sessionId);
194
+ }
195
+ break;
196
+ }
197
+ case 'session:list': {
198
+ sendControl({ type: 'session:list', sessions: sessionManager.listSessions() });
199
+ break;
200
+ }
201
+ case 'resize': {
202
+ const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null;
203
+ if (session && message.cols && message.rows) {
204
+ session.term.resize(message.cols, message.rows);
205
+ }
206
+ break;
207
+ }
208
+ case 'image:upload': {
209
+ const { data, mimeType, filename } = message;
210
+ if (!data)
211
+ break;
212
+ const ext = mimeType?.split('/')[1] || 'png';
213
+ const tempPath = path.join(os.tmpdir(), `claude-bridge-${Date.now()}-${filename || 'image'}.${ext}`);
214
+ try {
215
+ const buffer = Buffer.from(data, 'base64');
216
+ fs.writeFileSync(tempPath, buffer);
217
+ sendControl({ type: 'image:uploaded', path: tempPath });
218
+ }
219
+ catch (err) {
220
+ sendControl({ type: 'error', error: 'Failed to save image' });
221
+ }
222
+ break;
223
+ }
224
+ case 'session:destroy': {
225
+ if (message.sessionId) {
226
+ sessionManager.removeSession(message.sessionId);
227
+ sendControl({ type: 'session:destroyed', sessionId: message.sessionId });
228
+ }
229
+ break;
230
+ }
68
231
  }
69
232
  }
70
- catch {
71
- term.write(data);
233
+ else {
234
+ // Raw text -> forward to active pty
235
+ const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null;
236
+ if (session) {
237
+ session.term.write(msg.toString());
238
+ }
72
239
  }
73
240
  });
74
- term.onExit(() => {
75
- ws.close();
76
- });
77
241
  ws.on('close', () => {
78
- term.kill();
242
+ if (activeSessionId && outputHandler) {
243
+ const session = sessionManager.getSession(activeSessionId);
244
+ if (session) {
245
+ session.term.off('data', outputHandler);
246
+ }
247
+ }
79
248
  if (onDisconnect)
80
249
  onDisconnect();
81
250
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmduc16031996/claude-mb-bridge",
3
- "version": "2.3.5",
3
+ "version": "2.4.0",
4
4
  "description": "Bridge between Claude Code CLI and your mobile app via WebView",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/public/app.js CHANGED
@@ -110,23 +110,47 @@ class ClaudeBridge {
110
110
  this.ws = null;
111
111
  this.terminal = null;
112
112
  this.fitAddon = null;
113
+ this.serializeAddon = null;
114
+ this.reconnectTimer = null;
113
115
  this.ctrlActive = false;
114
116
  this.shiftActive = false;
117
+
118
+ this.sessions = [];
119
+ this.currentSessionId = null;
120
+ this.sessionCache = new Map(); // ID -> serialized state
115
121
 
116
122
  this.initElements();
117
123
  this.initTerminal();
118
124
  this.bindEvents();
119
-
120
- // Connect immediately
121
125
  this.connect();
122
126
  }
123
127
 
124
128
  initElements() {
125
129
  this.elements = {
126
- mainScreen: document.getElementById('main-screen'),
127
- terminalContainer: document.getElementById('terminal-container'),
128
- mobileKeys: document.getElementById('mobile-keys'),
129
130
  header: document.getElementById('header'),
131
+ terminalContainer: document.getElementById('terminal-container'),
132
+ sessionSelect: document.getElementById('session-select'),
133
+ sessionTabs: document.getElementById('session-tabs'),
134
+ newSessionBtn: document.getElementById('new-session-btn'),
135
+ closeSessionBtn: document.getElementById('close-session-btn'),
136
+ attachBtn: document.getElementById('attach-btn'),
137
+ imageInput: document.getElementById('image-input'),
138
+ previewBtn: document.getElementById('preview-btn'),
139
+ settingsBtn: document.getElementById('settings-btn'),
140
+ toggleHeaderBtn: document.getElementById('toggle-header-btn'),
141
+ toggleHeaderBtnDesktop: document.getElementById('toggle-header-btn-desktop'),
142
+ expandHeaderBtn: document.getElementById('expand-header-btn'),
143
+ scrollBottomBtn: document.getElementById('scroll-bottom-btn'),
144
+ reconnectIndicator: document.getElementById('reconnect-indicator'),
145
+
146
+ // Modals
147
+ newSessionModal: document.getElementById('new-session-modal'),
148
+ cwdInput: document.getElementById('cwd-input'),
149
+ cwdSuggestions: document.getElementById('cwd-suggestions'),
150
+ createSessionBtn: document.getElementById('create-session-btn'),
151
+ cancelSessionBtn: document.getElementById('cancel-session-btn'),
152
+ settingsModal: document.getElementById('settings-modal'),
153
+ closeSettingsBtn: document.getElementById('close-settings-btn')
130
154
  };
131
155
  }
132
156
 
@@ -134,63 +158,306 @@ class ClaudeBridge {
134
158
  this.terminal = new Terminal({
135
159
  cursorBlink: true,
136
160
  fontSize: 14,
137
- fontFamily: '"JetBrains Mono", monospace',
161
+ fontFamily: '"JetBrains Mono", Menlo, Monaco, monospace',
138
162
  theme: {
139
- background: '#262624',
163
+ background: '#0d1117',
140
164
  foreground: '#f0f6fc',
141
- cursor: '#f0a500',
165
+ cursor: '#58a6ff'
142
166
  },
143
- allowTransparency: true,
144
- convertEol: true,
167
+ allowTransparency: true
145
168
  });
146
169
 
147
170
  this.fitAddon = new FitAddon.FitAddon();
148
171
  this.terminal.loadAddon(this.fitAddon);
149
- this.terminal.open(this.elements.terminalContainer);
172
+ this.terminal.loadAddon(new WebLinksAddon.WebLinksAddon());
150
173
 
174
+ this.serializeAddon = new SerializeAddon.SerializeAddon();
175
+ this.terminal.loadAddon(this.serializeAddon);
176
+
177
+ this.terminal.open(this.elements.terminalContainer);
178
+ this.fitAddon.fit();
179
+
151
180
  this.terminal.onData(data => {
152
- if (this.ws?.readyState === WebSocket.OPEN) {
153
- let finalData = data;
154
- if (this.shiftActive && data.length === 1) {
155
- const char = data.charCodeAt(0);
156
- if (char >= 97 && char <= 122) finalData = String.fromCharCode(char - 32);
157
- this.setShiftActive(false);
158
- }
159
- if (this.ctrlActive && data.length === 1) {
160
- const char = data.charCodeAt(0);
161
- if (char >= 65 && char <= 90) finalData = String.fromCharCode(char - 64);
162
- else if (char >= 97 && char <= 122) finalData = String.fromCharCode(char - 96);
163
- this.setCtrlActive(false);
164
- }
165
- this.ws.send(finalData);
181
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
182
+ // Text message = raw terminal input
183
+ this.ws.send(data);
166
184
  }
167
185
  });
168
186
 
169
187
  this.terminal.onResize(({ cols, rows }) => {
170
- if (this.ws?.readyState === WebSocket.OPEN) {
171
- this.ws.send(JSON.stringify({ type: 'resize', cols, rows }));
172
- }
188
+ this.sendControl({ type: 'resize', cols, rows });
189
+ });
190
+
191
+ window.addEventListener('resize', () => {
192
+ this.fitAddon.fit();
193
+ });
194
+
195
+ this.terminal.onScroll(() => {
196
+ const buffer = this.terminal.buffer.active;
197
+ const isAtBottom = buffer.viewportY >= buffer.baseY;
198
+ this.elements.scrollBottomBtn.classList.toggle('hidden', isAtBottom);
173
199
  });
174
200
 
175
- window.addEventListener('resize', () => this.fitAddon.fit());
176
201
  new TouchScrollManager(this.terminal, this.elements.terminalContainer);
177
202
  }
178
203
 
204
+ connect() {
205
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
206
+ const host = window.location.host;
207
+ const urlParams = new URLSearchParams(window.location.search);
208
+ const token = urlParams.get('token');
209
+
210
+ this.ws = new WebSocket(`${protocol}//${host}${token ? '?token=' + token : ''}`);
211
+
212
+ this.ws.onopen = () => {
213
+ console.log('✅ Connected to bridge');
214
+ this.elements.reconnectIndicator.classList.add('hidden');
215
+ if (this.reconnectTimer) {
216
+ clearInterval(this.reconnectTimer);
217
+ this.reconnectTimer = null;
218
+ }
219
+ this.refreshSessionList();
220
+ };
221
+
222
+ this.ws.onmessage = async (event) => {
223
+ if (event.data instanceof Blob || event.data instanceof ArrayBuffer) {
224
+ // Binary message = JSON control
225
+ const text = await (event.data instanceof Blob ? event.data.text() : new TextDecoder().decode(event.data));
226
+ try {
227
+ const message = JSON.parse(text);
228
+ this.handleControlMessage(message);
229
+ } catch (e) {
230
+ console.error('Failed to parse binary message:', e);
231
+ }
232
+ } else {
233
+ // Text message = raw terminal output
234
+ this.terminal.write(event.data);
235
+ }
236
+ };
237
+
238
+ this.ws.onclose = () => {
239
+ console.log('❌ Connection closed');
240
+ this.elements.reconnectIndicator.classList.remove('hidden');
241
+ if (!this.reconnectTimer) {
242
+ this.reconnectTimer = setInterval(() => this.connect(), 3000);
243
+ }
244
+ };
245
+ }
246
+
247
+ handleControlMessage(message) {
248
+ switch (message.type) {
249
+ case 'session:attached':
250
+ this.currentSessionId = message.session.id;
251
+ this.updateUI();
252
+ break;
253
+ case 'session:created':
254
+ this.refreshSessionList();
255
+ this.hideNewSessionModal();
256
+ break;
257
+ case 'session:list':
258
+ this.sessions = message.sessions;
259
+ this.renderSessions();
260
+ break;
261
+ case 'image:uploaded':
262
+ // Paste the path into terminal (usually for /attach command)
263
+ this.terminal.focus();
264
+ this.terminal.paste(message.path);
265
+ break;
266
+ case 'session:destroyed':
267
+ if (message.sessionId === this.currentSessionId) {
268
+ this.currentSessionId = null;
269
+ this.terminal.clear();
270
+ }
271
+ this.refreshSessionList();
272
+ break;
273
+ case 'error':
274
+ this.terminal.writeln(`\r\n\x1b[31mError: ${message.error}\x1b[0m`);
275
+ break;
276
+ }
277
+ }
278
+
279
+ sendControl(message) {
280
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
281
+ this.ws.send(JSON.stringify(message));
282
+ }
283
+ }
284
+
285
+ refreshSessionList() {
286
+ this.sendControl({ type: 'session:list' });
287
+ }
288
+
289
+ renderSessions() {
290
+ // Render Mobile Dropdown
291
+ this.elements.sessionSelect.innerHTML = '<option value="">Select session...</option>';
292
+ this.sessions.forEach(s => {
293
+ const option = document.createElement('option');
294
+ option.value = s.id;
295
+ option.textContent = s.cwd.split('/').pop() || '/';
296
+ if (s.id === this.currentSessionId) option.selected = true;
297
+ this.elements.sessionSelect.appendChild(option);
298
+ });
299
+
300
+ // Render Desktop Tabs
301
+ this.elements.sessionTabs.innerHTML = '';
302
+ this.sessions.forEach(s => {
303
+ const tab = document.createElement('button');
304
+ tab.className = 'session-tab';
305
+ if (s.id === this.currentSessionId) tab.setAttribute('aria-selected', 'true');
306
+
307
+ const name = document.createElement('span');
308
+ name.className = 'session-tab-name';
309
+ name.textContent = s.cwd.split('/').pop() || '/';
310
+ tab.appendChild(name);
311
+
312
+ const close = document.createElement('span');
313
+ close.className = 'session-tab-close';
314
+ close.innerHTML = '&times;';
315
+ close.onclick = (e) => {
316
+ e.stopPropagation();
317
+ this.destroySession(s.id);
318
+ };
319
+ tab.appendChild(close);
320
+
321
+ tab.onclick = () => this.attachSession(s.id);
322
+ this.elements.sessionTabs.appendChild(tab);
323
+ });
324
+ }
325
+
326
+ attachSession(id) {
327
+ if (id === this.currentSessionId) return;
328
+ this.sendControl({ type: 'session:attach', sessionId: id });
329
+ }
330
+
331
+ destroySession(id) {
332
+ if (confirm('Are you sure you want to close this session?')) {
333
+ this.sendControl({ type: 'session:destroy', sessionId: id });
334
+ }
335
+ }
336
+
337
+ updateUI() {
338
+ this.renderSessions();
339
+ }
340
+
179
341
  bindEvents() {
342
+ // Mobile Keys
180
343
  document.querySelectorAll('.mobile-key').forEach(btn => {
181
- btn.addEventListener('click', (e) => {
344
+ btn.addEventListener('click', () => {
182
345
  const key = btn.dataset.key;
183
346
  switch (key) {
184
- case 'escape': this.terminal.focus(); this.ws.send('\x1b'); break;
347
+ case 'escape': this.terminal.focus(); this.terminal.onData('\x1b'); break; // Simulated ESC
185
348
  case 'ctrl': this.setCtrlActive(!this.ctrlActive); break;
186
349
  case 'shift': this.setShiftActive(!this.shiftActive); break;
187
- case 'tab': this.ws.send('\t'); break;
188
- case 'up': this.ws.send('\x1b[A'); break;
189
- case 'down': this.ws.send('\x1b[B'); break;
190
- case 'slash': this.ws.send('/'); break;
350
+ case 'tab': this.terminal.onData('\t'); break;
351
+ case 'up': this.terminal.onData('\x1b[A'); break;
352
+ case 'down': this.terminal.onData('\x1b[B'); break;
353
+ case 'slash': this.terminal.onData('/'); break;
191
354
  }
192
355
  });
193
356
  });
357
+
358
+ // Header Toggle
359
+ const handleToggleHeader = () => {
360
+ const isCollapsed = this.elements.header.classList.toggle('collapsed');
361
+ this.elements.expandHeaderBtn.classList.toggle('hidden', !isCollapsed);
362
+ setTimeout(() => this.fitAddon.fit(), 200);
363
+ };
364
+ [this.elements.toggleHeaderBtn, this.elements.toggleHeaderBtnDesktop, this.elements.expandHeaderBtn].forEach(btn => {
365
+ btn?.addEventListener('click', handleToggleHeader);
366
+ });
367
+
368
+ // Scroll Bottom
369
+ this.elements.scrollBottomBtn.addEventListener('click', () => {
370
+ this.terminal.scrollToBottom();
371
+ });
372
+
373
+ // Attach File
374
+ this.elements.attachBtn.addEventListener('click', () => this.elements.imageInput.click());
375
+ this.elements.imageInput.addEventListener('change', (e) => {
376
+ const file = e.target.files[0];
377
+ if (file) this.handleImageAttachment(file);
378
+ });
379
+
380
+ // New Session
381
+ this.elements.newSessionBtn.addEventListener('click', () => this.showNewSessionModal());
382
+ this.elements.cancelSessionBtn.addEventListener('click', () => this.hideNewSessionModal());
383
+ this.elements.createSessionBtn.addEventListener('click', () => {
384
+ const cwd = this.elements.cwdInput.value;
385
+ this.sendControl({ type: 'session:create', cwd });
386
+ });
387
+
388
+ // Settings
389
+ this.elements.settingsBtn.addEventListener('click', () => this.elements.settingsModal.classList.remove('hidden'));
390
+ this.elements.closeSettingsBtn.addEventListener('click', () => this.elements.settingsModal.classList.add('hidden'));
391
+
392
+ // Directory Autocomplete
393
+ this.elements.cwdInput.addEventListener('input', () => this.onCwdInput());
394
+
395
+ // Paste handling for images
396
+ document.addEventListener('paste', (e) => this.handlePaste(e));
397
+ }
398
+
399
+ handleImageAttachment(file) {
400
+ if (!file || !file.type.startsWith('image/')) return;
401
+ const reader = new FileReader();
402
+ reader.onload = () => {
403
+ const base64 = reader.result.split(',')[1];
404
+ this.sendControl({
405
+ type: 'image:upload',
406
+ data: base64,
407
+ filename: file.name,
408
+ mimeType: file.type
409
+ });
410
+ };
411
+ reader.readAsDataURL(file);
412
+ }
413
+
414
+ handlePaste(e) {
415
+ const items = e.clipboardData?.items;
416
+ if (!items) return;
417
+ for (let i = 0; i < items.length; i++) {
418
+ if (items[i].type.indexOf('image') !== -1) {
419
+ const file = items[i].getAsFile();
420
+ this.handleImageAttachment(file);
421
+ break;
422
+ }
423
+ }
424
+ }
425
+
426
+ onCwdInput() {
427
+ const path = this.elements.cwdInput.value;
428
+ if (path.length < 1) {
429
+ this.elements.cwdSuggestions.classList.add('hidden');
430
+ return;
431
+ }
432
+ fetch(`/api/dirs?path=${encodeURIComponent(path)}`)
433
+ .then(res => res.json())
434
+ .then(dirs => {
435
+ this.elements.cwdSuggestions.innerHTML = '';
436
+ if (dirs.length === 0) {
437
+ this.elements.cwdSuggestions.classList.add('hidden');
438
+ return;
439
+ }
440
+ dirs.forEach(dir => {
441
+ const li = document.createElement('li');
442
+ li.textContent = dir.path;
443
+ li.onclick = () => {
444
+ this.elements.cwdInput.value = dir.path;
445
+ this.elements.cwdSuggestions.classList.add('hidden');
446
+ };
447
+ this.elements.cwdSuggestions.appendChild(li);
448
+ });
449
+ this.elements.cwdSuggestions.classList.remove('hidden');
450
+ });
451
+ }
452
+
453
+ showNewSessionModal() {
454
+ this.elements.cwdInput.value = '';
455
+ this.elements.newSessionModal.classList.remove('hidden');
456
+ this.elements.cwdInput.focus();
457
+ }
458
+
459
+ hideNewSessionModal() {
460
+ this.elements.newSessionModal.classList.add('hidden');
194
461
  }
195
462
 
196
463
  setCtrlActive(active) {
@@ -202,34 +469,32 @@ class ClaudeBridge {
202
469
  this.shiftActive = active;
203
470
  document.querySelector('[data-key="shift"]').classList.toggle('active', active);
204
471
  }
472
+ }
205
473
 
206
- connect() {
207
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
208
- this.ws = new WebSocket(`${protocol}//${window.location.host}`);
209
-
210
- this.ws.onopen = () => {
211
- console.log('✅ Connected to terminal');
212
- this.elements.mainScreen.classList.add('active');
213
- this.elements.mobileKeys.classList.remove('hidden');
214
- setTimeout(() => this.fitAddon.fit(), 100);
215
- this.terminal.focus();
216
- };
217
-
218
- this.ws.onmessage = (e) => this.terminal.write(e.data);
219
-
220
- this.ws.onclose = () => {
221
- console.log('❌ Connection closed');
222
- // Show offline state in terminal
223
- this.terminal.write('\r\n\x1b[31m❌ Connection closed\x1b[0m\r\n');
224
- };
225
-
226
- this.ws.onerror = () => {
227
- console.error('⚠️ Connection error');
228
- };
229
- }
474
+ // Minimal TouchScrollManager
475
+ class TouchScrollManager {
476
+ constructor(terminal, container) {
477
+ this.terminal = terminal;
478
+ this.container = container;
479
+ this.bindEvents();
480
+ }
481
+ bindEvents() {
482
+ let lastY = 0;
483
+ this.container.addEventListener('touchstart', (e) => {
484
+ if (e.touches.length === 1) lastY = e.touches[0].clientY;
485
+ }, { passive: true });
486
+ this.container.addEventListener('touchmove', (e) => {
487
+ if (e.touches.length === 1) {
488
+ const delta = lastY - e.touches[0].clientY;
489
+ if (Math.abs(delta) > 5) {
490
+ this.terminal.scrollLines(Math.sign(delta));
491
+ lastY = e.touches[0].clientY;
492
+ }
493
+ }
494
+ }, { passive: true });
495
+ }
230
496
  }
231
497
 
232
- // Initialize on load
233
- window.addEventListener('DOMContentLoaded', () => {
498
+ document.addEventListener('DOMContentLoaded', () => {
234
499
  new ClaudeBridge();
235
500
  });
package/public/index.html CHANGED
@@ -120,6 +120,55 @@
120
120
  </svg>
121
121
  </button>
122
122
 
123
+ </div>
124
+
125
+ <!-- New Session Modal -->
126
+ <div id="new-session-modal" class="modal hidden" role="dialog" aria-labelledby="modal-title" aria-modal="true">
127
+ <div class="modal-content">
128
+ <h2 id="modal-title">New Session</h2>
129
+ <p class="modal-description">Create a new terminal session with optional working directory.</p>
130
+ <div class="autocomplete-wrapper">
131
+ <input
132
+ type="text"
133
+ id="cwd-input"
134
+ placeholder="Working directory (leave empty for current)"
135
+ autocomplete="off"
136
+ autocapitalize="off"
137
+ autocorrect="off"
138
+ spellcheck="false"
139
+ aria-label="Working directory path"
140
+ >
141
+ <ul id="cwd-suggestions" class="suggestions hidden" role="listbox"></ul>
142
+ </div>
143
+ <div class="modal-actions">
144
+ <button id="cancel-session-btn" class="btn-secondary">Cancel</button>
145
+ <button id="create-session-btn" class="btn-primary">Create</button>
146
+ </div>
147
+ </div>
148
+ </div>
149
+
150
+ <!-- Settings Modal -->
151
+ <div id="settings-modal" class="modal hidden" role="dialog" aria-labelledby="settings-title" aria-modal="true">
152
+ <div class="modal-content settings-content">
153
+ <div class="modal-header">
154
+ <h2 id="settings-title">Settings</h2>
155
+ <button id="close-settings-btn" class="btn-icon" aria-label="Close settings">
156
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
157
+ <line x1="18" y1="6" x2="6" y2="18"/>
158
+ <line x1="6" y1="6" x2="18" y2="18"/>
159
+ </svg>
160
+ </button>
161
+ </div>
162
+ <div class="setting-row">
163
+ <div class="setting-info">
164
+ <label>Bridge Status</label>
165
+ <p class="setting-description">Version 2.3.6 (Release)</p>
166
+ </div>
167
+ <span class="status-tag">Active</span>
168
+ </div>
169
+ </div>
170
+ </div>
171
+
123
172
  </div>
124
173
  <script src="/app.js"></script>
125
174
  </body>