@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 +1 -1
- package/dist/server.js +219 -50
- package/package.json +1 -1
- package/public/app.js +326 -61
- 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,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: '#
|
|
163
|
+
background: '#0d1117',
|
|
140
164
|
foreground: '#f0f6fc',
|
|
141
|
-
cursor: '#
|
|
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.
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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 = '×';
|
|
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', (
|
|
344
|
+
btn.addEventListener('click', () => {
|
|
182
345
|
const key = btn.dataset.key;
|
|
183
346
|
switch (key) {
|
|
184
|
-
case 'escape': this.terminal.focus(); this.
|
|
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.
|
|
188
|
-
case 'up': this.
|
|
189
|
-
case 'down': this.
|
|
190
|
-
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;
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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>
|