conduit-mobile 0.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.
@@ -0,0 +1,419 @@
1
+ import { hasWebSpeech, startWebSpeech } from './speech.js';
2
+
3
+ // ── State ─────────────────────────────────────────────────────────────────────
4
+ let ws = null;
5
+ let activeSessionId = null;
6
+
7
+ // Map of sessionId -> { term: Terminal, fitAddon, container, tabEl }
8
+ const sessions = new Map();
9
+
10
+ // ── DOM refs ──────────────────────────────────────────────────────────────────
11
+ const authScreen = document.getElementById('auth-screen');
12
+ const appScreen = document.getElementById('app-screen');
13
+ const tokenInput = document.getElementById('token-input');
14
+ const connectBtn = document.getElementById('connect-btn');
15
+ const authError = document.getElementById('auth-error');
16
+ const tabsEl = document.getElementById('tabs');
17
+ const newTabBtn = document.getElementById('new-tab-btn');
18
+ const viewport = document.getElementById('terminal-viewport');
19
+ const copyBtn = document.getElementById('copy-btn');
20
+ const msgInput = document.getElementById('msg-input');
21
+ const sendBtn = document.getElementById('send-btn');
22
+ const micBtn = document.getElementById('mic-btn');
23
+
24
+ // ── Auto-fill token from URL param ────────────────────────────────────────────
25
+ const urlToken = new URLSearchParams(location.search).get('token');
26
+ if (urlToken) tokenInput.value = urlToken;
27
+
28
+ // ── Connect ───────────────────────────────────────────────────────────────────
29
+ function connect(token) {
30
+ setAuthError('');
31
+ connectBtn.disabled = true;
32
+ connectBtn.textContent = 'Connecting…';
33
+
34
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
35
+ ws = new WebSocket(`${proto}://${location.host}`);
36
+
37
+ ws.onopen = () => {
38
+ ws.send(JSON.stringify({ type: 'auth', token }));
39
+ };
40
+
41
+ ws.onmessage = (event) => {
42
+ const msg = JSON.parse(event.data);
43
+ handleMessage(msg);
44
+ };
45
+
46
+ ws.onerror = () => {
47
+ setAuthError('Connection failed. Make sure the server is running.');
48
+ resetConnectBtn();
49
+ };
50
+
51
+ ws.onclose = () => {
52
+ if (appScreen.classList.contains('active')) {
53
+ showOverlay('Disconnected. Reload to reconnect.');
54
+ } else {
55
+ setAuthError('Connection closed.');
56
+ resetConnectBtn();
57
+ }
58
+ };
59
+ }
60
+
61
+ function handleMessage(msg) {
62
+ switch (msg.type) {
63
+
64
+ case 'auth_ok':
65
+ showApp();
66
+ if (msg.sessions.length === 0) {
67
+ requestNewSession();
68
+ } else {
69
+ // Re-hydrate existing sessions
70
+ for (const s of msg.sessions) addTab(s.id, s.label, false);
71
+ if (msg.sessions.length > 0) switchTo(msg.sessions[0].id);
72
+ }
73
+ break;
74
+
75
+ case 'auth_fail':
76
+ setAuthError('Invalid token. Try again.');
77
+ resetConnectBtn();
78
+ ws.close();
79
+ break;
80
+
81
+ case 'session_created':
82
+ switchTo(msg.id);
83
+ break;
84
+
85
+ case 'sessions_updated':
86
+ syncTabs(msg.sessions);
87
+ break;
88
+
89
+ case 'history':
90
+ case 'output': {
91
+ const s = sessions.get(msg.sessionId);
92
+ if (s) {
93
+ s.term.write(msg.data);
94
+ }
95
+ break;
96
+ }
97
+
98
+ case 'exit': {
99
+ const s = sessions.get(msg.sessionId);
100
+ if (s) {
101
+ s.term.write(`\r\n\x1b[31m[process exited with code ${msg.exitCode}]\x1b[0m\r\n`);
102
+ }
103
+ break;
104
+ }
105
+
106
+ case 'error':
107
+ alert(`Server error: ${msg.message}`);
108
+ break;
109
+ }
110
+ }
111
+
112
+ // ── Session / tab management ──────────────────────────────────────────────────
113
+ function requestNewSession(name) {
114
+ ws.send(JSON.stringify({ type: 'create_session', name: name || null }));
115
+ }
116
+
117
+ function addTab(id, label, subscribe = true) {
118
+ if (sessions.has(id)) return;
119
+
120
+ // Create xterm instance
121
+ const term = new Terminal({
122
+ theme: {
123
+ background: '#0d0d0d',
124
+ foreground: '#e8e8e8',
125
+ cursor: '#f97316',
126
+ selectionBackground: '#f9731644',
127
+ },
128
+ fontSize: 13,
129
+ fontFamily: '"Cascadia Code", "Fira Code", monospace',
130
+ cursorBlink: true,
131
+ scrollback: 2000,
132
+ convertEol: true,
133
+ });
134
+
135
+ const fitAddon = new FitAddon.FitAddon();
136
+ term.loadAddon(fitAddon);
137
+
138
+ // Container div
139
+ const container = document.createElement('div');
140
+ container.className = 'term-container';
141
+ container.dataset.sessionId = id;
142
+ viewport.appendChild(container);
143
+ term.open(container);
144
+ // Don't fit here — container is hidden. switchTo() handles fit once visible.
145
+
146
+ // Tab element
147
+ const tabEl = document.createElement('div');
148
+ tabEl.className = 'tab';
149
+ tabEl.dataset.sessionId = id;
150
+ tabEl.innerHTML = `<span class="tab-label">${label}</span><span class="close-tab">✕</span>`;
151
+ tabEl.querySelector('.tab-label').addEventListener('click', () => switchTo(id));
152
+ tabEl.querySelector('.close-tab').addEventListener('click', (e) => {
153
+ e.stopPropagation();
154
+ killSession(id);
155
+ });
156
+ tabsEl.appendChild(tabEl);
157
+
158
+ // Show/hide the copy button whenever the selection changes
159
+ term.onSelectionChange(() => {
160
+ if (id !== activeSessionId) return;
161
+ const has = term.getSelection().length > 0;
162
+ copyBtn.classList.toggle('hidden', !has);
163
+ });
164
+
165
+ sessions.set(id, { term, fitAddon, container, tabEl });
166
+
167
+ if (subscribe) {
168
+ ws.send(JSON.stringify({ type: 'subscribe', sessionId: id }));
169
+ }
170
+ }
171
+
172
+ function switchTo(id) {
173
+ if (!sessions.has(id)) return;
174
+
175
+ // Deactivate current
176
+ if (activeSessionId && sessions.has(activeSessionId)) {
177
+ const cur = sessions.get(activeSessionId);
178
+ cur.container.classList.remove('active');
179
+ cur.tabEl.classList.remove('active');
180
+ }
181
+
182
+ activeSessionId = id;
183
+ const s = sessions.get(id);
184
+ s.container.classList.add('active');
185
+ s.tabEl.classList.add('active');
186
+ copyBtn.classList.add('hidden'); // hide until a selection is made in this tab
187
+ s.tabEl.scrollIntoView({ inline: 'nearest', behavior: 'smooth' });
188
+
189
+ // Refit after becoming visible
190
+ requestAnimationFrame(() => {
191
+ s.fitAddon.fit();
192
+ sendResize(id, s.term);
193
+ });
194
+
195
+ msgInput.focus();
196
+ }
197
+
198
+ function syncTabs(serverSessions) {
199
+ // Remove tabs for sessions that no longer exist
200
+ for (const [id] of sessions) {
201
+ if (!serverSessions.find((s) => s.id === id)) removeTab(id);
202
+ }
203
+ // Add tabs for new sessions
204
+ for (const s of serverSessions) {
205
+ if (!sessions.has(s.id)) addTab(s.id, s.label);
206
+ }
207
+ }
208
+
209
+ function removeTab(id) {
210
+ const s = sessions.get(id);
211
+ if (!s) return;
212
+ s.term.dispose();
213
+ s.container.remove();
214
+ s.tabEl.remove();
215
+ sessions.delete(id);
216
+
217
+ if (activeSessionId === id) {
218
+ activeSessionId = null;
219
+ const remaining = [...sessions.keys()];
220
+ if (remaining.length > 0) switchTo(remaining[remaining.length - 1]);
221
+ }
222
+ }
223
+
224
+ function killSession(id) {
225
+ ws.send(JSON.stringify({ type: 'kill_session', sessionId: id }));
226
+ removeTab(id);
227
+ }
228
+
229
+ function sendResize(id, term) {
230
+ ws.send(JSON.stringify({ type: 'resize', sessionId: id, cols: term.cols, rows: term.rows }));
231
+ }
232
+
233
+ // ── Sending input ─────────────────────────────────────────────────────────────
234
+ function sendInput() {
235
+ const text = msgInput.value.trim();
236
+ if (!text || !activeSessionId) return;
237
+ ws.send(JSON.stringify({ type: 'input', sessionId: activeSessionId, data: text }));
238
+ msgInput.value = '';
239
+ msgInput.style.height = 'auto';
240
+ }
241
+
242
+ sendBtn.addEventListener('click', sendInput);
243
+
244
+ msgInput.addEventListener('keydown', (e) => {
245
+ // Ctrl+Enter or Shift+Enter = send; plain Enter = newline for multi-line editing
246
+ if (e.key === 'Enter' && !e.shiftKey) {
247
+ e.preventDefault();
248
+ sendInput();
249
+ }
250
+ });
251
+
252
+ // Auto-grow textarea
253
+ msgInput.addEventListener('input', () => {
254
+ msgInput.style.height = 'auto';
255
+ msgInput.style.height = Math.min(msgInput.scrollHeight, 120) + 'px';
256
+ });
257
+
258
+ // ── Speech to text ────────────────────────────────────────────────────────────
259
+ // Strategy: tap mic → focus textarea → phone keyboard opens with its mic button.
260
+ // The keyboard mic (Google/Apple STT) is more reliable than the Web Speech API.
261
+ // Web Speech API is used as a fallback on desktop or if keyboard mic isn't available.
262
+
263
+ let activeRecognition = null;
264
+
265
+ function setMsgInputValue(text) {
266
+ msgInput.value = text;
267
+ msgInput.style.height = 'auto';
268
+ msgInput.style.height = Math.min(msgInput.scrollHeight, 120) + 'px';
269
+ }
270
+
271
+ function micDone() {
272
+ micBtn.classList.remove('listening');
273
+ micBtn.textContent = '🎤';
274
+ activeRecognition = null;
275
+ }
276
+
277
+ micBtn.addEventListener('click', (e) => {
278
+ e.preventDefault();
279
+
280
+ // If Web Speech API is running, stop it
281
+ if (activeRecognition) {
282
+ try { activeRecognition.stop(); } catch {}
283
+ micDone();
284
+ return;
285
+ }
286
+
287
+ // On mobile: just focus the textarea — the keyboard opens and the user
288
+ // can tap the mic key on their native keyboard (most reliable approach)
289
+ msgInput.focus();
290
+
291
+ // Also try Web Speech API — if it works in this browser it kicks in
292
+ // alongside; if it fires first and gives a clean result, great.
293
+ // If not (e.g. permission denied / not supported) we silently fall back
294
+ // to the native keyboard mic.
295
+ if (hasWebSpeech()) {
296
+ micBtn.classList.add('listening');
297
+ micBtn.textContent = '🔴';
298
+
299
+ activeRecognition = startWebSpeech({
300
+ onFinal(transcript) {
301
+ if (transcript) setMsgInputValue((msgInput.value + ' ' + transcript).trim());
302
+ micDone();
303
+ },
304
+ onError(msg) {
305
+ // Permission denied = tell the user; anything else = silent fallback
306
+ if (msg.includes('denied')) alert(msg);
307
+ micDone();
308
+ },
309
+ });
310
+ }
311
+ });
312
+
313
+ // ── Special keys toolbar ──────────────────────────────────────────────────────
314
+ document.getElementById('keys-bar').addEventListener('click', (e) => {
315
+ const btn = e.target.closest('.key-btn');
316
+ if (!btn || !activeSessionId) return;
317
+ const raw = btn.dataset.raw;
318
+ if (!raw) return;
319
+ ws.send(JSON.stringify({ type: 'input', sessionId: activeSessionId, data: raw, raw: true }));
320
+ });
321
+
322
+ // ── Copy button ───────────────────────────────────────────────────────────────
323
+ copyBtn.addEventListener('click', async () => {
324
+ if (!activeSessionId) return;
325
+ const s = sessions.get(activeSessionId);
326
+ if (!s) return;
327
+ const text = s.term.getSelection();
328
+ if (!text) return;
329
+ try {
330
+ await navigator.clipboard.writeText(text);
331
+ copyBtn.textContent = 'Copied!';
332
+ copyBtn.classList.add('copied');
333
+ setTimeout(() => {
334
+ copyBtn.textContent = 'Copy';
335
+ copyBtn.classList.remove('copied');
336
+ copyBtn.classList.add('hidden');
337
+ s.term.clearSelection();
338
+ }, 1500);
339
+ } catch {
340
+ // clipboard API blocked (non-https or permissions) — fall back to prompt
341
+ prompt('Copy this text:', text);
342
+ }
343
+ });
344
+
345
+ // ── New tab button ────────────────────────────────────────────────────────────
346
+ newTabBtn.addEventListener('click', () => requestNewSession());
347
+
348
+ // ── Resize handling ───────────────────────────────────────────────────────────
349
+ const resizeObserver = new ResizeObserver(() => {
350
+ if (!activeSessionId) return;
351
+ const s = sessions.get(activeSessionId);
352
+ if (s) {
353
+ s.fitAddon.fit();
354
+ sendResize(activeSessionId, s.term);
355
+ }
356
+ });
357
+ resizeObserver.observe(viewport);
358
+
359
+ // ── Helpers ───────────────────────────────────────────────────────────────────
360
+ function showApp() {
361
+ authScreen.classList.remove('active');
362
+ appScreen.classList.add('active');
363
+ }
364
+
365
+ function setAuthError(msg) {
366
+ authError.textContent = msg;
367
+ authError.classList.toggle('hidden', !msg);
368
+ }
369
+
370
+ function resetConnectBtn() {
371
+ connectBtn.disabled = false;
372
+ connectBtn.textContent = 'Connect';
373
+ }
374
+
375
+ function showOverlay(msg) {
376
+ const el = document.createElement('div');
377
+ el.id = 'empty-state';
378
+ el.innerHTML = `<div class="big">⚡</div><div>${msg}</div>`;
379
+ viewport.appendChild(el);
380
+ }
381
+
382
+ // ── Visual Viewport (keyboard resize) ────────────────────────────────────────
383
+ // When the on-screen keyboard opens, the visual viewport shrinks.
384
+ // We pin #app-screen to the visual viewport so the terminal shrinks
385
+ // and the input bar stays pinned above the keyboard.
386
+ if (window.visualViewport) {
387
+ const appScreen = document.getElementById('app-screen');
388
+ const updateViewport = () => {
389
+ const vv = window.visualViewport;
390
+ appScreen.style.height = vv.height + 'px';
391
+ appScreen.style.top = vv.offsetTop + 'px';
392
+ // Refit the active terminal so cols/rows match the new size
393
+ if (activeSessionId) {
394
+ const s = sessions.get(activeSessionId);
395
+ if (s) {
396
+ requestAnimationFrame(() => {
397
+ s.fitAddon.fit();
398
+ sendResize(activeSessionId, s.term);
399
+ });
400
+ }
401
+ }
402
+ };
403
+ window.visualViewport.addEventListener('resize', updateViewport);
404
+ window.visualViewport.addEventListener('scroll', updateViewport);
405
+ }
406
+
407
+ // ── Boot ──────────────────────────────────────────────────────────────────────
408
+ connectBtn.addEventListener('click', () => {
409
+ const token = tokenInput.value.trim();
410
+ if (!token) { setAuthError('Please enter your token.'); return; }
411
+ connect(token);
412
+ });
413
+
414
+ tokenInput.addEventListener('keydown', (e) => {
415
+ if (e.key === 'Enter') connectBtn.click();
416
+ });
417
+
418
+ // Auto-connect if token in URL
419
+ if (urlToken) connectBtn.click();
@@ -0,0 +1,76 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
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
+ <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
+ <title>Conduit</title>
10
+ <link rel="manifest" href="/manifest.json" />
11
+ <link rel="stylesheet" href="/style.css" />
12
+ <!-- xterm.js -->
13
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css" />
14
+ <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
15
+ <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
16
+ </head>
17
+ <body>
18
+
19
+ <!-- ── Auth Screen ──────────────────────────────────────────────────────── -->
20
+ <div id="auth-screen" class="screen active">
21
+ <div class="auth-box">
22
+ <div class="logo">
23
+ <span class="logo-icon">⬡</span>
24
+ <span class="logo-text">Conduit</span>
25
+ </div>
26
+ <p class="auth-hint">Enter the token shown on your laptop</p>
27
+ <input id="token-input" type="text" placeholder="xxxx-xxxx-xxxx" autocomplete="off" spellcheck="false" />
28
+ <button id="connect-btn">Connect</button>
29
+ <p id="auth-error" class="error hidden"></p>
30
+ </div>
31
+ </div>
32
+
33
+ <!-- ── Main App ──────────────────────────────────────────────────────────── -->
34
+ <div id="app-screen" class="screen">
35
+
36
+ <!-- Tab bar -->
37
+ <div id="tab-bar">
38
+ <div id="tabs"></div>
39
+ <button id="new-tab-btn" title="New terminal">+</button>
40
+ </div>
41
+
42
+ <!-- Terminal viewport -->
43
+ <div id="terminal-viewport">
44
+ <!-- xterm instances are injected here -->
45
+ <button id="copy-btn" class="hidden" aria-label="Copy selection">Copy</button>
46
+ </div>
47
+
48
+ <!-- Special keys toolbar -->
49
+ <div id="keys-bar">
50
+ <button class="key-btn" data-raw="&#x1b;">Esc</button>
51
+ <button class="key-btn" data-raw="&#x09;">Tab</button>
52
+ <div class="key-sep"></div>
53
+ <button class="key-btn" data-raw="&#x1b;[A">↑</button>
54
+ <button class="key-btn" data-raw="&#x1b;[B">↓</button>
55
+ <button class="key-btn" data-raw="&#x1b;[D">←</button>
56
+ <button class="key-btn" data-raw="&#x1b;[C">→</button>
57
+ <div class="key-sep"></div>
58
+ <button class="key-btn" data-raw="&#x0d;">↵</button>
59
+ <button class="key-btn key-danger" data-raw="&#x03;">Ctrl+C</button>
60
+ <div class="key-sep"></div>
61
+ <button class="key-btn key-confirm" data-raw="y&#x0d;">y</button>
62
+ <button class="key-btn key-danger" data-raw="n&#x0d;">n</button>
63
+ </div>
64
+
65
+ <!-- Input bar -->
66
+ <div id="input-bar">
67
+ <button id="mic-btn" title="Speech to text">🎤</button>
68
+ <textarea id="msg-input" placeholder="Type a command..." rows="1"></textarea>
69
+ <button id="send-btn">Send</button>
70
+ </div>
71
+
72
+ </div>
73
+
74
+ <script type="module" src="/app.js"></script>
75
+ </body>
76
+ </html>
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "Conduit",
3
+ "short_name": "Conduit",
4
+ "description": "Run Claude Code from your phone",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#0d0d0d",
8
+ "theme_color": "#0d0d0d",
9
+ "orientation": "portrait-primary",
10
+ "icons": [
11
+ {
12
+ "src": "/icon-192.png",
13
+ "sizes": "192x192",
14
+ "type": "image/png"
15
+ },
16
+ {
17
+ "src": "/icon-512.png",
18
+ "sizes": "512x512",
19
+ "type": "image/png"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,39 @@
1
+ // Speech input — two strategies:
2
+ //
3
+ // 1. NATIVE (preferred): focus the textarea so the keyboard opens.
4
+ // The user taps the mic on their phone keyboard (Google/Apple STT).
5
+ // Zero bugs, best accuracy, works everywhere.
6
+ //
7
+ // 2. WEB SPEECH API (fallback): single-shot, no interim results.
8
+ // Avoids the duplication bug caused by continuous + interim mode.
9
+
10
+ export function hasWebSpeech() {
11
+ return !!(window.SpeechRecognition || window.webkitSpeechRecognition);
12
+ }
13
+
14
+ // Single-shot Web Speech API — start, speak, auto-stops on silence, fires onFinal once.
15
+ export function startWebSpeech({ onFinal, onError }) {
16
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
17
+ if (!SR) { onError('Not supported'); return null; }
18
+
19
+ const r = new SR();
20
+ r.continuous = false;
21
+ r.interimResults = false; // final result only — eliminates duplication
22
+ r.lang = navigator.language || 'en-US';
23
+
24
+ r.onresult = (event) => {
25
+ const transcript = event.results[0]?.[0]?.transcript?.trim() ?? '';
26
+ if (transcript) onFinal(transcript);
27
+ };
28
+
29
+ r.onerror = (e) => {
30
+ if (e.error === 'not-allowed') onError('Microphone permission denied.');
31
+ else if (e.error !== 'no-speech' && e.error !== 'aborted') onError(`Speech error: ${e.error}`);
32
+ else onFinal(''); // no-speech / aborted = silent, not an error
33
+ };
34
+
35
+ r.onend = () => {}; // handled via onresult / onerror
36
+
37
+ try { r.start(); } catch {}
38
+ return r;
39
+ }