@hmduc16031996/claude-mb-bridge 2.3.6 → 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.6')
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.6",
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,27 +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'),
130
140
  toggleHeaderBtn: document.getElementById('toggle-header-btn'),
131
141
  toggleHeaderBtnDesktop: document.getElementById('toggle-header-btn-desktop'),
132
142
  expandHeaderBtn: document.getElementById('expand-header-btn'),
133
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')
134
154
  };
135
155
  }
136
156
 
@@ -138,49 +158,41 @@ class ClaudeBridge {
138
158
  this.terminal = new Terminal({
139
159
  cursorBlink: true,
140
160
  fontSize: 14,
141
- fontFamily: '"JetBrains Mono", monospace',
161
+ fontFamily: '"JetBrains Mono", Menlo, Monaco, monospace',
142
162
  theme: {
143
- background: '#262624',
163
+ background: '#0d1117',
144
164
  foreground: '#f0f6fc',
145
- cursor: '#f0a500',
165
+ cursor: '#58a6ff'
146
166
  },
147
- allowTransparency: true,
148
- convertEol: true,
167
+ allowTransparency: true
149
168
  });
150
169
 
151
170
  this.fitAddon = new FitAddon.FitAddon();
152
171
  this.terminal.loadAddon(this.fitAddon);
153
- this.terminal.open(this.elements.terminalContainer);
172
+ this.terminal.loadAddon(new WebLinksAddon.WebLinksAddon());
154
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
+
155
180
  this.terminal.onData(data => {
156
- if (this.ws?.readyState === WebSocket.OPEN) {
157
- let finalData = data;
158
- if (this.shiftActive && data.length === 1) {
159
- const char = data.charCodeAt(0);
160
- if (char >= 97 && char <= 122) finalData = String.fromCharCode(char - 32);
161
- this.setShiftActive(false);
162
- }
163
- if (this.ctrlActive && data.length === 1) {
164
- const char = data.charCodeAt(0);
165
- if (char >= 65 && char <= 90) finalData = String.fromCharCode(char - 64);
166
- else if (char >= 97 && char <= 122) finalData = String.fromCharCode(char - 96);
167
- this.setCtrlActive(false);
168
- }
169
- this.ws.send(finalData);
181
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
182
+ // Text message = raw terminal input
183
+ this.ws.send(data);
170
184
  }
171
185
  });
172
186
 
173
187
  this.terminal.onResize(({ cols, rows }) => {
174
- if (this.ws?.readyState === WebSocket.OPEN) {
175
- this.ws.send(JSON.stringify({ type: 'resize', cols, rows }));
176
- }
188
+ this.sendControl({ type: 'resize', cols, rows });
177
189
  });
178
190
 
179
191
  window.addEventListener('resize', () => {
180
192
  this.fitAddon.fit();
181
193
  });
182
194
 
183
- this.terminal.onScroll((e) => {
195
+ this.terminal.onScroll(() => {
184
196
  const buffer = this.terminal.buffer.active;
185
197
  const isAtBottom = buffer.viewportY >= buffer.baseY;
186
198
  this.elements.scrollBottomBtn.classList.toggle('hidden', isAtBottom);
@@ -189,50 +201,263 @@ class ClaudeBridge {
189
201
  new TouchScrollManager(this.terminal, this.elements.terminalContainer);
190
202
  }
191
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
+
192
341
  bindEvents() {
342
+ // Mobile Keys
193
343
  document.querySelectorAll('.mobile-key').forEach(btn => {
194
- btn.addEventListener('click', (e) => {
344
+ btn.addEventListener('click', () => {
195
345
  const key = btn.dataset.key;
196
346
  switch (key) {
197
- case 'escape': this.terminal.focus(); this.ws.send('\x1b'); break;
347
+ case 'escape': this.terminal.focus(); this.terminal.onData('\x1b'); break; // Simulated ESC
198
348
  case 'ctrl': this.setCtrlActive(!this.ctrlActive); break;
199
349
  case 'shift': this.setShiftActive(!this.shiftActive); break;
200
- case 'tab': this.ws.send('\t'); break;
201
- case 'up': this.ws.send('\x1b[A'); break;
202
- case 'down': this.ws.send('\x1b[B'); break;
203
- 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;
204
354
  }
205
355
  });
206
356
  });
207
-
357
+
208
358
  // Header Toggle
209
359
  const handleToggleHeader = () => {
210
360
  const isCollapsed = this.elements.header.classList.toggle('collapsed');
211
361
  this.elements.expandHeaderBtn.classList.toggle('hidden', !isCollapsed);
212
- this.elements.toggleHeaderBtn.setAttribute('aria-expanded', !isCollapsed);
213
- this.elements.toggleHeaderBtnDesktop.setAttribute('aria-expanded', !isCollapsed);
214
362
  setTimeout(() => this.fitAddon.fit(), 200);
215
363
  };
364
+ [this.elements.toggleHeaderBtn, this.elements.toggleHeaderBtnDesktop, this.elements.expandHeaderBtn].forEach(btn => {
365
+ btn?.addEventListener('click', handleToggleHeader);
366
+ });
216
367
 
217
- this.elements.toggleHeaderBtn.addEventListener('click', handleToggleHeader);
218
- this.elements.toggleHeaderBtnDesktop.addEventListener('click', handleToggleHeader);
219
- this.elements.expandHeaderBtn.addEventListener('click', handleToggleHeader);
220
-
221
- // Scroll to Bottom
368
+ // Scroll Bottom
222
369
  this.elements.scrollBottomBtn.addEventListener('click', () => {
223
370
  this.terminal.scrollToBottom();
224
- this.elements.scrollBottomBtn.classList.add('hidden');
225
371
  });
226
372
 
227
- // Stubs for other buttons
228
- ['new-session-btn', 'attach-btn', 'preview-btn', 'settings-btn'].forEach(id => {
229
- const btn = document.getElementById(id);
230
- if (btn) {
231
- btn.addEventListener('click', () => {
232
- console.log(`Button ${id} clicked (not implemented in bridge)`);
233
- });
234
- }
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);
235
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');
236
461
  }
237
462
 
238
463
  setCtrlActive(active) {
@@ -244,34 +469,32 @@ class ClaudeBridge {
244
469
  this.shiftActive = active;
245
470
  document.querySelector('[data-key="shift"]').classList.toggle('active', active);
246
471
  }
472
+ }
247
473
 
248
- connect() {
249
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
250
- this.ws = new WebSocket(`${protocol}//${window.location.host}`);
251
-
252
- this.ws.onopen = () => {
253
- console.log('✅ Connected to terminal');
254
- this.elements.mainScreen.classList.add('active');
255
- this.elements.mobileKeys.classList.remove('hidden');
256
- setTimeout(() => this.fitAddon.fit(), 100);
257
- this.terminal.focus();
258
- };
259
-
260
- this.ws.onmessage = (e) => this.terminal.write(e.data);
261
-
262
- this.ws.onclose = () => {
263
- console.log('❌ Connection closed');
264
- // Show offline state in terminal
265
- this.terminal.write('\r\n\x1b[31m❌ Connection closed\x1b[0m\r\n');
266
- };
267
-
268
- this.ws.onerror = () => {
269
- console.error('⚠️ Connection error');
270
- };
271
- }
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
+ }
272
496
  }
273
497
 
274
- // Initialize on load
275
- window.addEventListener('DOMContentLoaded', () => {
498
+ document.addEventListener('DOMContentLoaded', () => {
276
499
  new ClaudeBridge();
277
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>