@hmduc16031996/claude-mb-bridge 2.0.0 → 2.1.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
@@ -2,14 +2,13 @@
2
2
  import { Command } from 'commander';
3
3
  import { CloudflareTunnel } from './tunnel.js';
4
4
  import { startTerminalServer } from './server.js';
5
- import { randomUUID } from 'crypto';
6
5
  const program = new Command();
7
6
  program
8
7
  .name('claude-mobile-bridge')
9
8
  .description('Bridge Claude Code CLI to mobile via WebView')
10
9
  .version('2.0.0')
11
10
  .option('--token <token>', 'Pairing token from mobile app')
12
- .option('--server <url>', 'Backend server URL', 'http://localhost:3000')
11
+ .option('--server <url>', 'Backend server URL', 'http://127.0.0.1:3000')
13
12
  .option('--path <path>', 'Working directory', process.cwd())
14
13
  .option('--port <port>', 'Local port for terminal server', '38473')
15
14
  .action(async (options) => {
@@ -18,13 +17,25 @@ program
18
17
  console.error('Error: --token is required');
19
18
  process.exit(1);
20
19
  }
20
+ // Define cleanup function
21
+ const cleanup = async () => {
22
+ console.log('\nšŸ‘‹ Cleaning up session and shutting down...');
23
+ try {
24
+ // Optional: Notify server to end session
25
+ await fetch(`${server}/api/sessions/${token}/expire`, { method: 'POST' }).catch(() => { });
26
+ }
27
+ finally {
28
+ tunnelResult.cleanup();
29
+ terminalServer.close();
30
+ process.exit(0);
31
+ }
32
+ };
21
33
  // 1. Start local terminal server
22
- const terminalAuthToken = randomUUID().replace(/-/g, '');
23
34
  const localPort = parseInt(port, 10);
24
- const terminalServer = startTerminalServer(localPort, terminalAuthToken, path);
35
+ const { server: terminalServer, actualPort } = await startTerminalServer(localPort, path, cleanup);
25
36
  // 2. Start Cloudflare Tunnel
26
37
  const tunnel = new CloudflareTunnel();
27
- const tunnelResult = await tunnel.start(localPort);
38
+ const tunnelResult = await tunnel.start(actualPort);
28
39
  if (!tunnelResult.url) {
29
40
  console.error('āŒ Failed to start Cloudflare Tunnel.');
30
41
  process.exit(1);
@@ -37,26 +48,22 @@ program
37
48
  'Content-Type': 'application/json'
38
49
  },
39
50
  body: JSON.stringify({
40
- public_url: tunnelResult.url,
41
- terminal_auth_token: terminalAuthToken
51
+ public_url: tunnelResult.url
42
52
  })
43
53
  });
44
54
  if (!res.ok) {
45
55
  throw new Error('Invalid token or session expired');
46
56
  }
47
- // ONLY show "session connected" as requested
48
57
  console.log('āœ… Session connected');
49
58
  }
50
59
  catch (err) {
51
60
  console.error(`āŒ Validation failed: ${err.message}`);
52
- tunnelResult.cleanup();
53
- process.exit(1);
61
+ console.error(' Ensure the central server is running at:', server);
62
+ cleanup();
54
63
  }
55
- // Handle graceful shutdown
56
- process.on('SIGINT', () => {
57
- tunnelResult.cleanup();
58
- terminalServer.close();
59
- process.exit(0);
60
- });
64
+ // Handle graceful shutdown signals
65
+ process.on('SIGINT', cleanup);
66
+ process.on('SIGTERM', cleanup);
67
+ process.on('SIGHUP', cleanup);
61
68
  });
62
69
  program.parse();
package/dist/server.d.ts CHANGED
@@ -1 +1,4 @@
1
- export declare function startTerminalServer(port: number, authToken: string, workingDir: string): import("http").Server<typeof import("http").IncomingMessage, typeof import("http").ServerResponse>;
1
+ export declare function startTerminalServer(port: number, workingDir: string, onDisconnect?: () => void): Promise<{
2
+ server: any;
3
+ actualPort: number;
4
+ }>;
package/dist/server.js CHANGED
@@ -7,19 +7,18 @@ import { createRequire } from 'module';
7
7
  const require = createRequire(import.meta.url);
8
8
  const pty = require('node-pty');
9
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
- export function startTerminalServer(port, authToken, workingDir) {
10
+ export function startTerminalServer(port, workingDir, onDisconnect) {
11
11
  const app = express();
12
12
  const server = createServer(app);
13
13
  const wss = new WebSocketServer({ server });
14
14
  const publicPath = path.join(__dirname, '../public');
15
15
  app.use(express.static(publicPath));
16
+ app.get('/health', (req, res) => {
17
+ res.json({ status: 'ok' });
18
+ });
16
19
  wss.on('connection', (ws, req) => {
17
- const url = new URL(req.url || '/', `http://${req.headers.host}`);
18
- const token = url.searchParams.get('token');
19
- if (token !== authToken) {
20
- ws.close(1008, 'Unauthorized');
21
- return;
22
- }
20
+ console.log(`šŸ“” New connection attempt from ${req.socket.remoteAddress}`);
21
+ console.log('āœ… Connection authorized');
23
22
  const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash';
24
23
  const term = pty.spawn(shell, [], {
25
24
  name: 'xterm-256color',
@@ -28,7 +27,6 @@ export function startTerminalServer(port, authToken, workingDir) {
28
27
  cwd: workingDir,
29
28
  env: process.env
30
29
  });
31
- // Automatically start claude if available
32
30
  term.write('claude\r');
33
31
  term.onData((data) => {
34
32
  ws.send(data);
@@ -53,10 +51,24 @@ export function startTerminalServer(port, authToken, workingDir) {
53
51
  });
54
52
  ws.on('close', () => {
55
53
  term.kill();
54
+ if (onDisconnect)
55
+ onDisconnect();
56
56
  });
57
57
  });
58
- server.listen(port, () => {
59
- // console.log(`Local terminal server running on port ${port}`);
58
+ return new Promise((resolve, reject) => {
59
+ server.on('error', (err) => {
60
+ if (err.code === 'EADDRINUSE' && port !== 0) {
61
+ console.warn(`āš ļø Port ${port} is busy, trying a random port...`);
62
+ resolve(startTerminalServer(0, workingDir, onDisconnect));
63
+ }
64
+ else {
65
+ reject(err);
66
+ }
67
+ });
68
+ server.listen(port, () => {
69
+ const address = server.address();
70
+ const actualPort = typeof address === 'string' ? 0 : address?.port || 0;
71
+ resolve({ server, actualPort });
72
+ });
60
73
  });
61
- return server;
62
74
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmduc16031996/claude-mb-bridge",
3
- "version": "2.0.0",
3
+ "version": "2.1.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 ADDED
@@ -0,0 +1,252 @@
1
+ // Claude Code Remote - Bridged Client
2
+ // This script adapts the premium UI from the reference repo to the simple bridge server.
3
+
4
+ class TouchScrollManager {
5
+ constructor(terminal, container) {
6
+ this.terminal = terminal;
7
+ this.container = container;
8
+ this.TIME_CONSTANT = 325;
9
+ this.VELOCITY_THRESHOLD = 10;
10
+ this.LINE_HEIGHT = 17;
11
+ this.TAP_THRESHOLD = 10;
12
+ this.TAP_DURATION = 300;
13
+ this.tracking = false;
14
+ this.animating = false;
15
+ this.velocity = 0;
16
+ this.lastY = 0;
17
+ this.lastTime = 0;
18
+ this.accumulatedScroll = 0;
19
+
20
+ const isTouchDevice = window.matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window;
21
+ if (isTouchDevice) this.bindEvents();
22
+ }
23
+
24
+ bindEvents() {
25
+ this.overlay = document.createElement('div');
26
+ this.overlay.className = 'touch-scroll-overlay';
27
+ this.container.style.position = 'relative';
28
+ this.container.appendChild(this.overlay);
29
+
30
+ this.overlay.addEventListener('touchstart', (e) => this.onTouchStart(e), { passive: false });
31
+ this.overlay.addEventListener('touchmove', (e) => this.onTouchMove(e), { passive: false });
32
+ this.overlay.addEventListener('touchend', (e) => this.onTouchEnd(e), { passive: true });
33
+ this.overlay.addEventListener('click', () => this.terminal.focus());
34
+ }
35
+
36
+ onTouchStart(e) {
37
+ this.animating = false;
38
+ if (e.touches.length !== 1) return;
39
+ this.tracking = true;
40
+ this.velocity = 0;
41
+ this.accumulatedScroll = 0;
42
+ this.totalMovement = 0;
43
+ this.startY = e.touches[0].clientY;
44
+ this.lastY = this.startY;
45
+ this.lastTime = Date.now();
46
+ this.startTime = this.lastTime;
47
+ }
48
+
49
+ onTouchMove(e) {
50
+ if (!this.tracking || e.touches.length !== 1) return;
51
+ const currentY = e.touches[0].clientY;
52
+ const currentTime = Date.now();
53
+ const deltaY = this.lastY - currentY;
54
+ const deltaTime = currentTime - this.lastTime;
55
+ this.totalMovement += Math.abs(deltaY);
56
+ if (this.totalMovement >= this.TAP_THRESHOLD) e.preventDefault();
57
+ if (deltaTime > 0) {
58
+ const instantVelocity = (deltaY / deltaTime) * 1000;
59
+ this.velocity = 0.8 * instantVelocity + 0.2 * this.velocity;
60
+ }
61
+ this.accumulatedScroll += deltaY;
62
+ const linesToScroll = Math.trunc(this.accumulatedScroll / this.LINE_HEIGHT);
63
+ if (linesToScroll !== 0) {
64
+ this.terminal.scrollLines(linesToScroll);
65
+ this.accumulatedScroll -= linesToScroll * this.LINE_HEIGHT;
66
+ }
67
+ this.lastY = currentY;
68
+ this.lastTime = currentTime;
69
+ }
70
+
71
+ onTouchEnd() {
72
+ if (!this.tracking) return;
73
+ this.tracking = false;
74
+ const duration = Date.now() - this.startTime;
75
+ if (this.totalMovement < this.TAP_THRESHOLD && duration < this.TAP_DURATION) {
76
+ this.terminal.focus();
77
+ return;
78
+ }
79
+ if (Math.abs(this.velocity) > this.VELOCITY_THRESHOLD) this.startMomentum();
80
+ }
81
+
82
+ startMomentum() {
83
+ this.animating = true;
84
+ const startTime = Date.now();
85
+ const startVelocity = this.velocity;
86
+ let scrolledLines = 0;
87
+ const animate = () => {
88
+ if (!this.animating) return;
89
+ const elapsed = Date.now() - startTime;
90
+ const currentVelocity = startVelocity * Math.exp(-elapsed / this.TIME_CONSTANT);
91
+ if (Math.abs(currentVelocity) < this.VELOCITY_THRESHOLD) {
92
+ this.animating = false;
93
+ return;
94
+ }
95
+ const totalDistance = startVelocity * this.TIME_CONSTANT * (1 - Math.exp(-elapsed / this.TIME_CONSTANT)) / 1000;
96
+ const totalLines = Math.trunc(totalDistance / this.LINE_HEIGHT);
97
+ const linesToScroll = totalLines - scrolledLines;
98
+ if (linesToScroll !== 0) {
99
+ this.terminal.scrollLines(linesToScroll);
100
+ scrolledLines = totalLines;
101
+ }
102
+ requestAnimationFrame(animate);
103
+ };
104
+ requestAnimationFrame(animate);
105
+ }
106
+ }
107
+
108
+ class ClaudeBridge {
109
+ constructor() {
110
+ this.ws = null;
111
+ this.terminal = null;
112
+ this.fitAddon = null;
113
+ this.ctrlActive = false;
114
+ this.shiftActive = false;
115
+
116
+ const urlParams = new URLSearchParams(window.location.search);
117
+ this.token = urlParams.get('token') || localStorage.getItem('bridgeToken') || '';
118
+
119
+ this.initElements();
120
+ this.initTerminal();
121
+ this.bindEvents();
122
+
123
+ if (this.token) {
124
+ this.elements.tokenInput.value = this.token;
125
+ this.connect();
126
+ }
127
+ }
128
+
129
+ initElements() {
130
+ this.elements = {
131
+ authScreen: document.getElementById('auth-screen'),
132
+ mainScreen: document.getElementById('main-screen'),
133
+ tokenInput: document.getElementById('token-input'),
134
+ connectBtn: document.getElementById('connect-btn'),
135
+ authError: document.getElementById('auth-error'),
136
+ terminalContainer: document.getElementById('terminal-container'),
137
+ mobileKeys: document.getElementById('mobile-keys'),
138
+ header: document.getElementById('header'),
139
+ };
140
+ }
141
+
142
+ initTerminal() {
143
+ this.terminal = new Terminal({
144
+ cursorBlink: true,
145
+ fontSize: 14,
146
+ fontFamily: '"JetBrains Mono", monospace',
147
+ theme: {
148
+ background: '#262624',
149
+ foreground: '#f0f6fc',
150
+ cursor: '#f0a500',
151
+ },
152
+ allowTransparency: true,
153
+ convertEol: true,
154
+ });
155
+
156
+ this.fitAddon = new FitAddon.FitAddon();
157
+ this.terminal.loadAddon(this.fitAddon);
158
+ this.terminal.open(this.elements.terminalContainer);
159
+
160
+ this.terminal.onData(data => {
161
+ if (this.ws?.readyState === WebSocket.OPEN) {
162
+ let finalData = data;
163
+ if (this.shiftActive && data.length === 1) {
164
+ const char = data.charCodeAt(0);
165
+ if (char >= 97 && char <= 122) finalData = String.fromCharCode(char - 32);
166
+ this.setShiftActive(false);
167
+ }
168
+ if (this.ctrlActive && data.length === 1) {
169
+ const char = data.charCodeAt(0);
170
+ if (char >= 65 && char <= 90) finalData = String.fromCharCode(char - 64);
171
+ else if (char >= 97 && char <= 122) finalData = String.fromCharCode(char - 96);
172
+ this.setCtrlActive(false);
173
+ }
174
+ this.ws.send(finalData);
175
+ }
176
+ });
177
+
178
+ this.terminal.onResize(({ cols, rows }) => {
179
+ if (this.ws?.readyState === WebSocket.OPEN) {
180
+ this.ws.send(JSON.stringify({ type: 'resize', cols, rows }));
181
+ }
182
+ });
183
+
184
+ window.addEventListener('resize', () => this.fitAddon.fit());
185
+ new TouchScrollManager(this.terminal, this.elements.terminalContainer);
186
+ }
187
+
188
+ bindEvents() {
189
+ this.elements.connectBtn.addEventListener('click', () => {
190
+ this.token = this.elements.tokenInput.value.trim();
191
+ this.connect();
192
+ });
193
+
194
+ document.querySelectorAll('.mobile-key').forEach(btn => {
195
+ btn.addEventListener('click', (e) => {
196
+ const key = btn.dataset.key;
197
+ switch (key) {
198
+ case 'escape': this.terminal.focus(); this.ws.send('\x1b'); break;
199
+ case 'ctrl': this.setCtrlActive(!this.ctrlActive); break;
200
+ case 'shift': this.setShiftActive(!this.shiftActive); break;
201
+ case 'tab': this.ws.send('\t'); break;
202
+ case 'up': this.ws.send('\x1b[A'); break;
203
+ case 'down': this.ws.send('\x1b[B'); break;
204
+ case 'slash': this.ws.send('/'); break;
205
+ }
206
+ });
207
+ });
208
+ }
209
+
210
+ setCtrlActive(active) {
211
+ this.ctrlActive = active;
212
+ document.querySelector('[data-key="ctrl"]').classList.toggle('active', active);
213
+ }
214
+
215
+ setShiftActive(active) {
216
+ this.shiftActive = active;
217
+ document.querySelector('[data-key="shift"]').classList.toggle('active', active);
218
+ }
219
+
220
+ connect() {
221
+ if (!this.token) return;
222
+ this.elements.connectBtn.disabled = true;
223
+ this.elements.authError.textContent = '';
224
+
225
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
226
+ this.ws = new WebSocket(`${protocol}//${window.location.host}`);
227
+
228
+ this.ws.onopen = () => {
229
+ localStorage.setItem('bridgeToken', this.token);
230
+ this.elements.authScreen.classList.remove('active');
231
+ this.elements.mainScreen.classList.add('active');
232
+ this.elements.mobileKeys.classList.remove('hidden');
233
+ setTimeout(() => this.fitAddon.fit(), 100);
234
+ this.terminal.focus();
235
+ };
236
+
237
+ this.ws.onmessage = (e) => this.terminal.write(e.data);
238
+
239
+ this.ws.onclose = () => {
240
+ this.elements.authScreen.classList.add('active');
241
+ this.elements.mainScreen.classList.remove('active');
242
+ this.elements.connectBtn.disabled = false;
243
+ };
244
+
245
+ this.ws.onerror = () => {
246
+ this.elements.authError.textContent = 'Connection failed';
247
+ this.elements.connectBtn.disabled = false;
248
+ };
249
+ }
250
+ }
251
+
252
+ window.addEventListener('load', () => new ClaudeBridge());
package/public/index.html CHANGED
@@ -1,77 +1,167 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
- <title>Claude Remote Terminal</title>
7
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
8
- <style>
9
- body, html {
10
- margin: 0;
11
- padding: 0;
12
- height: 100%;
13
- background: #0d1117;
14
- overflow: hidden;
15
- }
16
- #terminal-container {
17
- width: 100%;
18
- height: 100%;
19
- }
20
- .xterm-viewport {
21
- overflow-y: auto !important;
22
- }
23
- </style>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
+ <meta name="mobile-web-app-capable" content="yes">
7
+ <meta name="apple-mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
+ <meta name="theme-color" content="#0d1117">
10
+ <meta name="description" content="Remote access to Claude Code sessions from any device">
11
+
12
+ <!-- Favicon -->
13
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect fill='%230d1117' width='100' height='100' rx='20'/><text x='50' y='68' font-size='52' font-weight='600' text-anchor='middle' fill='%2358a6ff'>C</text></svg>">
14
+ <link rel="apple-touch-icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect fill='%230d1117' width='100' height='100'/><text x='50' y='68' font-size='52' font-weight='600' text-anchor='middle' fill='%2358a6ff'>C</text></svg>">
15
+
16
+ <title>Claude Code Remote</title>
17
+ <link rel="manifest" href="/manifest.json">
18
+
19
+ <!-- xterm.js from CDN -->
20
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
21
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
22
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
23
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
24
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-serialize@0.13.0/lib/addon-serialize.min.js"></script>
25
+
26
+ <link rel="stylesheet" href="/styles.css">
24
27
  </head>
25
28
  <body>
26
- <div id="terminal-container"></div>
27
- <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
28
- <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
29
- <script>
30
- const term = new Terminal({
31
- cursorBlink: true,
32
- theme: {
33
- background: '#0d1117',
34
- foreground: '#f0f6fc'
35
- },
36
- fontSize: 14,
37
- fontFamily: 'Menlo, Monaco, "Courier New", monospace'
38
- });
39
- const fitAddon = new FitAddon.FitAddon();
40
- term.loadAddon(fitAddon);
41
- term.open(document.getElementById('terminal-container'));
42
- fitAddon.fit();
29
+ <div id="app">
30
+ <!-- Auth Screen -->
31
+ <div id="auth-screen" class="screen active">
32
+ <div class="auth-container">
33
+ <!-- Logo -->
34
+ <div class="auth-logo" aria-hidden="true">
35
+ <svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
36
+ <rect width="64" height="64" rx="12" fill="rgba(88, 166, 255, 0.1)"/>
37
+ <rect x="1" y="1" width="62" height="62" rx="11" stroke="rgba(88, 166, 255, 0.3)" stroke-width="1"/>
38
+ <text x="32" y="44" font-size="32" font-weight="600" text-anchor="middle" fill="#58a6ff" font-family="system-ui, sans-serif">C</text>
39
+ </svg>
40
+ </div>
41
+
42
+ <h1>Claude Code Remote</h1>
43
+ <p class="subtitle">Connect to your local Claude Code session from anywhere</p>
44
+
45
+ <form id="auth-form" onsubmit="return false;">
46
+ <input
47
+ type="text"
48
+ id="token-input"
49
+ placeholder="Enter auth token"
50
+ autocomplete="off"
51
+ autocapitalize="off"
52
+ autocorrect="off"
53
+ spellcheck="false"
54
+ aria-label="Authentication token"
55
+ >
56
+ <button id="connect-btn" class="btn-primary" type="submit">
57
+ <span class="btn-text">Connect</span>
58
+ <span class="btn-loading" aria-hidden="true">
59
+ <svg class="spinner" width="20" height="20" viewBox="0 0 20 20" fill="none">
60
+ <circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="2" stroke-opacity="0.3"/>
61
+ <path d="M10 2a8 8 0 0 1 8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
62
+ </svg>
63
+ </span>
64
+ </button>
65
+ </form>
66
+
67
+ <p id="auth-error" class="error" role="alert" aria-live="polite"></p>
68
+ </div>
69
+ </div>
43
70
 
44
- const urlParams = new URLSearchParams(window.location.search);
45
- const token = urlParams.get('token');
46
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
47
- const ws = new WebSocket(`${protocol}//${window.location.host}?token=${token}`);
71
+ <!-- Main Screen -->
72
+ <div id="main-screen" class="screen">
73
+ <!-- Collapsible Header -->
74
+ <header id="header" role="toolbar" aria-label="Session controls">
75
+ <div class="header-left">
76
+ <!-- Mobile: Dropdown -->
77
+ <select id="session-select" class="mobile-only" aria-label="Select session">
78
+ <option value="">Select session...</option>
79
+ </select>
80
+ <!-- Desktop: Tabs -->
81
+ <div id="session-tabs" class="session-tabs desktop-only" role="tablist" aria-label="Sessions"></div>
82
+ <div class="header-left-actions">
83
+ <button id="new-session-btn" class="btn-icon-minimal" title="Create new session" aria-label="Create new session">
84
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
85
+ <line x1="12" y1="5" x2="12" y2="19"/>
86
+ <line x1="5" y1="12" x2="19" y2="12"/>
87
+ </svg>
88
+ </button>
89
+ <button id="close-session-btn" class="btn-icon-minimal mobile-only" title="Close current session" aria-label="Close current session">
90
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
91
+ <line x1="18" y1="6" x2="6" y2="18"/>
92
+ <line x1="6" y1="6" x2="18" y2="18"/>
93
+ </svg>
94
+ </button>
95
+ </div>
96
+ </div>
97
+ <div class="header-right">
98
+ <div id="reconnect-indicator" class="reconnect-indicator hidden" title="Reconnecting...">
99
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
100
+ <path d="M21 12a9 9 0 1 1-9-9"/>
101
+ <polyline points="21 3 21 9 15 9"/>
102
+ </svg>
103
+ </div>
104
+ <button id="attach-btn" class="btn-icon" title="Attach image" aria-label="Attach image">
105
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
106
+ <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
107
+ </svg>
108
+ </button>
109
+ <input type="file" id="image-input" accept="image/*" class="hidden" aria-hidden="true">
110
+ <button id="preview-btn" class="btn-icon" title="Port preview" aria-label="Open port preview">
111
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
112
+ <rect x="2" y="3" width="20" height="14" rx="2"/>
113
+ <line x1="8" y1="21" x2="16" y2="21"/>
114
+ <line x1="12" y1="17" x2="12" y2="21"/>
115
+ </svg>
116
+ </button>
117
+ <button id="settings-btn" class="btn-icon" title="Settings" aria-label="Settings">
118
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
119
+ <circle cx="12" cy="12" r="3"/>
120
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
121
+ </svg>
122
+ </button>
123
+ <button id="toggle-header-btn-desktop" class="btn-icon desktop-only" title="Toggle header" aria-label="Toggle header visibility" aria-expanded="true">
124
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="chevron-icon">
125
+ <polyline points="18 15 12 9 6 15"/>
126
+ </svg>
127
+ </button>
128
+ <button id="toggle-header-btn" class="btn-icon mobile-only" title="Toggle header" aria-label="Toggle header visibility" aria-expanded="true">
129
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="chevron-icon">
130
+ <polyline points="18 15 12 9 6 15"/>
131
+ </svg>
132
+ </button>
133
+ </div>
134
+ </header>
48
135
 
49
- ws.onopen = () => {
50
- // term.write('Connected to bridge.\r\n');
51
- };
136
+ <!-- Terminal Container -->
137
+ <div id="terminal-container" role="application" aria-label="Terminal"></div>
52
138
 
53
- ws.onmessage = (event) => {
54
- term.write(event.data);
55
- };
139
+ <!-- Scroll to bottom button (visible when terminal is scrolled up) -->
140
+ <button id="scroll-bottom-btn" class="scroll-bottom-btn hidden" title="Scroll to bottom" aria-label="Scroll to bottom">
141
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
142
+ <polyline points="6 9 12 15 18 9"/>
143
+ </svg>
144
+ </button>
56
145
 
57
- term.onData((data) => {
58
- ws.send(data);
59
- });
146
+ <!-- Mobile Control Keys Toolbar (visible when keyboard is open on mobile) -->
147
+ <div id="mobile-keys" class="mobile-keys hidden" role="toolbar" aria-label="Control keys">
148
+ <button type="button" class="mobile-key" data-key="escape" aria-label="Escape">Esc</button>
149
+ <button type="button" class="mobile-key mobile-key-modifier" data-key="ctrl" aria-label="Control modifier" aria-pressed="false">Ctrl</button>
150
+ <button type="button" class="mobile-key mobile-key-modifier" data-key="shift" aria-label="Shift modifier" aria-pressed="false">Shift</button>
151
+ <button type="button" class="mobile-key" data-key="tab" aria-label="Tab">Tab</button>
152
+ <button type="button" class="mobile-key" data-key="slash" aria-label="Slash">/</button>
153
+ <button type="button" class="mobile-key" data-key="up" aria-label="Arrow up">↑</button>
154
+ <button type="button" class="mobile-key" data-key="down" aria-label="Arrow down">↓</button>
155
+ </div>
60
156
 
61
- ws.onclose = () => {
62
- term.write('\r\nConnection closed.\r\n');
63
- };
157
+ <!-- Floating expand button (visible when header collapsed) -->
158
+ <button id="expand-header-btn" class="btn-icon floating-btn hidden" title="Show header" aria-label="Show header">
159
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
160
+ <polyline points="6 9 12 15 18 9"/>
161
+ </svg>
162
+ </button>
64
163
 
65
- window.addEventListener('resize', () => {
66
- fitAddon.fit();
67
- if (ws.readyState === WebSocket.OPEN) {
68
- ws.send(JSON.stringify({
69
- type: 'resize',
70
- cols: term.cols,
71
- rows: term.rows
72
- }));
73
- }
74
- });
75
- </script>
164
+ </div>
165
+ <script src="/app.js"></script>
76
166
  </body>
77
167
  </html>