@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 +1 -1
- package/dist/server.js +219 -50
- package/package.json +1 -1
- package/public/app.js +301 -78
- package/public/index.html +49 -0
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.
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
50
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
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: '#
|
|
163
|
+
background: '#0d1117',
|
|
144
164
|
foreground: '#f0f6fc',
|
|
145
|
-
cursor: '#
|
|
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.
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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((
|
|
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 = '×';
|
|
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', (
|
|
344
|
+
btn.addEventListener('click', () => {
|
|
195
345
|
const key = btn.dataset.key;
|
|
196
346
|
switch (key) {
|
|
197
|
-
case 'escape': this.terminal.focus(); this.
|
|
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.
|
|
201
|
-
case 'up': this.
|
|
202
|
-
case 'down': this.
|
|
203
|
-
case 'slash': this.
|
|
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
|
-
|
|
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
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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>
|