cli-tunnel 1.0.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/LICENSE +21 -0
- package/README.md +100 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +308 -0
- package/package.json +36 -0
- package/remote-ui/app.js +551 -0
- package/remote-ui/index.html +58 -0
- package/remote-ui/manifest.json +10 -0
- package/remote-ui/styles.css +249 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tamir Dresher
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# cli-tunnel
|
|
2
|
+
|
|
3
|
+
Tunnel any CLI app to your phone — see the exact terminal output in your browser and type back into it.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx cli-tunnel --tunnel copilot --yolo
|
|
7
|
+
npx cli-tunnel --tunnel python -i
|
|
8
|
+
npx cli-tunnel --tunnel htop
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## How It Works
|
|
12
|
+
|
|
13
|
+
1. Your command runs in a **PTY** (pseudo-terminal) — full TUI with colors, diffs, interactive prompts
|
|
14
|
+
2. Raw terminal output is streamed over **WebSocket** to **xterm.js** in your browser
|
|
15
|
+
3. **Microsoft Dev Tunnels** provide an authenticated HTTPS relay — zero servers to deploy
|
|
16
|
+
4. **Bidirectional**: type on your phone → keystrokes go into the CLI session
|
|
17
|
+
5. **Private by default**: only your Microsoft/GitHub account can access the tunnel
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g cli-tunnel
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or use directly with npx:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx cli-tunnel --tunnel <command> [args...]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
Any flags after the command name are passed directly to the underlying app — cli-tunnel doesn't interpret them.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Start copilot with remote access (--yolo is a copilot flag, not ours)
|
|
37
|
+
cli-tunnel --tunnel copilot --yolo
|
|
38
|
+
|
|
39
|
+
# Pass any flags to the underlying command
|
|
40
|
+
cli-tunnel --tunnel copilot --model claude-sonnet-4 --agent squad
|
|
41
|
+
cli-tunnel --tunnel copilot --allow-all --resume
|
|
42
|
+
|
|
43
|
+
# Name your session (shows in dashboard)
|
|
44
|
+
cli-tunnel --tunnel --name wizard copilot --agent squad
|
|
45
|
+
|
|
46
|
+
# Specific port
|
|
47
|
+
cli-tunnel --tunnel --port 4000 copilot
|
|
48
|
+
|
|
49
|
+
# Works with any CLI app — all their flags pass through
|
|
50
|
+
cli-tunnel --tunnel python -i
|
|
51
|
+
cli-tunnel --tunnel vim myfile.txt
|
|
52
|
+
cli-tunnel --tunnel htop
|
|
53
|
+
cli-tunnel --tunnel ssh user@server
|
|
54
|
+
|
|
55
|
+
# Local only (no tunnel)
|
|
56
|
+
cli-tunnel copilot
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**cli-tunnel's own flags** (`--tunnel`, `--port`, `--name`) must come **before** the command. Everything after the command name passes through unchanged.
|
|
60
|
+
|
|
61
|
+
## What You See on Your Phone
|
|
62
|
+
|
|
63
|
+
- **Full terminal** rendered by xterm.js — exact same output as your local terminal
|
|
64
|
+
- **Key bar** with ↑ ↓ → ← Tab Enter Esc Ctrl+C for mobile navigation
|
|
65
|
+
- **Sessions dashboard** — see all running sessions, tap to connect
|
|
66
|
+
- **Session cleanup** — remove stale tunnels
|
|
67
|
+
|
|
68
|
+
## Prerequisites
|
|
69
|
+
|
|
70
|
+
- [Node.js](https://nodejs.org/) 22+
|
|
71
|
+
- [Microsoft Dev Tunnels CLI](https://aka.ms/devtunnels/doc) (for `--tunnel` mode)
|
|
72
|
+
```bash
|
|
73
|
+
winget install Microsoft.devtunnel # Windows
|
|
74
|
+
brew install --cask devtunnel # macOS
|
|
75
|
+
```
|
|
76
|
+
Then authenticate once: `devtunnel user login`
|
|
77
|
+
|
|
78
|
+
## Security
|
|
79
|
+
|
|
80
|
+
Tunnels are **private by default** — only the Microsoft/GitHub account that created the tunnel can connect. Auth is enforced at Microsoft's relay layer before traffic reaches your machine.
|
|
81
|
+
|
|
82
|
+
- No inbound ports opened
|
|
83
|
+
- No anonymous access
|
|
84
|
+
- No central server
|
|
85
|
+
- TLS encryption via devtunnel relay
|
|
86
|
+
|
|
87
|
+
## How It's Built
|
|
88
|
+
|
|
89
|
+
- **[node-pty](https://github.com/microsoft/node-pty)** — spawns the command in a pseudo-terminal
|
|
90
|
+
- **[xterm.js](https://xtermjs.org/)** — terminal emulator in the browser (loaded from CDN)
|
|
91
|
+
- **[ws](https://github.com/websockets/ws)** — WebSocket server for real-time streaming
|
|
92
|
+
- **[Dev Tunnels](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/)** — authenticated HTTPS relay
|
|
93
|
+
|
|
94
|
+
## Blog Post
|
|
95
|
+
|
|
96
|
+
[Your Copilot CLI on Your Phone — Building Squad Remote Control](https://www.tamirdresher.com/blog/2026/02/26/squad-remote-control)
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cli-tunnel — Tunnel any CLI app to your phone
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* cli-tunnel <command> [args...] # local only
|
|
7
|
+
* cli-tunnel --tunnel <command> [args...] # with devtunnel remote access
|
|
8
|
+
* cli-tunnel --tunnel --name myapp <command> # named session
|
|
9
|
+
*
|
|
10
|
+
* Examples:
|
|
11
|
+
* cli-tunnel copilot --yolo
|
|
12
|
+
* cli-tunnel --tunnel copilot --yolo
|
|
13
|
+
* cli-tunnel --tunnel --name wizard copilot --agent squad
|
|
14
|
+
* cli-tunnel --tunnel python -i
|
|
15
|
+
* cli-tunnel --tunnel --port 4000 node server.js
|
|
16
|
+
*/
|
|
17
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cli-tunnel — Tunnel any CLI app to your phone
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* cli-tunnel <command> [args...] # local only
|
|
7
|
+
* cli-tunnel --tunnel <command> [args...] # with devtunnel remote access
|
|
8
|
+
* cli-tunnel --tunnel --name myapp <command> # named session
|
|
9
|
+
*
|
|
10
|
+
* Examples:
|
|
11
|
+
* cli-tunnel copilot --yolo
|
|
12
|
+
* cli-tunnel --tunnel copilot --yolo
|
|
13
|
+
* cli-tunnel --tunnel --name wizard copilot --agent squad
|
|
14
|
+
* cli-tunnel --tunnel python -i
|
|
15
|
+
* cli-tunnel --tunnel --port 4000 node server.js
|
|
16
|
+
*/
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import { execSync, spawn } from 'node:child_process';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import http from 'node:http';
|
|
22
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
const BOLD = '\x1b[1m';
|
|
25
|
+
const RESET = '\x1b[0m';
|
|
26
|
+
const DIM = '\x1b[2m';
|
|
27
|
+
const GREEN = '\x1b[32m';
|
|
28
|
+
const YELLOW = '\x1b[33m';
|
|
29
|
+
// ─── Parse args ─────────────────────────────────────────────
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
if (args.includes('--help') || args.includes('-h') || args.length === 0) {
|
|
32
|
+
console.log(`
|
|
33
|
+
${BOLD}cli-tunnel${RESET} — Tunnel any CLI app to your phone
|
|
34
|
+
|
|
35
|
+
${BOLD}Usage:${RESET}
|
|
36
|
+
cli-tunnel [options] <command> [args...]
|
|
37
|
+
|
|
38
|
+
${BOLD}Options:${RESET}
|
|
39
|
+
--tunnel Enable remote access via devtunnel
|
|
40
|
+
--port <n> Bridge port (default: random)
|
|
41
|
+
--name <name> Session name (shown in dashboard)
|
|
42
|
+
--help, -h Show this help
|
|
43
|
+
|
|
44
|
+
${BOLD}Examples:${RESET}
|
|
45
|
+
cli-tunnel copilot
|
|
46
|
+
cli-tunnel --tunnel copilot --yolo
|
|
47
|
+
cli-tunnel --tunnel copilot --model claude-sonnet-4 --agent squad
|
|
48
|
+
cli-tunnel --tunnel --name wizard copilot --allow-all
|
|
49
|
+
cli-tunnel --tunnel python -i
|
|
50
|
+
cli-tunnel --tunnel htop
|
|
51
|
+
|
|
52
|
+
Any flags after the command name pass through to the underlying
|
|
53
|
+
app. cli-tunnel's own flags (--tunnel, --port, --name) must come
|
|
54
|
+
before the command.
|
|
55
|
+
`);
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
const hasTunnel = args.includes('--tunnel');
|
|
59
|
+
const portIdx = args.indexOf('--port');
|
|
60
|
+
const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1], 10) : 0;
|
|
61
|
+
const nameIdx = args.indexOf('--name');
|
|
62
|
+
const sessionName = (nameIdx !== -1 && args[nameIdx + 1]) ? args[nameIdx + 1] : '';
|
|
63
|
+
// Everything that's not our flags is the command
|
|
64
|
+
const ourFlags = new Set(['--tunnel', '--port', '--name']);
|
|
65
|
+
const cmdArgs = [];
|
|
66
|
+
let skip = false;
|
|
67
|
+
for (let i = 0; i < args.length; i++) {
|
|
68
|
+
if (skip) {
|
|
69
|
+
skip = false;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (ourFlags.has(args[i]) && args[i] !== '--tunnel') {
|
|
73
|
+
skip = true;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (args[i] === '--tunnel')
|
|
77
|
+
continue;
|
|
78
|
+
cmdArgs.push(args[i]);
|
|
79
|
+
}
|
|
80
|
+
if (cmdArgs.length === 0) {
|
|
81
|
+
console.error('Error: no command specified. Run cli-tunnel --help for usage.');
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
const command = cmdArgs[0];
|
|
85
|
+
const commandArgs = cmdArgs.slice(1);
|
|
86
|
+
const cwd = process.cwd();
|
|
87
|
+
// ─── Tunnel helpers ─────────────────────────────────────────
|
|
88
|
+
function sanitizeLabel(l) {
|
|
89
|
+
const clean = l.replace(/[^a-zA-Z0-9_\-=]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').substring(0, 50);
|
|
90
|
+
return clean || 'unknown';
|
|
91
|
+
}
|
|
92
|
+
function getGitInfo() {
|
|
93
|
+
try {
|
|
94
|
+
const remote = execSync('git remote get-url origin', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
95
|
+
const repo = remote.split('/').pop()?.replace('.git', '') || 'unknown';
|
|
96
|
+
const branch = execSync('git branch --show-current', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() || 'unknown';
|
|
97
|
+
return { repo, branch };
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { repo: path.basename(cwd), branch: 'unknown' };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// ─── Bridge server ──────────────────────────────────────────
|
|
104
|
+
const acpEventLog = [];
|
|
105
|
+
const connections = new Map();
|
|
106
|
+
const server = http.createServer((req, res) => {
|
|
107
|
+
// Sessions API
|
|
108
|
+
if (req.url === '/api/sessions' && req.method === 'GET') {
|
|
109
|
+
try {
|
|
110
|
+
const output = execSync('devtunnel list --labels cli-tunnel --json', { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
111
|
+
const data = JSON.parse(output);
|
|
112
|
+
const sessions = (data.tunnels || []).map((t) => {
|
|
113
|
+
const labels = t.labels || [];
|
|
114
|
+
const id = t.tunnelId?.replace(/\.\w+$/, '') || t.tunnelId;
|
|
115
|
+
const cluster = t.tunnelId?.split('.').pop() || 'euw';
|
|
116
|
+
const portLabel = labels.find((l) => l.startsWith('port-'));
|
|
117
|
+
const p = portLabel ? parseInt(portLabel.replace('port-', ''), 10) : 3456;
|
|
118
|
+
return {
|
|
119
|
+
id, tunnelId: t.tunnelId,
|
|
120
|
+
name: labels[1] || 'unnamed',
|
|
121
|
+
repo: labels[2] || 'unknown',
|
|
122
|
+
branch: (labels[3] || 'unknown').replace(/_/g, '/'),
|
|
123
|
+
machine: labels[4] || 'unknown',
|
|
124
|
+
online: (t.hostConnections || 0) > 0,
|
|
125
|
+
port: p,
|
|
126
|
+
url: `https://${id}-${p}.${cluster}.devtunnels.ms`,
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
130
|
+
res.end(JSON.stringify({ sessions }));
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
134
|
+
res.end(JSON.stringify({ sessions: [] }));
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// Delete session
|
|
139
|
+
if (req.url?.startsWith('/api/sessions/') && req.method === 'DELETE') {
|
|
140
|
+
const tunnelId = req.url.replace('/api/sessions/', '').replace(/\.\w+$/, '');
|
|
141
|
+
try {
|
|
142
|
+
execSync(`devtunnel delete ${tunnelId} --force`, { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
143
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
144
|
+
res.end(JSON.stringify({ deleted: true }));
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
148
|
+
res.end(JSON.stringify({ deleted: false }));
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Static files
|
|
153
|
+
const uiDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../remote-ui');
|
|
154
|
+
let filePath = path.join(uiDir, req.url === '/' ? 'index.html' : req.url || 'index.html');
|
|
155
|
+
if (!filePath.startsWith(uiDir)) {
|
|
156
|
+
res.writeHead(403);
|
|
157
|
+
res.end();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!fs.existsSync(filePath))
|
|
161
|
+
filePath = path.join(uiDir, 'index.html');
|
|
162
|
+
const ext = path.extname(filePath);
|
|
163
|
+
const mimes = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json' };
|
|
164
|
+
res.writeHead(200, { 'Content-Type': mimes[ext] || 'application/octet-stream' });
|
|
165
|
+
fs.createReadStream(filePath).pipe(res);
|
|
166
|
+
});
|
|
167
|
+
const wss = new WebSocketServer({ server });
|
|
168
|
+
wss.on('connection', (ws) => {
|
|
169
|
+
const id = Math.random().toString(36).substring(2);
|
|
170
|
+
connections.set(id, ws);
|
|
171
|
+
// Replay history
|
|
172
|
+
for (const event of acpEventLog) {
|
|
173
|
+
ws.send(JSON.stringify({ type: '_replay', data: event }));
|
|
174
|
+
}
|
|
175
|
+
ws.send(JSON.stringify({ type: '_replay_done' }));
|
|
176
|
+
ws.on('message', (data) => {
|
|
177
|
+
const raw = data.toString();
|
|
178
|
+
try {
|
|
179
|
+
const msg = JSON.parse(raw);
|
|
180
|
+
if (msg.type === 'pty_input' && ptyProcess) {
|
|
181
|
+
ptyProcess.write(msg.data);
|
|
182
|
+
}
|
|
183
|
+
if (msg.type === 'pty_resize' && ptyProcess) {
|
|
184
|
+
ptyProcess.resize(msg.cols, msg.rows);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
if (ptyProcess)
|
|
189
|
+
ptyProcess.write(raw + '\r');
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
ws.on('close', () => connections.delete(id));
|
|
193
|
+
});
|
|
194
|
+
function broadcast(data) {
|
|
195
|
+
const msg = JSON.stringify({ type: 'pty', data });
|
|
196
|
+
acpEventLog.push(msg);
|
|
197
|
+
if (acpEventLog.length > 2000)
|
|
198
|
+
acpEventLog.splice(0, acpEventLog.length - 2000);
|
|
199
|
+
for (const [, ws] of connections) {
|
|
200
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
201
|
+
ws.send(msg);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// ─── Start bridge ───────────────────────────────────────────
|
|
205
|
+
let ptyProcess = null;
|
|
206
|
+
async function main() {
|
|
207
|
+
const actualPort = await new Promise((resolve, reject) => {
|
|
208
|
+
server.listen(port, () => {
|
|
209
|
+
const addr = server.address();
|
|
210
|
+
resolve(typeof addr === 'object' ? addr.port : port);
|
|
211
|
+
});
|
|
212
|
+
server.on('error', reject);
|
|
213
|
+
});
|
|
214
|
+
const { repo, branch } = getGitInfo();
|
|
215
|
+
const machine = os.hostname();
|
|
216
|
+
const displayName = sessionName || command;
|
|
217
|
+
console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.0.0${RESET}\n`);
|
|
218
|
+
console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
|
|
219
|
+
console.log(` ${DIM}Name:${RESET} ${displayName}`);
|
|
220
|
+
console.log(` ${DIM}Port:${RESET} ${actualPort}`);
|
|
221
|
+
// Tunnel
|
|
222
|
+
if (hasTunnel) {
|
|
223
|
+
try {
|
|
224
|
+
execSync('devtunnel --version', { stdio: 'pipe' });
|
|
225
|
+
const labels = ['cli-tunnel', sanitizeLabel(sessionName || command), sanitizeLabel(repo), sanitizeLabel(branch), sanitizeLabel(machine), `port-${actualPort}`]
|
|
226
|
+
.map(l => `--labels ${l}`).join(' ');
|
|
227
|
+
const createOut = execSync(`devtunnel create ${labels} --expiration 1d --json`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
228
|
+
const tunnelId = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[0];
|
|
229
|
+
const cluster = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[1] || 'euw';
|
|
230
|
+
execSync(`devtunnel port create ${tunnelId} -p ${actualPort} --protocol http`, { stdio: 'pipe' });
|
|
231
|
+
const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false });
|
|
232
|
+
const url = await new Promise((resolve, reject) => {
|
|
233
|
+
const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 15000);
|
|
234
|
+
let out = '';
|
|
235
|
+
hostProc.stdout?.on('data', (d) => {
|
|
236
|
+
out += d.toString();
|
|
237
|
+
const match = out.match(/https:\/\/[^\s]+/);
|
|
238
|
+
if (match) {
|
|
239
|
+
clearTimeout(timeout);
|
|
240
|
+
resolve(match[0]);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
|
|
244
|
+
});
|
|
245
|
+
console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${url}${RESET}\n`);
|
|
246
|
+
try {
|
|
247
|
+
// @ts-ignore
|
|
248
|
+
const qr = (await import('qrcode-terminal'));
|
|
249
|
+
qr.default.generate(url, { small: true }, (code) => console.log(code));
|
|
250
|
+
}
|
|
251
|
+
catch { }
|
|
252
|
+
process.on('SIGINT', () => { hostProc.kill(); try {
|
|
253
|
+
execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
|
|
254
|
+
}
|
|
255
|
+
catch { } });
|
|
256
|
+
process.on('exit', () => { hostProc.kill(); try {
|
|
257
|
+
execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
|
|
258
|
+
}
|
|
259
|
+
catch { } });
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
console.log(` ${YELLOW}⚠${RESET} Tunnel failed: ${err.message}\n`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
console.log(` ${DIM}Starting ${command}...${RESET}\n`);
|
|
266
|
+
// Spawn PTY
|
|
267
|
+
const nodePty = await import('node-pty');
|
|
268
|
+
const cols = process.stdout.columns || 120;
|
|
269
|
+
const rows = process.stdout.rows || 30;
|
|
270
|
+
// Resolve command path for node-pty on Windows
|
|
271
|
+
let resolvedCmd = command;
|
|
272
|
+
if (process.platform === 'win32') {
|
|
273
|
+
try {
|
|
274
|
+
const wherePaths = execSync(`where ${command}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim().split('\n');
|
|
275
|
+
// Prefer .exe or .cmd over .ps1 for node-pty compatibility
|
|
276
|
+
const exePath = wherePaths.find(p => p.trim().endsWith('.exe')) || wherePaths.find(p => p.trim().endsWith('.cmd'));
|
|
277
|
+
if (exePath) {
|
|
278
|
+
resolvedCmd = exePath.trim();
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
// For .ps1 scripts, wrap with powershell
|
|
282
|
+
resolvedCmd = 'powershell';
|
|
283
|
+
commandArgs.unshift('-File', wherePaths[0].trim());
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
catch { /* use as-is */ }
|
|
287
|
+
}
|
|
288
|
+
ptyProcess = nodePty.spawn(resolvedCmd, commandArgs, {
|
|
289
|
+
name: 'xterm-256color',
|
|
290
|
+
cols, rows, cwd,
|
|
291
|
+
env: process.env,
|
|
292
|
+
});
|
|
293
|
+
ptyProcess.onData((data) => {
|
|
294
|
+
process.stdout.write(data);
|
|
295
|
+
broadcast(data);
|
|
296
|
+
});
|
|
297
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
298
|
+
console.log(`\n${DIM}Process exited (code ${exitCode}).${RESET}`);
|
|
299
|
+
server.close();
|
|
300
|
+
process.exit(exitCode);
|
|
301
|
+
});
|
|
302
|
+
if (process.stdin.isTTY)
|
|
303
|
+
process.stdin.setRawMode(true);
|
|
304
|
+
process.stdin.resume();
|
|
305
|
+
process.stdin.on('data', (data) => ptyProcess.write(data.toString()));
|
|
306
|
+
process.stdout.on('resize', () => ptyProcess.resize(process.stdout.columns || 120, process.stdout.rows || 30));
|
|
307
|
+
}
|
|
308
|
+
main().catch((err) => { console.error(err); process.exit(1); });
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cli-tunnel",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Tunnel any CLI app to your phone - PTY + devtunnel + xterm.js",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"cli-tunnel": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": ["dist", "remote-ui"],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"test": "vitest run"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["cli", "tunnel", "remote", "pty", "xterm", "devtunnel", "copilot", "terminal"],
|
|
16
|
+
"author": "Tamir Dresher",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/tamirdresher/cli-tunnel.git"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22.0.0"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"node-pty": "^1.1.0",
|
|
27
|
+
"qrcode-terminal": "^0.12.0",
|
|
28
|
+
"ws": "^8.19.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^25.3.2",
|
|
32
|
+
"@types/ws": "^8.18.1",
|
|
33
|
+
"typescript": "^5.9.3",
|
|
34
|
+
"vitest": "^4.0.18"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/remote-ui/app.js
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli-tunnel — Terminal-Style PWA (ACP Protocol)
|
|
3
|
+
* Raw terminal rendering matching Copilot CLI output
|
|
4
|
+
*/
|
|
5
|
+
(function () {
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
let ws = null;
|
|
9
|
+
let connected = false;
|
|
10
|
+
let sessionId = null;
|
|
11
|
+
let requestId = 0;
|
|
12
|
+
let pendingRequests = {};
|
|
13
|
+
let acpReady = false;
|
|
14
|
+
let streamingEl = null;
|
|
15
|
+
let replaying = false;
|
|
16
|
+
let toolCalls = {};
|
|
17
|
+
|
|
18
|
+
const $ = (sel) => document.querySelector(sel);
|
|
19
|
+
const terminal = $('#terminal');
|
|
20
|
+
const inputEl = $('#input');
|
|
21
|
+
const formEl = $('#input-form');
|
|
22
|
+
const statusEl = $('#status-indicator');
|
|
23
|
+
const statusText = $('#status-text');
|
|
24
|
+
const permOverlay = $('#permission-overlay');
|
|
25
|
+
const dashboard = $('#dashboard');
|
|
26
|
+
const termContainer = $('#terminal-container');
|
|
27
|
+
let currentView = 'terminal'; // 'dashboard' or 'terminal'
|
|
28
|
+
|
|
29
|
+
// ─── xterm.js Terminal ───────────────────────────────────
|
|
30
|
+
let xterm = null;
|
|
31
|
+
let fitAddon = null;
|
|
32
|
+
|
|
33
|
+
function initXterm() {
|
|
34
|
+
if (xterm) return;
|
|
35
|
+
xterm = new Terminal({
|
|
36
|
+
theme: {
|
|
37
|
+
background: '#0d1117',
|
|
38
|
+
foreground: '#c9d1d9',
|
|
39
|
+
cursor: '#3fb950',
|
|
40
|
+
selectionBackground: '#264f78',
|
|
41
|
+
black: '#0d1117',
|
|
42
|
+
red: '#f85149',
|
|
43
|
+
green: '#3fb950',
|
|
44
|
+
yellow: '#d29922',
|
|
45
|
+
blue: '#58a6ff',
|
|
46
|
+
magenta: '#bc8cff',
|
|
47
|
+
cyan: '#39c5cf',
|
|
48
|
+
white: '#c9d1d9',
|
|
49
|
+
brightBlack: '#6e7681',
|
|
50
|
+
brightRed: '#f85149',
|
|
51
|
+
brightGreen: '#3fb950',
|
|
52
|
+
brightYellow: '#d29922',
|
|
53
|
+
brightBlue: '#58a6ff',
|
|
54
|
+
brightMagenta: '#bc8cff',
|
|
55
|
+
brightCyan: '#39c5cf',
|
|
56
|
+
brightWhite: '#f0f6fc',
|
|
57
|
+
},
|
|
58
|
+
fontFamily: "'Cascadia Code', 'SF Mono', 'Fira Code', 'Menlo', monospace",
|
|
59
|
+
fontSize: 13,
|
|
60
|
+
scrollback: 5000,
|
|
61
|
+
cursorBlink: true,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
fitAddon = new FitAddon.FitAddon();
|
|
65
|
+
xterm.loadAddon(fitAddon);
|
|
66
|
+
xterm.open(termContainer);
|
|
67
|
+
fitAddon.fit();
|
|
68
|
+
|
|
69
|
+
// Send terminal size to PTY so copilot renders correctly
|
|
70
|
+
function sendResize() {
|
|
71
|
+
if (ws && ws.readyState === WebSocket.OPEN && xterm) {
|
|
72
|
+
ws.send(JSON.stringify({ type: 'pty_resize', cols: xterm.cols, rows: xterm.rows }));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Handle resize
|
|
77
|
+
window.addEventListener('resize', () => {
|
|
78
|
+
if (fitAddon) { fitAddon.fit(); sendResize(); }
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Send initial size
|
|
82
|
+
setTimeout(sendResize, 500);
|
|
83
|
+
|
|
84
|
+
// Keyboard input → send to bridge → PTY
|
|
85
|
+
xterm.onData((data) => {
|
|
86
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
87
|
+
ws.send(JSON.stringify({ type: 'pty_input', data }));
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Dashboard ───────────────────────────────────────────
|
|
93
|
+
let showOffline = false;
|
|
94
|
+
|
|
95
|
+
async function loadSessions() {
|
|
96
|
+
try {
|
|
97
|
+
const resp = await fetch('/api/sessions');
|
|
98
|
+
const data = await resp.json();
|
|
99
|
+
renderDashboard(data.sessions || []);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
dashboard.innerHTML = '<div style="padding:12px;color:var(--red)">Failed to load sessions: ' + err.message + '</div>';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function renderDashboard(sessions) {
|
|
106
|
+
const filtered = showOffline ? sessions : sessions.filter(s => s.online);
|
|
107
|
+
const offlineCount = sessions.filter(s => !s.online).length;
|
|
108
|
+
const onlineCount = sessions.filter(s => s.online).length;
|
|
109
|
+
|
|
110
|
+
let html = `<div style="padding:8px 4px;display:flex;align-items:center;gap:8px">
|
|
111
|
+
<span style="color:var(--text-dim);font-size:12px">${onlineCount} online${offlineCount > 0 ? ', ' + offlineCount + ' offline' : ''}</span>
|
|
112
|
+
<span style="flex:1"></span>
|
|
113
|
+
<button onclick="toggleOffline()" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">${showOffline ? 'Hide offline' : 'Show offline'}</button>
|
|
114
|
+
${offlineCount > 0 ? '<button onclick="cleanOffline()" style="background:none;border:1px solid var(--red);color:var(--red);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">Clean offline</button>' : ''}
|
|
115
|
+
<button onclick="loadSessions()" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">↻</button>
|
|
116
|
+
</div>`;
|
|
117
|
+
|
|
118
|
+
if (filtered.length === 0) {
|
|
119
|
+
html += '<div style="padding:20px 12px;color:var(--text-dim);text-align:center">' +
|
|
120
|
+
(sessions.length === 0 ? 'No Squad RC sessions found.' : 'No online sessions. Tap "Show offline" to see stale ones.') +
|
|
121
|
+
'</div>';
|
|
122
|
+
} else {
|
|
123
|
+
html += filtered.map(s => `
|
|
124
|
+
<div class="session-card" ${s.online ? 'onclick="openSession(\'' + escapeHtml(s.url) + '\')"' : ''}>
|
|
125
|
+
<span class="status-dot ${s.online ? 'online' : 'offline'}"></span>
|
|
126
|
+
<div class="info">
|
|
127
|
+
<div class="repo">📦 ${escapeHtml(s.repo)}</div>
|
|
128
|
+
<div class="branch">🌿 ${escapeHtml(s.branch)}</div>
|
|
129
|
+
<div class="machine">💻 ${escapeHtml(s.machine)}</div>
|
|
130
|
+
</div>
|
|
131
|
+
${s.online ? '<span class="arrow">→</span>' :
|
|
132
|
+
'<button onclick="event.stopPropagation();deleteSession(\'' + escapeHtml(s.id) + '\')" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px" title="Remove">✕</button>'}
|
|
133
|
+
</div>
|
|
134
|
+
`).join('');
|
|
135
|
+
}
|
|
136
|
+
dashboard.innerHTML = html;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
window.openSession = (url) => {
|
|
140
|
+
window.location.href = url;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
window.toggleOffline = () => {
|
|
144
|
+
showOffline = !showOffline;
|
|
145
|
+
loadSessions();
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
window.cleanOffline = async () => {
|
|
149
|
+
const resp = await fetch('/api/sessions');
|
|
150
|
+
const data = await resp.json();
|
|
151
|
+
const offline = (data.sessions || []).filter(s => !s.online);
|
|
152
|
+
for (const s of offline) {
|
|
153
|
+
await fetch('/api/sessions/' + s.id, { method: 'DELETE' });
|
|
154
|
+
}
|
|
155
|
+
loadSessions();
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
window.deleteSession = async (id) => {
|
|
159
|
+
await fetch('/api/sessions/' + id, { method: 'DELETE' });
|
|
160
|
+
loadSessions();
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
window.toggleView = () => {
|
|
164
|
+
if (currentView === 'terminal') {
|
|
165
|
+
currentView = 'dashboard';
|
|
166
|
+
terminal.classList.add('hidden');
|
|
167
|
+
termContainer.classList.add('hidden');
|
|
168
|
+
$('#input-area').classList.add('hidden');
|
|
169
|
+
dashboard.classList.remove('hidden');
|
|
170
|
+
$('#btn-sessions').textContent = 'Terminal';
|
|
171
|
+
loadSessions();
|
|
172
|
+
} else {
|
|
173
|
+
currentView = 'terminal';
|
|
174
|
+
dashboard.classList.add('hidden');
|
|
175
|
+
$('#input-area').classList.remove('hidden');
|
|
176
|
+
if (ptyMode) {
|
|
177
|
+
termContainer.classList.remove('hidden');
|
|
178
|
+
$('#input-form').classList.add('hidden');
|
|
179
|
+
if (fitAddon) fitAddon.fit();
|
|
180
|
+
if (xterm) xterm.focus();
|
|
181
|
+
} else {
|
|
182
|
+
terminal.classList.remove('hidden');
|
|
183
|
+
}
|
|
184
|
+
$('#btn-sessions').textContent = 'Sessions';
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// ─── Terminal Output ─────────────────────────────────────
|
|
189
|
+
function write(html, cls) {
|
|
190
|
+
const div = document.createElement('div');
|
|
191
|
+
if (cls) div.className = cls;
|
|
192
|
+
div.innerHTML = html;
|
|
193
|
+
terminal.appendChild(div);
|
|
194
|
+
if (!replaying) scrollToBottom();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function writeSys(text) { write(escapeHtml(text), 'sys'); }
|
|
198
|
+
|
|
199
|
+
function writeUserInput(text) {
|
|
200
|
+
write(escapeHtml(text), 'user-input');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function startStreaming() {
|
|
204
|
+
streamingEl = document.createElement('div');
|
|
205
|
+
streamingEl.className = 'agent-text';
|
|
206
|
+
streamingEl.innerHTML = '<span class="cursor"></span>';
|
|
207
|
+
terminal.appendChild(streamingEl);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function appendStreaming(text) {
|
|
211
|
+
if (!streamingEl) startStreaming();
|
|
212
|
+
// Remove cursor, append text, re-add cursor
|
|
213
|
+
const cursor = streamingEl.querySelector('.cursor');
|
|
214
|
+
if (cursor) cursor.remove();
|
|
215
|
+
streamingEl.innerHTML += escapeHtml(text);
|
|
216
|
+
const c = document.createElement('span');
|
|
217
|
+
c.className = 'cursor';
|
|
218
|
+
streamingEl.appendChild(c);
|
|
219
|
+
if (!replaying) scrollToBottom();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function endStreaming() {
|
|
223
|
+
if (streamingEl) {
|
|
224
|
+
const cursor = streamingEl.querySelector('.cursor');
|
|
225
|
+
if (cursor) cursor.remove();
|
|
226
|
+
// Render markdown-ish formatting
|
|
227
|
+
streamingEl.innerHTML = formatText(streamingEl.textContent || '');
|
|
228
|
+
streamingEl = null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── Tool Call Rendering ─────────────────────────────────
|
|
233
|
+
function renderToolCall(update) {
|
|
234
|
+
const id = update.id || update.toolCallId || ('tc-' + Date.now());
|
|
235
|
+
const name = update.name || 'tool';
|
|
236
|
+
const icons = { read: '📖', edit: '✏️', write: '✏️', shell: '▶️', search: '🔍', think: '💭', fetch: '🌐' };
|
|
237
|
+
const guessKind = name.includes('read') ? 'read' : name.includes('edit') || name.includes('write') ? 'edit' :
|
|
238
|
+
name.includes('shell') || name.includes('exec') || name.includes('run') ? 'shell' :
|
|
239
|
+
name.includes('search') || name.includes('grep') || name.includes('glob') ? 'search' :
|
|
240
|
+
name.includes('think') || name.includes('reason') ? 'think' : 'other';
|
|
241
|
+
const icon = icons[guessKind] || '⚙️';
|
|
242
|
+
|
|
243
|
+
const el = document.createElement('div');
|
|
244
|
+
el.className = 'tool-call';
|
|
245
|
+
el.id = 'tool-' + id;
|
|
246
|
+
el.dataset.toolId = id;
|
|
247
|
+
|
|
248
|
+
const inputStr = update.input ? (typeof update.input === 'string' ? update.input : JSON.stringify(update.input)) : '';
|
|
249
|
+
const shortInput = inputStr.length > 80 ? inputStr.substring(0, 80) + '...' : inputStr;
|
|
250
|
+
|
|
251
|
+
el.innerHTML = `<span class="tool-icon">${icon}</span><span class="tool-name">${escapeHtml(name)}</span> ${escapeHtml(shortInput)}<span class="tool-status in_progress">⟳</span><div class="tool-body"></div>`;
|
|
252
|
+
el.addEventListener('click', () => el.classList.toggle('expanded'));
|
|
253
|
+
|
|
254
|
+
terminal.appendChild(el);
|
|
255
|
+
toolCalls[id] = el;
|
|
256
|
+
if (!replaying) scrollToBottom();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function updateToolCall(update) {
|
|
260
|
+
const id = update.id || update.toolCallId;
|
|
261
|
+
const el = toolCalls[id];
|
|
262
|
+
if (!el) return;
|
|
263
|
+
|
|
264
|
+
if (update.status) {
|
|
265
|
+
el.classList.remove('completed', 'failed');
|
|
266
|
+
if (update.status === 'completed') el.classList.add('completed');
|
|
267
|
+
if (update.status === 'failed' || update.status === 'errored') el.classList.add('failed');
|
|
268
|
+
|
|
269
|
+
const badge = el.querySelector('.tool-status');
|
|
270
|
+
if (badge) {
|
|
271
|
+
badge.className = 'tool-status ' + update.status;
|
|
272
|
+
badge.textContent = update.status === 'completed' ? '✓' : update.status === 'failed' || update.status === 'errored' ? '✗' : '⟳';
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (update.content) {
|
|
277
|
+
const body = el.querySelector('.tool-body');
|
|
278
|
+
if (body) {
|
|
279
|
+
for (const item of (Array.isArray(update.content) ? update.content : [update.content])) {
|
|
280
|
+
if (item.type === 'diff' && item.diff) {
|
|
281
|
+
let diffHtml = `<div class="diff"><div class="diff-header">${escapeHtml(item.path || '')}</div>`;
|
|
282
|
+
if (item.diff.before) diffHtml += `<div class="diff-del">${escapeHtml(item.diff.before)}</div>`;
|
|
283
|
+
if (item.diff.after) diffHtml += `<div class="diff-add">${escapeHtml(item.diff.after)}</div>`;
|
|
284
|
+
diffHtml += '</div>';
|
|
285
|
+
body.innerHTML += diffHtml;
|
|
286
|
+
} else if (item.type === 'text' && item.text) {
|
|
287
|
+
body.innerHTML += `<div class="code-block">${escapeHtml(item.text)}</div>`;
|
|
288
|
+
} else if (typeof item === 'string') {
|
|
289
|
+
body.innerHTML += `<div class="code-block">${escapeHtml(item)}</div>`;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
el.classList.add('expanded');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ─── ACP JSON-RPC ────────────────────────────────────────
|
|
298
|
+
function sendRequest(method, params, timeoutMs) {
|
|
299
|
+
return new Promise((resolve, reject) => {
|
|
300
|
+
const id = ++requestId;
|
|
301
|
+
pendingRequests[id] = { resolve, reject };
|
|
302
|
+
const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params });
|
|
303
|
+
if (ws && ws.readyState === WebSocket.OPEN) ws.send(msg);
|
|
304
|
+
const timeout = timeoutMs !== undefined ? timeoutMs : (method === 'initialize' ? 60000 : 120000);
|
|
305
|
+
if (timeout > 0) {
|
|
306
|
+
setTimeout(() => {
|
|
307
|
+
if (pendingRequests[id]) { delete pendingRequests[id]; reject(new Error(`${method} timed out`)); }
|
|
308
|
+
}, timeout);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ─── ACP Initialize ─────────────────────────────────────
|
|
314
|
+
async function initializeACP(attempt) {
|
|
315
|
+
attempt = attempt || 1;
|
|
316
|
+
setStatus('connecting', attempt === 1 ? 'Initializing...' : `Retry ${attempt}/5...`);
|
|
317
|
+
if (attempt === 1) writeSys('Waiting for Copilot to load (~15-20s)...');
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const result = await sendRequest('initialize', {
|
|
321
|
+
protocolVersion: 1, clientCapabilities: {},
|
|
322
|
+
clientInfo: { name: 'squad-rc', title: 'Squad RC', version: '1.0.0' },
|
|
323
|
+
});
|
|
324
|
+
writeSys('Connected to Copilot ' + (result.agentInfo?.version || ''));
|
|
325
|
+
const sessionResult = await sendRequest('session/new', { cwd: '.', mcpServers: [] });
|
|
326
|
+
sessionId = sessionResult.sessionId;
|
|
327
|
+
acpReady = true;
|
|
328
|
+
setStatus('online', 'Ready');
|
|
329
|
+
writeSys('Session ready. Type a message below.');
|
|
330
|
+
} catch (err) {
|
|
331
|
+
if (attempt < 5) {
|
|
332
|
+
writeSys('Not ready, retrying in 5s... (' + attempt + '/5)');
|
|
333
|
+
setTimeout(() => initializeACP(attempt + 1), 5000);
|
|
334
|
+
} else {
|
|
335
|
+
setStatus('offline', 'Failed');
|
|
336
|
+
writeSys('Failed to connect: ' + err.message);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ─── WebSocket ───────────────────────────────────────────
|
|
342
|
+
function connect() {
|
|
343
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
344
|
+
ws = new WebSocket(`${proto}//${location.host}`);
|
|
345
|
+
setStatus('connecting', 'Connecting...');
|
|
346
|
+
|
|
347
|
+
ws.onopen = () => {
|
|
348
|
+
connected = true;
|
|
349
|
+
setTimeout(() => initializeACP(1), 1000);
|
|
350
|
+
};
|
|
351
|
+
ws.onclose = () => {
|
|
352
|
+
connected = false; acpReady = false; sessionId = null;
|
|
353
|
+
setStatus('offline', 'Disconnected');
|
|
354
|
+
setTimeout(connect, 3000);
|
|
355
|
+
};
|
|
356
|
+
ws.onerror = () => setStatus('offline', 'Error');
|
|
357
|
+
ws.onmessage = (e) => {
|
|
358
|
+
try {
|
|
359
|
+
const msg = JSON.parse(e.data);
|
|
360
|
+
handleMessage(msg);
|
|
361
|
+
} catch {}
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ─── Message Handler ─────────────────────────────────────
|
|
366
|
+
function handleMessage(msg) {
|
|
367
|
+
// Replay events from bridge recording
|
|
368
|
+
if (msg.type === '_replay') {
|
|
369
|
+
replaying = true;
|
|
370
|
+
try { handleMessage(JSON.parse(msg.data)); } catch {}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (msg.type === '_replay_done') {
|
|
374
|
+
replaying = false;
|
|
375
|
+
scrollToBottom();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// PTY data — raw terminal output → xterm.js
|
|
380
|
+
if (msg.type === 'pty') {
|
|
381
|
+
if (!ptyMode) {
|
|
382
|
+
ptyMode = true;
|
|
383
|
+
setStatus('online', 'PTY Mirror');
|
|
384
|
+
terminal.classList.add('hidden');
|
|
385
|
+
// Hide text input form but keep key bar visible
|
|
386
|
+
$('#input-form').classList.add('hidden');
|
|
387
|
+
termContainer.classList.remove('hidden');
|
|
388
|
+
initXterm();
|
|
389
|
+
}
|
|
390
|
+
xterm.write(msg.data);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// JSON-RPC response (ACP mode fallback)
|
|
395
|
+
if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
|
|
396
|
+
const p = pendingRequests[msg.id];
|
|
397
|
+
if (p) {
|
|
398
|
+
delete pendingRequests[msg.id];
|
|
399
|
+
msg.error ? p.reject(new Error(msg.error.message || 'Error')) : p.resolve(msg.result);
|
|
400
|
+
}
|
|
401
|
+
if (msg.result?.stopReason) endStreaming();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// session/update notification (ACP mode fallback)
|
|
406
|
+
if (msg.method === 'session/update' && msg.params) {
|
|
407
|
+
const u = msg.params.update || msg.params;
|
|
408
|
+
if (u.sessionUpdate === 'agent_message_chunk' && u.content?.text) {
|
|
409
|
+
appendStreaming(u.content.text);
|
|
410
|
+
}
|
|
411
|
+
if (u.sessionUpdate === 'tool_call') renderToolCall(u);
|
|
412
|
+
if (u.sessionUpdate === 'tool_call_update') updateToolCall(u);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Permission request (ACP mode)
|
|
417
|
+
if (msg.method === 'session/request_permission') {
|
|
418
|
+
showPermission(msg);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ─── PTY Terminal Rendering ──────────────────────────────
|
|
424
|
+
function appendTerminalData(data) {
|
|
425
|
+
// Strip some ANSI sequences that don't render well in HTML
|
|
426
|
+
// but keep colors and basic formatting
|
|
427
|
+
const html = ansiToHtml(data);
|
|
428
|
+
terminal.innerHTML += html;
|
|
429
|
+
if (!replaying) scrollToBottom();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function ansiToHtml(text) {
|
|
433
|
+
// Convert ANSI escape codes to HTML spans
|
|
434
|
+
let html = escapeHtml(text);
|
|
435
|
+
|
|
436
|
+
// Color codes → spans
|
|
437
|
+
const colorMap = {
|
|
438
|
+
'30': '#6e7681', '31': '#f85149', '32': '#3fb950', '33': '#d29922',
|
|
439
|
+
'34': '#58a6ff', '35': '#bc8cff', '36': '#39c5cf', '37': '#c9d1d9',
|
|
440
|
+
'90': '#6e7681', '91': '#f85149', '92': '#3fb950', '93': '#d29922',
|
|
441
|
+
'94': '#58a6ff', '95': '#bc8cff', '96': '#39c5cf', '97': '#f0f6fc',
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
// Replace \x1b[Xm patterns
|
|
445
|
+
html = html.replace(/\x1b\[(\d+)m/g, (_, code) => {
|
|
446
|
+
if (code === '0') return '</span>';
|
|
447
|
+
if (code === '1') return '<span style="font-weight:bold">';
|
|
448
|
+
if (code === '2') return '<span style="opacity:0.6">';
|
|
449
|
+
if (code === '4') return '<span style="text-decoration:underline">';
|
|
450
|
+
if (colorMap[code]) return `<span style="color:${colorMap[code]}">`;
|
|
451
|
+
return '';
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// Clean up escape sequences we don't handle
|
|
455
|
+
html = html.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
|
|
456
|
+
// Clean \r
|
|
457
|
+
html = html.replace(/\r/g, '');
|
|
458
|
+
|
|
459
|
+
return html;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ─── Permission Dialog ───────────────────────────────────
|
|
463
|
+
function showPermission(msg) {
|
|
464
|
+
const p = msg.params || {};
|
|
465
|
+
// Extract readable info from the permission request
|
|
466
|
+
const toolCall = p.toolCall || {};
|
|
467
|
+
const title = toolCall.title || p.tool || 'Tool action';
|
|
468
|
+
const kind = toolCall.kind || 'unknown';
|
|
469
|
+
const kindIcons = { read: '📖', edit: '✏️', execute: '▶️', delete: '🗑️' };
|
|
470
|
+
const icon = kindIcons[kind] || '🔧';
|
|
471
|
+
// For shell commands, show just the first line
|
|
472
|
+
const command = toolCall.rawInput?.command || toolCall.rawInput?.commands?.[0] || '';
|
|
473
|
+
const shortCmd = command.split('\n')[0].substring(0, 100) + (command.length > 100 ? '...' : '');
|
|
474
|
+
|
|
475
|
+
permOverlay.classList.remove('hidden');
|
|
476
|
+
permOverlay.innerHTML = `<div class="perm-dialog">
|
|
477
|
+
<h3>${icon} ${escapeHtml(title)}</h3>
|
|
478
|
+
<p>${escapeHtml(shortCmd || JSON.stringify(p).substring(0, 200))}</p>
|
|
479
|
+
<div class="perm-actions">
|
|
480
|
+
<button class="btn-deny" onclick="handlePerm(${msg.id}, false)">Deny</button>
|
|
481
|
+
<button class="btn-approve" onclick="handlePerm(${msg.id}, true)">Approve</button>
|
|
482
|
+
</div>
|
|
483
|
+
</div>`;
|
|
484
|
+
}
|
|
485
|
+
window.handlePerm = (id, approved) => {
|
|
486
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
487
|
+
ws.send(JSON.stringify({ jsonrpc: '2.0', id, result: { outcome: approved ? 'approved' : 'denied' } }));
|
|
488
|
+
}
|
|
489
|
+
permOverlay.classList.add('hidden');
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
// ─── Mobile Key Bar ───────────────────────────────────────
|
|
493
|
+
window.sendKey = (key) => {
|
|
494
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
495
|
+
ws.send(JSON.stringify({ type: 'pty_input', data: key }));
|
|
496
|
+
}
|
|
497
|
+
if (xterm) xterm.focus();
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
// ─── Send Prompt ─────────────────────────────────────────
|
|
501
|
+
let ptyMode = false;
|
|
502
|
+
|
|
503
|
+
formEl.addEventListener('submit', async (e) => {
|
|
504
|
+
e.preventDefault();
|
|
505
|
+
const text = inputEl.value.trim();
|
|
506
|
+
if (!text) return;
|
|
507
|
+
inputEl.value = '';
|
|
508
|
+
|
|
509
|
+
if (ptyMode) {
|
|
510
|
+
// xterm.js handles input directly — focus it
|
|
511
|
+
if (xterm) xterm.focus();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ACP mode
|
|
516
|
+
if (!acpReady || !sessionId) return;
|
|
517
|
+
writeUserInput(text);
|
|
518
|
+
try {
|
|
519
|
+
await sendRequest('session/prompt', {
|
|
520
|
+
sessionId, prompt: [{ type: 'text', text }],
|
|
521
|
+
}, 0);
|
|
522
|
+
} catch (err) {
|
|
523
|
+
endStreaming();
|
|
524
|
+
writeSys('Error: ' + err.message);
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// ─── Helpers ─────────────────────────────────────────────
|
|
529
|
+
function setStatus(state, text) {
|
|
530
|
+
statusEl.className = state;
|
|
531
|
+
statusText.textContent = text;
|
|
532
|
+
}
|
|
533
|
+
function scrollToBottom() {
|
|
534
|
+
requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
|
|
535
|
+
}
|
|
536
|
+
function escapeHtml(s) {
|
|
537
|
+
const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML;
|
|
538
|
+
}
|
|
539
|
+
function formatText(text) {
|
|
540
|
+
return escapeHtml(text)
|
|
541
|
+
.replace(/```(\w*)\n([\s\S]*?)```/g, '<div class="code-block">$2</div>')
|
|
542
|
+
.replace(/`([^`]+)`/g, '<code style="background:var(--bg-tool);padding:1px 4px;border-radius:3px">$1</code>')
|
|
543
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong style="color:var(--text-bright)">$1</strong>')
|
|
544
|
+
.replace(/\n/g, '<br>');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ─── Start ───────────────────────────────────────────────
|
|
548
|
+
writeSys('cli-tunnel');
|
|
549
|
+
connect();
|
|
550
|
+
})();
|
|
551
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
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="theme-color" content="#0d1117">
|
|
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>cli-tunnel</title>
|
|
10
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
|
11
|
+
<link rel="stylesheet" href="/styles.css">
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="app">
|
|
15
|
+
<header id="header">
|
|
16
|
+
<span id="status-indicator">●</span>
|
|
17
|
+
<span id="status-text">Connecting...</span>
|
|
18
|
+
<span style="flex:1"></span>
|
|
19
|
+
<button id="btn-sessions" onclick="toggleView()" style="background:none;border:none;color:var(--text-dim);font-family:var(--font);font-size:12px;cursor:pointer">Sessions</button>
|
|
20
|
+
</header>
|
|
21
|
+
|
|
22
|
+
<!-- Dashboard view -->
|
|
23
|
+
<div id="dashboard" class="hidden">
|
|
24
|
+
<div style="padding:12px;color:var(--text-dim);font-size:12px">Loading sessions...</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<!-- Terminal view (xterm.js) -->
|
|
28
|
+
<div id="terminal-container"></div>
|
|
29
|
+
|
|
30
|
+
<!-- Legacy terminal (ACP mode fallback) -->
|
|
31
|
+
<main id="terminal" class="hidden"></main>
|
|
32
|
+
|
|
33
|
+
<footer id="input-area">
|
|
34
|
+
<div id="key-bar">
|
|
35
|
+
<button onclick="sendKey('\x1b[A')">↑</button>
|
|
36
|
+
<button onclick="sendKey('\x1b[B')">↓</button>
|
|
37
|
+
<button onclick="sendKey('\x1b[C')">→</button>
|
|
38
|
+
<button onclick="sendKey('\x1b[D')">←</button>
|
|
39
|
+
<button onclick="sendKey('\t')">Tab</button>
|
|
40
|
+
<button onclick="sendKey('\r')">Enter</button>
|
|
41
|
+
<button onclick="sendKey('\x1b')">Esc</button>
|
|
42
|
+
<button onclick="sendKey('\x03')">Ctrl+C</button>
|
|
43
|
+
<button onclick="sendKey(' ')">Space</button>
|
|
44
|
+
<button onclick="sendKey('\x7f')">⌫</button>
|
|
45
|
+
</div>
|
|
46
|
+
<form id="input-form">
|
|
47
|
+
<span class="prompt">></span>
|
|
48
|
+
<input type="text" id="input" placeholder="Send a message..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
|
|
49
|
+
</form>
|
|
50
|
+
</footer>
|
|
51
|
+
</div>
|
|
52
|
+
<div id="permission-overlay" class="hidden"></div>
|
|
53
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
|
54
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
55
|
+
<script src="/app.js"></script>
|
|
56
|
+
</body>
|
|
57
|
+
</html>
|
|
58
|
+
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #0d1117;
|
|
3
|
+
--bg-tool: #161b22;
|
|
4
|
+
--text: #c9d1d9;
|
|
5
|
+
--text-dim: #6e7681;
|
|
6
|
+
--text-bright: #f0f6fc;
|
|
7
|
+
--green: #3fb950;
|
|
8
|
+
--red: #f85149;
|
|
9
|
+
--yellow: #d29922;
|
|
10
|
+
--blue: #58a6ff;
|
|
11
|
+
--purple: #bc8cff;
|
|
12
|
+
--cyan: #39c5cf;
|
|
13
|
+
--border: #30363d;
|
|
14
|
+
--font: 'Cascadia Code', 'SF Mono', 'Fira Code', 'Menlo', 'Consolas', monospace;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
18
|
+
|
|
19
|
+
body {
|
|
20
|
+
font-family: var(--font);
|
|
21
|
+
font-size: 13px;
|
|
22
|
+
background: var(--bg);
|
|
23
|
+
color: var(--text);
|
|
24
|
+
height: 100dvh;
|
|
25
|
+
overflow: hidden;
|
|
26
|
+
-webkit-font-smoothing: antialiased;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#app {
|
|
30
|
+
display: flex;
|
|
31
|
+
flex-direction: column;
|
|
32
|
+
height: 100dvh;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* Header — minimal status bar */
|
|
36
|
+
header {
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
gap: 8px;
|
|
40
|
+
padding: 6px 12px;
|
|
41
|
+
background: var(--bg-tool);
|
|
42
|
+
border-bottom: 1px solid var(--border);
|
|
43
|
+
flex-shrink: 0;
|
|
44
|
+
font-size: 12px;
|
|
45
|
+
}
|
|
46
|
+
#status-indicator { font-size: 10px; }
|
|
47
|
+
#status-indicator.online { color: var(--green); }
|
|
48
|
+
#status-indicator.offline { color: var(--red); }
|
|
49
|
+
#status-indicator.connecting { color: var(--yellow); }
|
|
50
|
+
#status-text { color: var(--text-dim); }
|
|
51
|
+
|
|
52
|
+
/* Terminal area (legacy) */
|
|
53
|
+
#terminal {
|
|
54
|
+
flex: 1;
|
|
55
|
+
overflow-y: auto;
|
|
56
|
+
padding: 8px 12px;
|
|
57
|
+
white-space: pre-wrap;
|
|
58
|
+
word-wrap: break-word;
|
|
59
|
+
line-height: 1.5;
|
|
60
|
+
scroll-behavior: smooth;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* xterm.js container */
|
|
64
|
+
#terminal-container {
|
|
65
|
+
flex: 1;
|
|
66
|
+
overflow: hidden;
|
|
67
|
+
}
|
|
68
|
+
#terminal-container .xterm {
|
|
69
|
+
height: 100%;
|
|
70
|
+
padding: 4px;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* System messages */
|
|
74
|
+
.sys { color: var(--text-dim); font-style: italic; }
|
|
75
|
+
|
|
76
|
+
/* User input echo */
|
|
77
|
+
.user-input { color: var(--blue); }
|
|
78
|
+
.user-input::before { content: '❯ '; color: var(--green); }
|
|
79
|
+
|
|
80
|
+
/* Agent text */
|
|
81
|
+
.agent-text { color: var(--text); }
|
|
82
|
+
|
|
83
|
+
/* Streaming cursor */
|
|
84
|
+
.cursor {
|
|
85
|
+
display: inline-block;
|
|
86
|
+
width: 7px; height: 14px;
|
|
87
|
+
background: var(--text);
|
|
88
|
+
animation: blink 0.7s infinite;
|
|
89
|
+
vertical-align: text-bottom;
|
|
90
|
+
margin-left: 1px;
|
|
91
|
+
}
|
|
92
|
+
@keyframes blink { 50% { opacity: 0; } }
|
|
93
|
+
|
|
94
|
+
/* Tool calls */
|
|
95
|
+
.tool-call {
|
|
96
|
+
margin: 4px 0;
|
|
97
|
+
border-left: 2px solid var(--blue);
|
|
98
|
+
padding: 2px 0 2px 8px;
|
|
99
|
+
color: var(--text-dim);
|
|
100
|
+
font-size: 12px;
|
|
101
|
+
}
|
|
102
|
+
.tool-call.completed { border-left-color: var(--green); }
|
|
103
|
+
.tool-call.failed { border-left-color: var(--red); }
|
|
104
|
+
.tool-call .tool-icon { margin-right: 4px; }
|
|
105
|
+
.tool-call .tool-name { color: var(--cyan); }
|
|
106
|
+
.tool-call .tool-status { margin-left: 8px; }
|
|
107
|
+
.tool-call .tool-status.completed { color: var(--green); }
|
|
108
|
+
.tool-call .tool-status.failed { color: var(--red); }
|
|
109
|
+
.tool-call .tool-status.in_progress { color: var(--yellow); }
|
|
110
|
+
|
|
111
|
+
/* Tool call content (expandable) */
|
|
112
|
+
.tool-body { display: none; margin-top: 4px; }
|
|
113
|
+
.tool-call.expanded .tool-body { display: block; }
|
|
114
|
+
|
|
115
|
+
/* Diff blocks */
|
|
116
|
+
.diff { margin: 4px 0; font-size: 12px; }
|
|
117
|
+
.diff-header { color: var(--text-dim); }
|
|
118
|
+
.diff-add { color: var(--green); }
|
|
119
|
+
.diff-add::before { content: '+ '; }
|
|
120
|
+
.diff-del { color: var(--red); }
|
|
121
|
+
.diff-del::before { content: '- '; }
|
|
122
|
+
|
|
123
|
+
/* Code blocks */
|
|
124
|
+
.code-block {
|
|
125
|
+
background: var(--bg-tool);
|
|
126
|
+
border: 1px solid var(--border);
|
|
127
|
+
border-radius: 4px;
|
|
128
|
+
padding: 6px 8px;
|
|
129
|
+
margin: 4px 0;
|
|
130
|
+
overflow-x: auto;
|
|
131
|
+
font-size: 12px;
|
|
132
|
+
}
|
|
133
|
+
.code-header { color: var(--text-dim); font-size: 11px; margin-bottom: 2px; }
|
|
134
|
+
|
|
135
|
+
/* Permission dialog */
|
|
136
|
+
#permission-overlay {
|
|
137
|
+
position: fixed;
|
|
138
|
+
top: 0; left: 0; right: 0; bottom: 0;
|
|
139
|
+
background: rgba(0,0,0,0.7);
|
|
140
|
+
display: flex;
|
|
141
|
+
align-items: flex-end;
|
|
142
|
+
justify-content: center;
|
|
143
|
+
padding-bottom: 60px;
|
|
144
|
+
z-index: 100;
|
|
145
|
+
}
|
|
146
|
+
#permission-overlay.hidden { display: none; }
|
|
147
|
+
.perm-dialog {
|
|
148
|
+
background: var(--bg-tool);
|
|
149
|
+
border: 1px solid var(--yellow);
|
|
150
|
+
border-radius: 8px;
|
|
151
|
+
padding: 12px;
|
|
152
|
+
width: calc(100% - 24px);
|
|
153
|
+
max-width: 400px;
|
|
154
|
+
max-height: 40vh;
|
|
155
|
+
display: flex;
|
|
156
|
+
flex-direction: column;
|
|
157
|
+
}
|
|
158
|
+
.perm-dialog h3 { color: var(--yellow); font-size: 14px; margin-bottom: 6px; flex-shrink: 0; }
|
|
159
|
+
.perm-dialog p { font-size: 12px; color: var(--text); margin-bottom: 10px; overflow-y: auto; flex: 1; min-height: 0; }
|
|
160
|
+
.perm-actions { display: flex; gap: 8px; justify-content: flex-end; flex-shrink: 0; padding-top: 4px; }
|
|
161
|
+
.perm-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
|
162
|
+
.perm-actions button {
|
|
163
|
+
padding: 6px 16px; border: none; border-radius: 4px;
|
|
164
|
+
cursor: pointer; font-size: 13px; font-family: var(--font);
|
|
165
|
+
}
|
|
166
|
+
.btn-approve { background: var(--green); color: #000; }
|
|
167
|
+
.btn-deny { background: var(--red); color: #fff; }
|
|
168
|
+
|
|
169
|
+
/* Input area */
|
|
170
|
+
#input-area {
|
|
171
|
+
padding: 4px 8px 6px;
|
|
172
|
+
background: var(--bg-tool);
|
|
173
|
+
border-top: 1px solid var(--border);
|
|
174
|
+
flex-shrink: 0;
|
|
175
|
+
}
|
|
176
|
+
#key-bar {
|
|
177
|
+
display: flex;
|
|
178
|
+
gap: 4px;
|
|
179
|
+
padding: 4px 0;
|
|
180
|
+
overflow-x: auto;
|
|
181
|
+
-webkit-overflow-scrolling: touch;
|
|
182
|
+
}
|
|
183
|
+
#key-bar button {
|
|
184
|
+
background: var(--bg);
|
|
185
|
+
border: 1px solid var(--border);
|
|
186
|
+
color: var(--text);
|
|
187
|
+
font-family: var(--font);
|
|
188
|
+
font-size: 13px;
|
|
189
|
+
padding: 6px 10px;
|
|
190
|
+
border-radius: 4px;
|
|
191
|
+
cursor: pointer;
|
|
192
|
+
flex-shrink: 0;
|
|
193
|
+
min-width: 36px;
|
|
194
|
+
-webkit-tap-highlight-color: transparent;
|
|
195
|
+
}
|
|
196
|
+
#key-bar button:active { background: var(--blue); color: #000; }
|
|
197
|
+
#input-form {
|
|
198
|
+
display: flex;
|
|
199
|
+
align-items: center;
|
|
200
|
+
gap: 4px;
|
|
201
|
+
}
|
|
202
|
+
.prompt { color: var(--green); font-weight: bold; }
|
|
203
|
+
#input {
|
|
204
|
+
flex: 1;
|
|
205
|
+
background: transparent;
|
|
206
|
+
border: none;
|
|
207
|
+
color: var(--text-bright);
|
|
208
|
+
font-size: 14px;
|
|
209
|
+
font-family: var(--font);
|
|
210
|
+
outline: none;
|
|
211
|
+
caret-color: var(--green);
|
|
212
|
+
}
|
|
213
|
+
#input::placeholder { color: var(--text-dim); }
|
|
214
|
+
|
|
215
|
+
.hidden { display: none !important; }
|
|
216
|
+
|
|
217
|
+
/* Dashboard */
|
|
218
|
+
#dashboard {
|
|
219
|
+
flex: 1;
|
|
220
|
+
overflow-y: auto;
|
|
221
|
+
padding: 8px;
|
|
222
|
+
}
|
|
223
|
+
.session-card {
|
|
224
|
+
background: var(--bg-tool);
|
|
225
|
+
border: 1px solid var(--border);
|
|
226
|
+
border-radius: 6px;
|
|
227
|
+
padding: 10px 12px;
|
|
228
|
+
margin-bottom: 6px;
|
|
229
|
+
cursor: pointer;
|
|
230
|
+
display: flex;
|
|
231
|
+
align-items: center;
|
|
232
|
+
gap: 10px;
|
|
233
|
+
}
|
|
234
|
+
.session-card:hover { border-color: var(--blue); }
|
|
235
|
+
.session-card .status-dot {
|
|
236
|
+
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
|
237
|
+
}
|
|
238
|
+
.session-card .status-dot.online { background: var(--green); }
|
|
239
|
+
.session-card .status-dot.offline { background: var(--text-dim); }
|
|
240
|
+
.session-card .info { flex: 1; min-width: 0; }
|
|
241
|
+
.session-card .repo { color: var(--blue); font-weight: bold; font-size: 13px; }
|
|
242
|
+
.session-card .branch { color: var(--text-dim); font-size: 11px; }
|
|
243
|
+
.session-card .machine { color: var(--text-dim); font-size: 11px; }
|
|
244
|
+
.session-card .arrow { color: var(--text-dim); }
|
|
245
|
+
|
|
246
|
+
/* Scrollbar */
|
|
247
|
+
::-webkit-scrollbar { width: 6px; }
|
|
248
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
249
|
+
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|