@bakapiano/ccsm 0.19.2 → 0.19.4
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/lib/config.js +9 -0
- package/lib/tunnel.js +149 -3
- package/package.json +1 -1
- package/public/css/terminals.css +90 -0
- package/public/css/widgets.css +42 -0
- package/public/js/components/TerminalKeyBar.js +148 -0
- package/public/js/components/TerminalView.js +52 -5
- package/public/js/pages/ConfigurePage.js +24 -14
- package/public/js/pages/LaunchPage.js +51 -6
- package/public/js/pages/RemotePage.js +88 -20
- package/public/js/util.js +44 -0
- package/server.js +20 -0
package/lib/config.js
CHANGED
|
@@ -75,6 +75,12 @@ const DEFAULTS = {
|
|
|
75
75
|
// Launch button when the user doesn't override.
|
|
76
76
|
clis: DEFAULT_CLIS,
|
|
77
77
|
defaultCliId: 'claude',
|
|
78
|
+
// Devtunnel state. tunnelId holds the persistent (named) tunnel
|
|
79
|
+
// ccsm minted via `devtunnel create` on first Start. Reusing it
|
|
80
|
+
// across host restarts keeps the public URL — and therefore the
|
|
81
|
+
// remote browsers' approval records — stable. `devtunnel delete <id>`
|
|
82
|
+
// is invoked when the user explicitly rotates via the Reset button.
|
|
83
|
+
devtunnel: { tunnelId: null },
|
|
78
84
|
};
|
|
79
85
|
|
|
80
86
|
function ensureDataDir() {
|
|
@@ -106,6 +112,9 @@ migrateLegacyDataIfNeeded();
|
|
|
106
112
|
// object so callers don't mutate DEFAULTS.
|
|
107
113
|
function mergeWithDefaults(partial) {
|
|
108
114
|
const out = { ...DEFAULTS, ...partial };
|
|
115
|
+
// Deep-merge devtunnel so a partial save (just .tunnelId) doesn't
|
|
116
|
+
// wipe sibling keys we may add later.
|
|
117
|
+
out.devtunnel = { ...DEFAULTS.devtunnel, ...(partial?.devtunnel || {}) };
|
|
109
118
|
// Drop v0.x keys that the new architecture doesn't use.
|
|
110
119
|
delete out.terminal;
|
|
111
120
|
delete out.commandShell;
|
package/lib/tunnel.js
CHANGED
|
@@ -22,6 +22,7 @@ const path = require('node:path');
|
|
|
22
22
|
const fs = require('node:fs');
|
|
23
23
|
const os = require('node:os');
|
|
24
24
|
const { promisify } = require('node:util');
|
|
25
|
+
const { loadConfig, saveConfig } = require('./config');
|
|
25
26
|
const execFileP = promisify(execFile);
|
|
26
27
|
|
|
27
28
|
const PROVIDERS = {
|
|
@@ -46,7 +47,21 @@ const PROVIDERS = {
|
|
|
46
47
|
path.join(process.env['LOCALAPPDATA'] || '', 'Microsoft', 'WinGet', 'Packages',
|
|
47
48
|
'Microsoft.devtunnel_Microsoft.Winget.Source_8wekyb3d8bbwe', 'devtunnel.exe'),
|
|
48
49
|
],
|
|
49
|
-
args: (port
|
|
50
|
+
args: (port, opts = {}) => {
|
|
51
|
+
// With a persistent (named) tunnel, ports + anonymous access are
|
|
52
|
+
// configured ahead of time via `devtunnel port create` and
|
|
53
|
+
// `devtunnel access create` (see ensureDevtunnelTunnelId +
|
|
54
|
+
// configureDevtunnelTunnel). The host call then takes ONLY the
|
|
55
|
+
// tunnel id — passing -p or --allow-anonymous here makes the CLI
|
|
56
|
+
// try to batch-update the existing tunnel and the service rejects
|
|
57
|
+
// it ("Batch update of ports is not supported"). Anonymous +
|
|
58
|
+
// ephemeral mode keeps the legacy flags as a fallback when no
|
|
59
|
+
// tunnel id has been minted.
|
|
60
|
+
if (opts.tunnelId) {
|
|
61
|
+
return ['host', opts.tunnelId];
|
|
62
|
+
}
|
|
63
|
+
return ['host', '-p', String(port), '--allow-anonymous'];
|
|
64
|
+
},
|
|
50
65
|
// devtunnel sometimes prints two URL forms for the same tunnel:
|
|
51
66
|
// https://<id>.<region>.devtunnels.ms:<port> ← port as suffix
|
|
52
67
|
// https://<id>-<port>.<region>.devtunnels.ms ← port baked into
|
|
@@ -128,6 +143,115 @@ async function checkDevtunnelLogin(exe) {
|
|
|
128
143
|
}
|
|
129
144
|
}
|
|
130
145
|
|
|
146
|
+
// Mint (or read back) a persistent devtunnel id and stash it in
|
|
147
|
+
// config.devtunnel.tunnelId so subsequent `devtunnel host` invocations
|
|
148
|
+
// reuse the same public URL.
|
|
149
|
+
//
|
|
150
|
+
// Why: every `devtunnel host` without a tunnel id allocates a fresh
|
|
151
|
+
// random id, which means a fresh subdomain and therefore a fresh
|
|
152
|
+
// browser origin on the remote side. localStorage is per-origin, so
|
|
153
|
+
// approved device ids get orphaned and the remote user has to re-
|
|
154
|
+
// register from scratch on every tunnel restart.
|
|
155
|
+
//
|
|
156
|
+
// Behaviour:
|
|
157
|
+
// - If config already has a tunnelId, return it verbatim. We do NOT
|
|
158
|
+
// validate it against `devtunnel list` here — the host child will
|
|
159
|
+
// fail loudly if the id was deleted from another machine, and
|
|
160
|
+
// callers can drop it and retry by deleting config.devtunnel.
|
|
161
|
+
// - Otherwise call `devtunnel create --json`, capture the .tunnelId
|
|
162
|
+
// from the response, persist it, return it.
|
|
163
|
+
// - If the create call fails for any reason, return null and let
|
|
164
|
+
// start() fall back to a temporary tunnel — degraded but working.
|
|
165
|
+
async function ensureDevtunnelTunnelId(exe) {
|
|
166
|
+
try {
|
|
167
|
+
const cfg = await loadConfig();
|
|
168
|
+
if (cfg?.devtunnel?.tunnelId) return cfg.devtunnel.tunnelId;
|
|
169
|
+
const { stdout } = await execFileP(exe, ['create', '--json'], {
|
|
170
|
+
windowsHide: true,
|
|
171
|
+
timeout: 20_000,
|
|
172
|
+
});
|
|
173
|
+
let id = null;
|
|
174
|
+
try {
|
|
175
|
+
const j = JSON.parse(String(stdout));
|
|
176
|
+
// The CLI's JSON shape varies a bit by version; the canonical
|
|
177
|
+
// field is `tunnelId` but older builds nest it under `tunnel`.
|
|
178
|
+
id = j.tunnelId || j.tunnel?.tunnelId || j.id || null;
|
|
179
|
+
} catch {
|
|
180
|
+
// Fall back to scraping the plain-text output for `Tunnel ID: foo`
|
|
181
|
+
const m = String(stdout).match(/Tunnel ID:\s*(\S+)/i);
|
|
182
|
+
if (m) id = m[1];
|
|
183
|
+
}
|
|
184
|
+
if (!id) return null;
|
|
185
|
+
// Persist for next time. Swallow save errors — worst case the next
|
|
186
|
+
// start re-allocates one.
|
|
187
|
+
try { await saveConfig({ devtunnel: { tunnelId: id } }); }
|
|
188
|
+
catch (e) { console.warn('[tunnel] persist devtunnel id failed:', e.message); }
|
|
189
|
+
return id;
|
|
190
|
+
} catch (e) {
|
|
191
|
+
console.warn('[tunnel] ensureDevtunnelTunnelId failed:', e.message);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Bring a named tunnel into shape for hosting: make sure the port is
|
|
197
|
+
// in its port list and anonymous access is allowed. Idempotent — the
|
|
198
|
+
// "already exists" errors from `port create` / `access create` are
|
|
199
|
+
// silently absorbed. Required because `devtunnel host <id>` (which
|
|
200
|
+
// we use for persistent tunnels) doesn't accept -p / --allow-anonymous
|
|
201
|
+
// flags after the tunnel exists — passing them triggers the service
|
|
202
|
+
// to reject the call with "Batch update of ports is not supported".
|
|
203
|
+
async function configureDevtunnelTunnel(exe, tunnelId, port) {
|
|
204
|
+
if (!tunnelId) return;
|
|
205
|
+
// Add port. If it already exists the CLI prints an error; swallow it.
|
|
206
|
+
try {
|
|
207
|
+
await execFileP(exe, ['port', 'create', tunnelId, '-p', String(port)], {
|
|
208
|
+
windowsHide: true,
|
|
209
|
+
timeout: 10_000,
|
|
210
|
+
});
|
|
211
|
+
} catch (e) {
|
|
212
|
+
// Only surface failures that aren't "already exists" — those mean
|
|
213
|
+
// something is genuinely broken (auth lost, wrong tunnel id, etc.).
|
|
214
|
+
const msg = String(e.stderr || e.stdout || e.message || '');
|
|
215
|
+
if (!/already/i.test(msg)) {
|
|
216
|
+
console.warn('[tunnel] devtunnel port create failed:', msg.slice(0, 200));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// Add anonymous access. Same idempotency story.
|
|
220
|
+
try {
|
|
221
|
+
await execFileP(exe, ['access', 'create', tunnelId, '-a'], {
|
|
222
|
+
windowsHide: true,
|
|
223
|
+
timeout: 10_000,
|
|
224
|
+
});
|
|
225
|
+
} catch (e) {
|
|
226
|
+
const msg = String(e.stderr || e.stdout || e.message || '');
|
|
227
|
+
if (!/already/i.test(msg)) {
|
|
228
|
+
console.warn('[tunnel] devtunnel access create failed:', msg.slice(0, 200));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Delete the persisted devtunnel id (and the remote tunnel resource
|
|
234
|
+
// itself, best-effort). Used by the Reset affordance in the Remote
|
|
235
|
+
// page when the user wants to rotate the public URL.
|
|
236
|
+
async function resetDevtunnelTunnelId() {
|
|
237
|
+
let prevId = null;
|
|
238
|
+
try {
|
|
239
|
+
const cfg = await loadConfig();
|
|
240
|
+
prevId = cfg?.devtunnel?.tunnelId || null;
|
|
241
|
+
} catch {}
|
|
242
|
+
try { await saveConfig({ devtunnel: { tunnelId: null } }); } catch {}
|
|
243
|
+
if (prevId) {
|
|
244
|
+
const exe = await findBinary('devtunnel');
|
|
245
|
+
if (exe) {
|
|
246
|
+
// Detach from the tunnel resource on the Azure side too; failure
|
|
247
|
+
// is fine (resource may already be gone). 5s cap.
|
|
248
|
+
try { await execFileP(exe, ['delete', prevId, '-f'], { windowsHide: true, timeout: 5_000 }); }
|
|
249
|
+
catch { /* ignore */ }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return { previousTunnelId: prevId };
|
|
253
|
+
}
|
|
254
|
+
|
|
131
255
|
// Probe is cached. Each cold call shells out 4-6 sync execs (where.exe,
|
|
132
256
|
// --version per provider, `devtunnel user show`) — cumulatively ~1s of
|
|
133
257
|
// blocked event loop on Windows. The Remote page polls /api/tunnel/status
|
|
@@ -178,6 +302,10 @@ async function status() {
|
|
|
178
302
|
pid: current?.child?.pid || null,
|
|
179
303
|
log: current?.log?.slice(-50) || [],
|
|
180
304
|
token,
|
|
305
|
+
tunnelId: current?.tunnelId || (await (async () => {
|
|
306
|
+
try { return (await loadConfig())?.devtunnel?.tunnelId || null; }
|
|
307
|
+
catch { return null; }
|
|
308
|
+
})()),
|
|
181
309
|
// Token is echoed back so the Remote page can render the
|
|
182
310
|
// pre-built share URL. The route itself is token-protected
|
|
183
311
|
// (the middleware blocks non-loopback callers without it), so
|
|
@@ -342,9 +470,26 @@ async function start({ provider, port }) {
|
|
|
342
470
|
if (!loggedIn) throw new Error('devtunnel requires login — run `devtunnel user login` first');
|
|
343
471
|
}
|
|
344
472
|
|
|
345
|
-
|
|
473
|
+
// Resolve devtunnel's persistent id BEFORE building args so the
|
|
474
|
+
// CLI is invoked with `host <id>` and the public URL stays stable
|
|
475
|
+
// across restarts. Cloudflared has no equivalent here — quick
|
|
476
|
+
// tunnels always rotate URLs and named tunnels require a CF
|
|
477
|
+
// account + DNS setup that's outside ccsm's scope.
|
|
478
|
+
let tunnelId = null;
|
|
479
|
+
if (provider === 'devtunnel') {
|
|
480
|
+
tunnelId = await ensureDevtunnelTunnelId(exe);
|
|
481
|
+
if (tunnelId) {
|
|
482
|
+
// Make sure the port is in the tunnel's port list and anonymous
|
|
483
|
+
// access is enabled. `devtunnel host <id>` doesn't accept port /
|
|
484
|
+
// access flags after the tunnel exists, so this has to be done
|
|
485
|
+
// out of band before host.
|
|
486
|
+
await configureDevtunnelTunnel(exe, tunnelId, port);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const args = p.args(port, { tunnelId });
|
|
346
491
|
const child = spawn(exe, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
347
|
-
const entry = { provider, child, url: null, startedAt: Date.now(), log: [] };
|
|
492
|
+
const entry = { provider, child, url: null, startedAt: Date.now(), log: [], tunnelId };
|
|
348
493
|
current = entry;
|
|
349
494
|
|
|
350
495
|
const pushLog = (line) => {
|
|
@@ -420,4 +565,5 @@ module.exports = {
|
|
|
420
565
|
cancelDevtunnelLogin,
|
|
421
566
|
clearDevtunnelLogin,
|
|
422
567
|
invalidateProbe,
|
|
568
|
+
resetDevtunnelTunnelId,
|
|
423
569
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.4",
|
|
4
4
|
"description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
package/public/css/terminals.css
CHANGED
|
@@ -494,3 +494,93 @@
|
|
|
494
494
|
font-size: 11.5px !important;
|
|
495
495
|
color: rgba(232, 227, 213, 0.45) !important;
|
|
496
496
|
}
|
|
497
|
+
|
|
498
|
+
/* ─── Mobile terminal accessory bar (TerminalKeyBar.js) ───────────────
|
|
499
|
+
Floats just above the soft keyboard via a JS-set `bottom` offset
|
|
500
|
+
(visualViewport keyboard height). Styled against the dark terminal
|
|
501
|
+
palette — it visually belongs to the terminal, not the cream chrome —
|
|
502
|
+
so it reads as one surface with the xterm canvas above it. */
|
|
503
|
+
.term-keybar {
|
|
504
|
+
position: fixed;
|
|
505
|
+
left: 0;
|
|
506
|
+
right: 0;
|
|
507
|
+
z-index: 215; /* above the mobile FAB (210) */
|
|
508
|
+
background: #201d19;
|
|
509
|
+
border-top: 1px solid rgba(232, 227, 213, 0.12);
|
|
510
|
+
padding: 6px 8px;
|
|
511
|
+
touch-action: manipulation; /* kill the 300ms double-tap-zoom delay */
|
|
512
|
+
user-select: none;
|
|
513
|
+
-webkit-user-select: none;
|
|
514
|
+
/* NOT overflow:auto here — that would clip the Ctrl popover (which sits
|
|
515
|
+
at bottom:100%, above the bar). The horizontal scroll lives on the
|
|
516
|
+
inner .term-keybar-row instead. */
|
|
517
|
+
}
|
|
518
|
+
/* Inner scroll row — holds the keys; scrolls horizontally if they don't
|
|
519
|
+
fit, without clipping the popover that escapes the bar upward. */
|
|
520
|
+
.term-keybar-row {
|
|
521
|
+
display: flex;
|
|
522
|
+
gap: 6px;
|
|
523
|
+
align-items: center;
|
|
524
|
+
overflow-x: auto;
|
|
525
|
+
-webkit-overflow-scrolling: touch;
|
|
526
|
+
white-space: nowrap;
|
|
527
|
+
}
|
|
528
|
+
.term-keybar-row::-webkit-scrollbar { display: none; }
|
|
529
|
+
|
|
530
|
+
.tkb-key {
|
|
531
|
+
flex: 0 0 auto;
|
|
532
|
+
min-width: 42px;
|
|
533
|
+
height: 38px;
|
|
534
|
+
display: inline-flex;
|
|
535
|
+
align-items: center;
|
|
536
|
+
justify-content: center;
|
|
537
|
+
padding: 0 12px;
|
|
538
|
+
font-family: var(--mono);
|
|
539
|
+
font-size: 13px;
|
|
540
|
+
line-height: 1;
|
|
541
|
+
color: #e8e3d5;
|
|
542
|
+
background: rgba(232, 227, 213, 0.06);
|
|
543
|
+
border: 1px solid rgba(232, 227, 213, 0.14);
|
|
544
|
+
border-radius: 8px;
|
|
545
|
+
touch-action: manipulation;
|
|
546
|
+
-webkit-tap-highlight-color: transparent;
|
|
547
|
+
}
|
|
548
|
+
.tkb-key:active,
|
|
549
|
+
.tkb-key.is-active {
|
|
550
|
+
background: rgba(232, 227, 213, 0.20);
|
|
551
|
+
border-color: rgba(232, 227, 213, 0.32);
|
|
552
|
+
}
|
|
553
|
+
.tkb-arrow { padding: 0 10px; }
|
|
554
|
+
.tkb-arrow svg { width: 18px; height: 18px; }
|
|
555
|
+
/* S-Tab carries a multi-char label — let it size to content. */
|
|
556
|
+
.tkb-wide { padding: 0 12px; }
|
|
557
|
+
/* The ↵ glyph renders a touch small in the mono stack; bump it so it
|
|
558
|
+
matches the arrow icons' optical weight. */
|
|
559
|
+
.tkb-glyph { font-size: 17px; line-height: 1; }
|
|
560
|
+
|
|
561
|
+
/* Ctrl combos — a wrap grid that pops ABOVE the bar (bottom:100%). */
|
|
562
|
+
.term-keybar-pop {
|
|
563
|
+
position: absolute;
|
|
564
|
+
bottom: 100%;
|
|
565
|
+
left: 8px;
|
|
566
|
+
right: 8px;
|
|
567
|
+
z-index: 1; /* above the key row inside the bar's context */
|
|
568
|
+
margin-bottom: 6px;
|
|
569
|
+
display: grid;
|
|
570
|
+
grid-template-columns: repeat(5, 1fr);
|
|
571
|
+
gap: 6px;
|
|
572
|
+
padding: 8px;
|
|
573
|
+
background: #201d19;
|
|
574
|
+
border: 1px solid rgba(232, 227, 213, 0.16);
|
|
575
|
+
border-radius: 10px;
|
|
576
|
+
box-shadow: 0 -8px 24px -8px rgba(0, 0, 0, 0.5);
|
|
577
|
+
}
|
|
578
|
+
.tkb-combo {
|
|
579
|
+
flex-direction: column;
|
|
580
|
+
height: auto;
|
|
581
|
+
min-width: 0;
|
|
582
|
+
padding: 7px 4px;
|
|
583
|
+
gap: 2px;
|
|
584
|
+
}
|
|
585
|
+
.tkb-combo-label { font-family: var(--mono); font-size: 13px; color: #e8e3d5; }
|
|
586
|
+
.tkb-combo-hint { font-size: 9.5px; color: rgba(232, 227, 213, 0.5); letter-spacing: 0.01em; }
|
package/public/css/widgets.css
CHANGED
|
@@ -1774,6 +1774,48 @@
|
|
|
1774
1774
|
.provider-status-switch { margin-left: auto; }
|
|
1775
1775
|
.provider-status-signin { margin-left: auto; }
|
|
1776
1776
|
|
|
1777
|
+
/* DevtunnelTunnelIdRow — shown under the signed-in Microsoft Dev
|
|
1778
|
+
Tunnel status. The tunnel id is the persistent identifier that
|
|
1779
|
+
keeps the public URL stable across `devtunnel host` restarts;
|
|
1780
|
+
surfaced so the user knows what's being reused and can rotate it
|
|
1781
|
+
when they want fresh credentials. */
|
|
1782
|
+
.tunnel-id-row {
|
|
1783
|
+
display: flex;
|
|
1784
|
+
align-items: center;
|
|
1785
|
+
flex-wrap: wrap;
|
|
1786
|
+
gap: var(--s-2);
|
|
1787
|
+
margin-top: var(--s-2);
|
|
1788
|
+
padding: 6px 10px;
|
|
1789
|
+
border: 1px solid var(--border-soft);
|
|
1790
|
+
border-radius: 6px;
|
|
1791
|
+
background: var(--bg);
|
|
1792
|
+
font-size: 12px;
|
|
1793
|
+
color: var(--ink-mid);
|
|
1794
|
+
}
|
|
1795
|
+
.tunnel-id-row.is-empty { color: var(--ink-muted); }
|
|
1796
|
+
.tunnel-id-label {
|
|
1797
|
+
font-size: 10.5px;
|
|
1798
|
+
letter-spacing: 0.16em;
|
|
1799
|
+
text-transform: uppercase;
|
|
1800
|
+
font-weight: 600;
|
|
1801
|
+
color: var(--ink-muted);
|
|
1802
|
+
}
|
|
1803
|
+
.tunnel-id-value {
|
|
1804
|
+
font-family: var(--mono);
|
|
1805
|
+
font-size: 12px;
|
|
1806
|
+
color: var(--ink);
|
|
1807
|
+
user-select: all;
|
|
1808
|
+
padding: 1px 6px;
|
|
1809
|
+
border-radius: 4px;
|
|
1810
|
+
background: var(--bg-elev);
|
|
1811
|
+
border: 1px solid var(--border-soft);
|
|
1812
|
+
}
|
|
1813
|
+
.tunnel-id-value-empty {
|
|
1814
|
+
font-style: italic;
|
|
1815
|
+
color: var(--ink-muted);
|
|
1816
|
+
}
|
|
1817
|
+
.tunnel-id-reset { margin-left: auto; }
|
|
1818
|
+
|
|
1777
1819
|
/* ── Tunnel section: hero (idle) + live banner (running) ──────────
|
|
1778
1820
|
Idle: a single horizontal card with the headline + start CTA.
|
|
1779
1821
|
Running: a status banner (state · provider · uptime · stop link)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Mobile-only terminal accessory bar. The soft keyboard has no Esc / Tab /
|
|
2
|
+
// arrows / Ctrl, which are exactly the keys claude & codex TUIs lean on
|
|
3
|
+
// (menu nav, cancel, autocomplete, interrupt). We float a row of those
|
|
4
|
+
// keys just above the soft keyboard — the same "extra-keys row" pattern
|
|
5
|
+
// Termux / Blink / Termius all settled on.
|
|
6
|
+
//
|
|
7
|
+
// Web has no native keyboard-accessory API, so the bar is a position:fixed
|
|
8
|
+
// element anchored to the top of the keyboard via the visualViewport API:
|
|
9
|
+
// when the keyboard opens, visualViewport.height shrinks and the gap below
|
|
10
|
+
// it (window.innerHeight − vv.height) is the keyboard's height; we park the
|
|
11
|
+
// bar at that offset.
|
|
12
|
+
//
|
|
13
|
+
// Every button MUST preventDefault on pointerdown — otherwise tapping it
|
|
14
|
+
// blurs the terminal's hidden textarea, which dismisses the soft keyboard
|
|
15
|
+
// (and would hide this bar). preventDefault keeps focus on the textarea so
|
|
16
|
+
// the keyboard stays up and we just inject the escape sequence over the WS.
|
|
17
|
+
|
|
18
|
+
import { html } from '../html.js';
|
|
19
|
+
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
20
|
+
import { isMobile } from '../state.js';
|
|
21
|
+
import { IconChevronUp, IconChevronDown, IconChevronLeft, IconChevronRight } from '../icons.js';
|
|
22
|
+
|
|
23
|
+
// Ctrl+<letter> is the letter's code & 0x1f. Pre-computed for the combos
|
|
24
|
+
// that actually come up in a REPL / TUI session.
|
|
25
|
+
const CTRL_COMBOS = [
|
|
26
|
+
{ label: '^C', data: '\x03', hint: 'interrupt' },
|
|
27
|
+
{ label: '^D', data: '\x04', hint: 'EOF' },
|
|
28
|
+
{ label: '^Z', data: '\x1a', hint: 'suspend' },
|
|
29
|
+
{ label: '^R', data: '\x12', hint: 'rev-search' },
|
|
30
|
+
{ label: '^L', data: '\x0c', hint: 'clear' },
|
|
31
|
+
{ label: '^A', data: '\x01', hint: 'line start' },
|
|
32
|
+
{ label: '^E', data: '\x05', hint: 'line end' },
|
|
33
|
+
{ label: '^U', data: '\x15', hint: 'kill line' },
|
|
34
|
+
{ label: '^K', data: '\x0b', hint: 'kill to end' },
|
|
35
|
+
{ label: '^W', data: '\x17', hint: 'kill word' },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export function TerminalKeyBar({ send, cliType }) {
|
|
39
|
+
if (!isMobile.value) return null;
|
|
40
|
+
const [visible, setVisible] = useState(false);
|
|
41
|
+
const [kbOffset, setKbOffset] = useState(0);
|
|
42
|
+
const [ctrlOpen, setCtrlOpen] = useState(false);
|
|
43
|
+
const gesture = useRef({ x: 0, y: 0, id: null, moved: false });
|
|
44
|
+
|
|
45
|
+
// Show only while the terminal textarea holds focus (i.e. keyboard up).
|
|
46
|
+
// Buttons preventDefault so they never steal focus → no spurious blur
|
|
47
|
+
// while the bar is in use; focusout only fires on a genuine dismissal.
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const inTerm = (el) => !!(el && el.closest && el.closest('.terminal-host'));
|
|
50
|
+
const onFocusIn = (e) => { if (inTerm(e.target)) setVisible(true); };
|
|
51
|
+
const onFocusOut = () => {
|
|
52
|
+
// Defer one tick so document.activeElement settles to the new target.
|
|
53
|
+
setTimeout(() => {
|
|
54
|
+
if (!inTerm(document.activeElement)) { setVisible(false); setCtrlOpen(false); }
|
|
55
|
+
}, 0);
|
|
56
|
+
};
|
|
57
|
+
document.addEventListener('focusin', onFocusIn);
|
|
58
|
+
document.addEventListener('focusout', onFocusOut);
|
|
59
|
+
return () => {
|
|
60
|
+
document.removeEventListener('focusin', onFocusIn);
|
|
61
|
+
document.removeEventListener('focusout', onFocusOut);
|
|
62
|
+
};
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
// Track the keyboard's top edge. window.innerHeight stays constant when
|
|
66
|
+
// the soft keyboard opens (both iOS Safari & Android Chrome with the
|
|
67
|
+
// default resizes-visual behaviour); vv.height shrinks. The difference
|
|
68
|
+
// is the keyboard height → the bar's distance from the layout-viewport
|
|
69
|
+
// bottom.
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const vv = window.visualViewport;
|
|
72
|
+
if (!vv) return;
|
|
73
|
+
const sync = () => setKbOffset(Math.max(0, window.innerHeight - vv.height - vv.offsetTop));
|
|
74
|
+
sync();
|
|
75
|
+
vv.addEventListener('resize', sync);
|
|
76
|
+
vv.addEventListener('scroll', sync);
|
|
77
|
+
return () => { vv.removeEventListener('resize', sync); vv.removeEventListener('scroll', sync); };
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
if (!visible) return null;
|
|
81
|
+
|
|
82
|
+
// Insert-newline (composer multi-line). Mirrors TerminalView's Shift/Ctrl
|
|
83
|
+
// +Enter handler: claude's prompt parses a bare LF as insert-newline;
|
|
84
|
+
// crossterm-based TUIs (codex/copilot) take ESC+CR i.e. Alt+Enter. This
|
|
85
|
+
// is the ONLY way to add a newline on a soft keyboard whose Enter submits.
|
|
86
|
+
const newlineData = cliType === 'claude' ? '\n' : '\x1b\r';
|
|
87
|
+
|
|
88
|
+
// Tap-vs-drag discrimination. The key row scrolls horizontally
|
|
89
|
+
// (overflow-x), so a swipe to scroll it starts on a button — firing the
|
|
90
|
+
// key on pointerdown meant every scroll-drag injected a keystroke. Track
|
|
91
|
+
// the pointer from down→up and only fire if it stayed put (a real tap).
|
|
92
|
+
// One pointer at a time on a touch bar, so a single shared ref is enough.
|
|
93
|
+
//
|
|
94
|
+
// preventDefault on pointerdown keeps the terminal's textarea focused (the
|
|
95
|
+
// button never grabs focus) so the soft keyboard stays up. Scrolling is
|
|
96
|
+
// governed by touch-action, not this preventDefault, so the row still pans.
|
|
97
|
+
const DRAG_PX = 8;
|
|
98
|
+
const onDown = (e) => {
|
|
99
|
+
gesture.current = { x: e.clientX, y: e.clientY, id: e.pointerId, moved: false };
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
};
|
|
102
|
+
const onMove = (e) => {
|
|
103
|
+
const g = gesture.current;
|
|
104
|
+
if (g.id !== e.pointerId || g.moved) return;
|
|
105
|
+
if (Math.hypot(e.clientX - g.x, e.clientY - g.y) > DRAG_PX) g.moved = true;
|
|
106
|
+
};
|
|
107
|
+
const onCancel = () => { gesture.current.moved = true; };
|
|
108
|
+
// Fire on release, but only for a tap (no drag) and the same pointer.
|
|
109
|
+
const keyProps = (fn) => ({
|
|
110
|
+
onPointerDown: onDown,
|
|
111
|
+
onPointerMove: onMove,
|
|
112
|
+
onPointerCancel: onCancel,
|
|
113
|
+
onPointerUp: (e) => {
|
|
114
|
+
const g = gesture.current;
|
|
115
|
+
if (g.id !== e.pointerId || g.moved) return;
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
fn();
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
const sendKey = (data) => keyProps(() => send(data));
|
|
121
|
+
const ctrlCombo = (data) => keyProps(() => { send(data); setCtrlOpen(false); });
|
|
122
|
+
|
|
123
|
+
return html`
|
|
124
|
+
<div class="term-keybar" style=${`bottom:${kbOffset}px`}>
|
|
125
|
+
${ctrlOpen ? html`
|
|
126
|
+
<div class="term-keybar-pop">
|
|
127
|
+
${CTRL_COMBOS.map((c) => html`
|
|
128
|
+
<button class="tkb-key tkb-combo" key=${c.label}
|
|
129
|
+
...${ctrlCombo(c.data)} title=${c.hint}>
|
|
130
|
+
<span class="tkb-combo-label">${c.label}</span>
|
|
131
|
+
<span class="tkb-combo-hint">${c.hint}</span>
|
|
132
|
+
</button>`)}
|
|
133
|
+
</div>` : null}
|
|
134
|
+
|
|
135
|
+
<div class="term-keybar-row">
|
|
136
|
+
<button class=${`tkb-key${ctrlOpen ? ' is-active' : ''}`}
|
|
137
|
+
...${keyProps(() => setCtrlOpen((v) => !v))}>Ctrl</button>
|
|
138
|
+
<button class="tkb-key" ...${sendKey('\x1b')}>Esc</button>
|
|
139
|
+
<button class="tkb-key" ...${sendKey('\t')}>Tab</button>
|
|
140
|
+
<button class="tkb-key tkb-wide" ...${sendKey('\x1b[Z')}>S-Tab</button>
|
|
141
|
+
<button class="tkb-key tkb-arrow" ...${sendKey(newlineData)} aria-label="newline"><span class="tkb-glyph">↵</span></button>
|
|
142
|
+
<button class="tkb-key tkb-arrow" ...${sendKey('\x1b[A')} aria-label="up"><${IconChevronUp} /></button>
|
|
143
|
+
<button class="tkb-key tkb-arrow" ...${sendKey('\x1b[B')} aria-label="down"><${IconChevronDown} /></button>
|
|
144
|
+
<button class="tkb-key tkb-arrow" ...${sendKey('\x1b[D')} aria-label="left"><${IconChevronLeft} /></button>
|
|
145
|
+
<button class="tkb-key tkb-arrow" ...${sendKey('\x1b[C')} aria-label="right"><${IconChevronRight} /></button>
|
|
146
|
+
</div>
|
|
147
|
+
</div>`;
|
|
148
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// output frames into xterm. Disposes everything on unmount or id change.
|
|
4
4
|
|
|
5
5
|
import { html } from '../html.js';
|
|
6
|
+
import { Fragment } from 'preact';
|
|
6
7
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
7
8
|
import { Terminal } from '@xterm/xterm';
|
|
8
9
|
import { FitAddon } from '@xterm/addon-fit';
|
|
@@ -10,6 +11,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links';
|
|
|
10
11
|
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
|
11
12
|
import { WebglAddon } from '@xterm/addon-webgl';
|
|
12
13
|
import { wsBase, getToken, getDeviceId } from '../backend.js';
|
|
14
|
+
import { TerminalKeyBar } from './TerminalKeyBar.js';
|
|
13
15
|
|
|
14
16
|
// Dark xterm theme. We give the terminal a near-black ink background to
|
|
15
17
|
// match what claude code's TUI assumes (it paints its own input box +
|
|
@@ -44,6 +46,13 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
44
46
|
const [displaced, setDisplaced] = useState(false);
|
|
45
47
|
const [reattachNonce, setReattach] = useState(0);
|
|
46
48
|
|
|
49
|
+
// Raw escape-sequence injector for the mobile key bar. Reads wsRef at
|
|
50
|
+
// call time so it stays valid across reattaches without re-binding.
|
|
51
|
+
const sendInput = (data) => {
|
|
52
|
+
const ws = wsRef.current;
|
|
53
|
+
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data }));
|
|
54
|
+
};
|
|
55
|
+
|
|
47
56
|
useEffect(() => {
|
|
48
57
|
if (!terminalId || !hostRef.current) return;
|
|
49
58
|
|
|
@@ -128,8 +137,26 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
128
137
|
|
|
129
138
|
const host = hostRef.current;
|
|
130
139
|
term.open(host);
|
|
131
|
-
//
|
|
132
|
-
|
|
140
|
+
// Robust fit scheduler. A single requestAnimationFrame works most
|
|
141
|
+
// of the time but races on tab/session switches: the .tab-panel
|
|
142
|
+
// just flipped from display:none to display:flex and although the
|
|
143
|
+
// browser has laid the element out by the next frame, xterm's
|
|
144
|
+
// canvas measurement occasionally still reports the pre-display
|
|
145
|
+
// size (Chromium quirk — the WebGL renderer caches its viewport
|
|
146
|
+
// before the layout flush propagates through ResizeObserver).
|
|
147
|
+
// Result: visible "wrong cols/rows until I resize the window" bug.
|
|
148
|
+
// Spraying fits at 0 / one rAF / 60ms / 200ms covers every
|
|
149
|
+
// measurement-arrival path without being expensive — fit.fit() is
|
|
150
|
+
// a no-op when cols/rows match the previous call.
|
|
151
|
+
const scheduleFit = () => {
|
|
152
|
+
try { fit.fit(); } catch {}
|
|
153
|
+
requestAnimationFrame(() => {
|
|
154
|
+
try { fit.fit(); } catch {}
|
|
155
|
+
setTimeout(() => { try { fit.fit(); } catch {} }, 60);
|
|
156
|
+
setTimeout(() => { try { fit.fit(); } catch {} }, 200);
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
scheduleFit();
|
|
133
160
|
termRef.current = term;
|
|
134
161
|
|
|
135
162
|
// Browser WS API can't set Authorization headers — token + device
|
|
@@ -154,7 +181,7 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
154
181
|
// wrapped at 80 cols, and the follow-up resize from the rAF fit
|
|
155
182
|
// wouldn't reflow the already-emitted bytes. Visible as squeezed
|
|
156
183
|
// text on every session switch.
|
|
157
|
-
|
|
184
|
+
scheduleFit();
|
|
158
185
|
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
159
186
|
};
|
|
160
187
|
ws.onmessage = (ev) => {
|
|
@@ -193,6 +220,20 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
193
220
|
const ro = new ResizeObserver(() => { try { fit.fit(); } catch {} });
|
|
194
221
|
ro.observe(hostRef.current);
|
|
195
222
|
|
|
223
|
+
// Mobile soft-keyboard resize. When the IME slides up on iOS /
|
|
224
|
+
// Android, the layout viewport doesn't change but `visualViewport`
|
|
225
|
+
// does — the page now has less vertical room before the keyboard
|
|
226
|
+
// covers the bottom. xterm's host element keeps its old layout
|
|
227
|
+
// height (we use 100vh-derived sizing) so half the terminal sits
|
|
228
|
+
// behind the keyboard with no resize callback fired. Listening
|
|
229
|
+
// here covers it: any visualViewport size change triggers a fit
|
|
230
|
+
// so the cell grid matches the visible area. Cheap; fit.fit() is
|
|
231
|
+
// a no-op when nothing changed.
|
|
232
|
+
const vv = window.visualViewport;
|
|
233
|
+
const onVisualResize = () => scheduleFit();
|
|
234
|
+
vv?.addEventListener?.('resize', onVisualResize);
|
|
235
|
+
vv?.addEventListener?.('scroll', onVisualResize);
|
|
236
|
+
|
|
196
237
|
// Tab-switch refresh. The terminal lives inside a .tab-panel which gets
|
|
197
238
|
// display:none when another tab is active. WebGL renderers keep a glyph
|
|
198
239
|
// texture atlas in GPU memory; when the canvas hides + redisplays at a
|
|
@@ -208,7 +249,7 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
208
249
|
if (panel.hasAttribute('data-active')) {
|
|
209
250
|
requestAnimationFrame(() => {
|
|
210
251
|
try { term.clearTextureAtlas?.(); } catch {}
|
|
211
|
-
|
|
252
|
+
scheduleFit();
|
|
212
253
|
try { term.refresh(0, term.rows - 1); } catch {}
|
|
213
254
|
});
|
|
214
255
|
}
|
|
@@ -368,6 +409,8 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
368
409
|
}
|
|
369
410
|
ro.disconnect();
|
|
370
411
|
if (panelMo) panelMo.disconnect();
|
|
412
|
+
vv?.removeEventListener?.('resize', onVisualResize);
|
|
413
|
+
vv?.removeEventListener?.('scroll', onVisualResize);
|
|
371
414
|
try { ws.close(); } catch {}
|
|
372
415
|
try { term.dispose(); } catch {}
|
|
373
416
|
termRef.current = null;
|
|
@@ -418,5 +461,9 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
418
461
|
</div>
|
|
419
462
|
</section>`;
|
|
420
463
|
}
|
|
421
|
-
return html
|
|
464
|
+
return html`
|
|
465
|
+
<${Fragment}>
|
|
466
|
+
<div key="host" ref=${hostRef} class="terminal-host"></div>
|
|
467
|
+
<${TerminalKeyBar} send=${sendInput} cliType=${cliType} />
|
|
468
|
+
</${Fragment}>`;
|
|
422
469
|
}
|
|
@@ -25,6 +25,22 @@ import { PageTitleBar } from '../components/PageTitleBar.js';
|
|
|
25
25
|
import { EntityFormModal } from '../components/EntityFormModal.js';
|
|
26
26
|
import { useDragSort } from '../components/useDragSort.js';
|
|
27
27
|
import { IconPlus, IconPencil, IconClose, IconTerminal, IconFolder, IconBranch, IconRefresh, IconChevronUp, IconChevronDown, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor } from '../icons.js';
|
|
28
|
+
import { parseArgs, formatArgs } from '../util.js';
|
|
29
|
+
|
|
30
|
+
// Tokenize the three free-form args fields into string[] before they hit
|
|
31
|
+
// the backend. Form values arrive as strings (text inputs) — backend
|
|
32
|
+
// stores arrays. parseArgs handles shell-style quoting so users can type
|
|
33
|
+
// `-Model "claude-opus-4-8"` or `-Path 'C:\some dir\bin'` and get sane
|
|
34
|
+
// argv splitting instead of a literal-quote token.
|
|
35
|
+
function tokenizeCliArgs(v) {
|
|
36
|
+
const tok = (x) => typeof x === 'string' ? parseArgs(x) : x;
|
|
37
|
+
return {
|
|
38
|
+
...v,
|
|
39
|
+
args: tok(v.args),
|
|
40
|
+
resumeIdArgs: tok(v.resumeIdArgs),
|
|
41
|
+
newSessionIdArgs: tok(v.newSessionIdArgs),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
28
44
|
|
|
29
45
|
// Type → smart defaults. Choosing a type in the form auto-fills resumeArgs
|
|
30
46
|
// (and command if blank) so users don't need to remember the per-CLI flag.
|
|
@@ -72,8 +88,8 @@ function cliFieldsFor({ creating } = {}) {
|
|
|
72
88
|
},
|
|
73
89
|
{ key: 'name', label: 'Name', placeholder: 'My CLI', required: true },
|
|
74
90
|
{ key: 'command', label: 'Command', mono: true, placeholder: 'ccp / claude / ...', required: true },
|
|
75
|
-
{ key: 'args', label: 'Args
|
|
76
|
-
hint: 'Used on every launch.' },
|
|
91
|
+
{ key: 'args', label: 'Args', mono: true, placeholder: '',
|
|
92
|
+
hint: 'Used on every launch. Shell-style quoting: -Model "claude-opus-4-8" or -Path \'C:\\some dir\\bin\'.' },
|
|
77
93
|
{ key: 'newSessionIdArgs', label: 'New session id args', mono: true, placeholder: '--session-id <id>',
|
|
78
94
|
// Lock for known types — those args are an integration contract
|
|
79
95
|
// with the upstream CLI, not a user knob. Only Type=Other allows
|
|
@@ -194,7 +210,7 @@ export function ConfigurePage() {
|
|
|
194
210
|
id: c.id,
|
|
195
211
|
icon: html`<${Icon} />`,
|
|
196
212
|
primary: c.name,
|
|
197
|
-
secondary: html`<span class="mono">${c.command}${c.args?.length ? ' ' + c.args
|
|
213
|
+
secondary: html`<span class="mono">${c.command}${c.args?.length ? ' ' + formatArgs(c.args) : ''}</span>${c.shell && c.shell !== 'direct' ? html` · ${c.shell}` : null}`,
|
|
198
214
|
badges: tags,
|
|
199
215
|
undeletable: c.builtin,
|
|
200
216
|
raw: c,
|
|
@@ -291,7 +307,7 @@ export function ConfigurePage() {
|
|
|
291
307
|
onClose=${close} submitLabel="Create"
|
|
292
308
|
onTest=${(v) => testCli({ command: v.command, shell: v.shell, type: v.type })}
|
|
293
309
|
onSubmit=${async (v) => {
|
|
294
|
-
try { await createCli(v); setToast(`created CLI · ${v.name}`); }
|
|
310
|
+
try { await createCli(tokenizeCliArgs(v)); setToast(`created CLI · ${v.name}`); }
|
|
295
311
|
catch (e) { setToast(e.message, 'error'); throw e; }
|
|
296
312
|
}} />` : null}
|
|
297
313
|
|
|
@@ -300,21 +316,15 @@ export function ConfigurePage() {
|
|
|
300
316
|
readOnlyKeys=${edit.payload.builtin ? ['type'] : []}
|
|
301
317
|
initial=${{
|
|
302
318
|
...edit.payload,
|
|
303
|
-
args: (edit.payload.args
|
|
304
|
-
resumeIdArgs: (edit.payload.resumeIdArgs
|
|
305
|
-
newSessionIdArgs: (edit.payload.newSessionIdArgs
|
|
319
|
+
args: formatArgs(edit.payload.args),
|
|
320
|
+
resumeIdArgs: formatArgs(edit.payload.resumeIdArgs),
|
|
321
|
+
newSessionIdArgs: formatArgs(edit.payload.newSessionIdArgs),
|
|
306
322
|
}}
|
|
307
323
|
onClose=${close}
|
|
308
324
|
onTest=${(v) => testCli({ command: v.command, shell: v.shell, type: v.type })}
|
|
309
325
|
onSubmit=${async (v) => {
|
|
310
326
|
try {
|
|
311
|
-
|
|
312
|
-
...v,
|
|
313
|
-
args: typeof v.args === 'string' ? v.args.split(/\s+/).filter(Boolean) : v.args,
|
|
314
|
-
resumeIdArgs: typeof v.resumeIdArgs === 'string' ? v.resumeIdArgs.split(/\s+/).filter(Boolean) : v.resumeIdArgs,
|
|
315
|
-
newSessionIdArgs: typeof v.newSessionIdArgs === 'string' ? v.newSessionIdArgs.split(/\s+/).filter(Boolean) : v.newSessionIdArgs,
|
|
316
|
-
};
|
|
317
|
-
await updateCli(edit.payload.id, patch);
|
|
327
|
+
await updateCli(edit.payload.id, tokenizeCliArgs(v));
|
|
318
328
|
setToast('saved');
|
|
319
329
|
} catch (e) { setToast(e.message, 'error'); throw e; }
|
|
320
330
|
}} />` : null}
|
|
@@ -21,7 +21,32 @@ import { BrandMark, IconTerminal, IconFolder, IconFolderOpen, IconBranch, IconCh
|
|
|
21
21
|
const ROOT_ID = 'newSessionProgress';
|
|
22
22
|
const selectedRepos = signal(new Set());
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// Persist the user's last Launch picks (CLI / folder / mode / cwd /
|
|
25
|
+
// selected repos) so the form stays as they left it across reloads
|
|
26
|
+
// and tab switches. localStorage is best-effort — any access failure
|
|
27
|
+
// falls back silently.
|
|
28
|
+
const LS_KEY = 'ccsm.launch-state';
|
|
29
|
+
function loadLaunchState() {
|
|
30
|
+
try {
|
|
31
|
+
const raw = localStorage.getItem(LS_KEY);
|
|
32
|
+
if (!raw) return null;
|
|
33
|
+
const j = JSON.parse(raw);
|
|
34
|
+
if (j && typeof j === 'object') return j;
|
|
35
|
+
} catch {}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
function saveLaunchState(s) {
|
|
39
|
+
try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function initRepoSelection(repos, override) {
|
|
43
|
+
if (override && Array.isArray(override)) {
|
|
44
|
+
// Only honour names that still exist in the user's repo list;
|
|
45
|
+
// anything else was deleted between sessions.
|
|
46
|
+
const valid = new Set(repos.map((r) => r.name));
|
|
47
|
+
selectedRepos.value = new Set(override.filter((n) => valid.has(n)));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
25
50
|
const want = new Set(repos.filter((r) => r.defaultSelected).map((r) => r.name));
|
|
26
51
|
selectedRepos.value = want;
|
|
27
52
|
}
|
|
@@ -31,11 +56,15 @@ function LaunchHero() {
|
|
|
31
56
|
const clis = cfg.clis || [];
|
|
32
57
|
const repos = cfg.repos || [];
|
|
33
58
|
const defaultCli = cfg.defaultCliId || clis[0]?.id || '';
|
|
59
|
+
const saved = loadLaunchState();
|
|
34
60
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const [
|
|
61
|
+
// Initial values pull from localStorage first (last-used picks),
|
|
62
|
+
// then fall back to config defaults. cliId is validated below in
|
|
63
|
+
// the useEffect once `clis` arrives.
|
|
64
|
+
const [cliId, setCliId] = useState(saved?.cliId || defaultCli);
|
|
65
|
+
const [folderId, setFolderId] = useState(saved?.folderId || '');
|
|
66
|
+
const [mode, setMode] = useState(saved?.mode === 'cwd' ? 'cwd' : 'auto');
|
|
67
|
+
const [cwd, setCwd] = useState(saved?.cwd || ''); // only used when mode === 'cwd'
|
|
39
68
|
const [busy, setBusy] = useState(false);
|
|
40
69
|
const [result, setResult] = useState('');
|
|
41
70
|
const [openPicker, setOpenPicker] = useState(null); // 'cli' | 'folder' | 'workdir' | null
|
|
@@ -50,6 +79,22 @@ function LaunchHero() {
|
|
|
50
79
|
}
|
|
51
80
|
}, [defaultCli, clis.length]);
|
|
52
81
|
|
|
82
|
+
// Validate the persisted folder id against the live folders list
|
|
83
|
+
// — folders deleted between sessions snap back to "no folder".
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!folderId) return;
|
|
86
|
+
if (!folders.value.find((f) => f.id === folderId)) setFolderId('');
|
|
87
|
+
}, [folderId, folders.value.length]);
|
|
88
|
+
|
|
89
|
+
// Persist every change. JSON-stringifying a Set isn't useful, so
|
|
90
|
+
// we materialize selectedRepos to an array here.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
saveLaunchState({
|
|
93
|
+
cliId, folderId, mode, cwd,
|
|
94
|
+
repos: [...selectedRepos.value],
|
|
95
|
+
});
|
|
96
|
+
}, [cliId, folderId, mode, cwd, selectedRepos.value]);
|
|
97
|
+
|
|
53
98
|
const folderDnd = useDragSort(
|
|
54
99
|
folders.value.map((f) => f.id),
|
|
55
100
|
async (nextIds) => {
|
|
@@ -59,7 +104,7 @@ function LaunchHero() {
|
|
|
59
104
|
);
|
|
60
105
|
|
|
61
106
|
const sig = repos.map((r) => r.name + ':' + r.defaultSelected).join('|');
|
|
62
|
-
useStateOnce(sig, () => initRepoSelection(repos));
|
|
107
|
+
useStateOnce(sig, () => initRepoSelection(repos, saved?.repos));
|
|
63
108
|
|
|
64
109
|
const cli = clis.find((c) => c.id === cliId) || clis[0];
|
|
65
110
|
const folder = folders.value.find((f) => f.id === folderId);
|
|
@@ -104,6 +104,32 @@ function ProviderTile({ id, label, hint, icon, selected, disabled, onSelect }) {
|
|
|
104
104
|
</button>`;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
// Tiny inline row shown under the signed-in Microsoft Dev Tunnel
|
|
108
|
+
// status. Displays the persisted (named) tunnel id ccsm reuses across
|
|
109
|
+
// restarts so the public URL stays stable — and lets the user rotate
|
|
110
|
+
// it on demand. Reset requires the tunnel to be stopped first; the
|
|
111
|
+
// server-side route also enforces this.
|
|
112
|
+
function DevtunnelTunnelIdRow({ tunnelId, running, onReset }) {
|
|
113
|
+
if (!tunnelId) {
|
|
114
|
+
return html`
|
|
115
|
+
<div class="tunnel-id-row is-empty">
|
|
116
|
+
<span class="tunnel-id-label">Tunnel id</span>
|
|
117
|
+
<span class="tunnel-id-value-empty">none yet · minted on next Start</span>
|
|
118
|
+
</div>`;
|
|
119
|
+
}
|
|
120
|
+
return html`
|
|
121
|
+
<div class="tunnel-id-row">
|
|
122
|
+
<span class="tunnel-id-label">Tunnel id</span>
|
|
123
|
+
<code class="tunnel-id-value" title="Stable public URL identifier · reused across restarts">${tunnelId}</code>
|
|
124
|
+
<button type="button" class="action subtle small tunnel-id-reset"
|
|
125
|
+
disabled=${running}
|
|
126
|
+
title=${running ? 'Stop the tunnel first' : 'Mint a fresh tunnel id (public URL will change)'}
|
|
127
|
+
onClick=${onReset}>
|
|
128
|
+
<${IconRecycle} /> Reset
|
|
129
|
+
</button>
|
|
130
|
+
</div>`;
|
|
131
|
+
}
|
|
132
|
+
|
|
107
133
|
function ProviderStatus({ id, info, onInstall, onLogin, loggingIn }) {
|
|
108
134
|
if (!info) return html`<span class="provider-status-muted">probing…</span>`;
|
|
109
135
|
if (!info.installed) {
|
|
@@ -261,9 +287,26 @@ function DevtunnelLoginPanel({ login, onCancel, onDismiss, onRetry }) {
|
|
|
261
287
|
|
|
262
288
|
export function RemotePage() {
|
|
263
289
|
clockTick.value; // re-tick fmtAgo "last seen" labels
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
290
|
+
// Hydrate from a localStorage cache so the page renders the same
|
|
291
|
+
// shape it had at the end of the previous visit — provider tiles,
|
|
292
|
+
// signed-in state, tunnel id, share URL — instead of empty / placeholder
|
|
293
|
+
// chrome that fills in after the slow /api/tunnel/status round-trip
|
|
294
|
+
// (700ms+ on a cold probe). The cached snapshot is overwritten by
|
|
295
|
+
// refresh() the moment the live response lands.
|
|
296
|
+
const cachedStatus = (() => {
|
|
297
|
+
try {
|
|
298
|
+
const raw = localStorage.getItem('ccsm.remote-status-cache');
|
|
299
|
+
return raw ? JSON.parse(raw) : null;
|
|
300
|
+
} catch { return null; }
|
|
301
|
+
})();
|
|
302
|
+
const [status, setStatus] = useState(cachedStatus);
|
|
303
|
+
const [provider, setProvider] = useState(() => {
|
|
304
|
+
if (cachedStatus?.running && cachedStatus?.provider) return cachedStatus.provider;
|
|
305
|
+
if (cachedStatus?.providers?.devtunnel?.installed) return 'devtunnel';
|
|
306
|
+
if (cachedStatus?.providers?.cloudflared?.installed) return 'cloudflared';
|
|
307
|
+
return 'devtunnel';
|
|
308
|
+
});
|
|
309
|
+
const [token, setTokenLocal] = useState(cachedStatus?.token || '');
|
|
267
310
|
const [busy, setBusy] = useState(false);
|
|
268
311
|
const [deviceList, setDeviceList] = useState([]);
|
|
269
312
|
const pollRef = useRef(null);
|
|
@@ -280,10 +323,17 @@ export function RemotePage() {
|
|
|
280
323
|
setProvider((cur) => {
|
|
281
324
|
if (s.running && s.provider) return s.provider;
|
|
282
325
|
if (cur) return cur;
|
|
283
|
-
if (s.providers?.cloudflared?.installed) return 'cloudflared';
|
|
284
326
|
if (s.providers?.devtunnel?.installed) return 'devtunnel';
|
|
285
|
-
|
|
327
|
+
if (s.providers?.cloudflared?.installed) return 'cloudflared';
|
|
328
|
+
return cur || 'devtunnel';
|
|
286
329
|
});
|
|
330
|
+
// Snapshot for the next mount. Skip the per-call `log` so the
|
|
331
|
+
// cache stays small.
|
|
332
|
+
try {
|
|
333
|
+
localStorage.setItem('ccsm.remote-status-cache', JSON.stringify({
|
|
334
|
+
...s, log: undefined,
|
|
335
|
+
}));
|
|
336
|
+
} catch {}
|
|
287
337
|
} catch (e) { setToast(`status load failed · ${e.message}`, 'error'); }
|
|
288
338
|
}
|
|
289
339
|
|
|
@@ -400,6 +450,18 @@ export function RemotePage() {
|
|
|
400
450
|
try { await api('POST', '/api/tunnel/devtunnel/login/dismiss'); refresh(); }
|
|
401
451
|
catch (e) { setToast(`dismiss failed · ${e.message}`, 'error'); }
|
|
402
452
|
}
|
|
453
|
+
async function onResetDevtunnelId() {
|
|
454
|
+
const ok = await ccsmConfirm(
|
|
455
|
+
`Mint a fresh tunnel id? The public URL changes — every approved remote device will need to re-register on the new URL. Any existing share links stop working.`,
|
|
456
|
+
{ title: 'Reset Microsoft Dev Tunnel id', okLabel: 'Reset', danger: true },
|
|
457
|
+
);
|
|
458
|
+
if (!ok) return;
|
|
459
|
+
try {
|
|
460
|
+
await api('POST', '/api/tunnel/devtunnel/reset');
|
|
461
|
+
refresh();
|
|
462
|
+
setToast('Tunnel id reset · next Start mints a fresh one', 'ok');
|
|
463
|
+
} catch (e) { setToast(`reset failed · ${e.message}`, 'error'); }
|
|
464
|
+
}
|
|
403
465
|
|
|
404
466
|
const running = status?.running;
|
|
405
467
|
const url = status?.url;
|
|
@@ -426,30 +488,21 @@ export function RemotePage() {
|
|
|
426
488
|
<div class="field">
|
|
427
489
|
<span class="label">Provider</span>
|
|
428
490
|
<div class="provider-tile-row">
|
|
429
|
-
<${ProviderTile} id="cloudflared" label="Cloudflare Tunnel"
|
|
430
|
-
hint="Anonymous · no login"
|
|
431
|
-
icon=${html`<${IconCloudflareColor} size=${32} />`}
|
|
432
|
-
selected=${provider === 'cloudflared'}
|
|
433
|
-
disabled=${running}
|
|
434
|
-
onSelect=${setProvider} />
|
|
435
491
|
<${ProviderTile} id="devtunnel" label="Microsoft Dev Tunnel"
|
|
436
492
|
hint="Requires sign-in"
|
|
437
493
|
icon=${html`<${IconMicrosoftColor} size=${32} />`}
|
|
438
494
|
selected=${provider === 'devtunnel'}
|
|
439
495
|
disabled=${running}
|
|
440
496
|
onSelect=${setProvider} />
|
|
497
|
+
<${ProviderTile} id="cloudflared" label="Cloudflare Tunnel"
|
|
498
|
+
hint="Anonymous · no login"
|
|
499
|
+
icon=${html`<${IconCloudflareColor} size=${32} />`}
|
|
500
|
+
selected=${provider === 'cloudflared'}
|
|
501
|
+
disabled=${running}
|
|
502
|
+
onSelect=${setProvider} />
|
|
441
503
|
</div>
|
|
442
504
|
${running ? html`<span class="hint">Stop the tunnel to switch provider.</span>` : null}
|
|
443
505
|
</div>
|
|
444
|
-
${provider === 'cloudflared' ? html`
|
|
445
|
-
<div class="field">
|
|
446
|
-
<span class="label">Cloudflare Tunnel</span>
|
|
447
|
-
<div class="remote-status-line">
|
|
448
|
-
<${ProviderStatus} id="cloudflared" info=${cf}
|
|
449
|
-
onInstall=${() => onInstall('cloudflared')} />
|
|
450
|
-
</div>
|
|
451
|
-
</div>
|
|
452
|
-
` : null}
|
|
453
506
|
${provider === 'devtunnel' ? html`
|
|
454
507
|
<div class="field">
|
|
455
508
|
<span class="label">Microsoft Dev Tunnel</span>
|
|
@@ -466,6 +519,21 @@ export function RemotePage() {
|
|
|
466
519
|
onDismiss=${onLoginDismiss}
|
|
467
520
|
onRetry=${() => onLogin('devtunnel')} />
|
|
468
521
|
` : null}
|
|
522
|
+
${dt?.loggedIn ? html`
|
|
523
|
+
<${DevtunnelTunnelIdRow}
|
|
524
|
+
tunnelId=${status?.tunnelId}
|
|
525
|
+
running=${running && status?.provider === 'devtunnel'}
|
|
526
|
+
onReset=${onResetDevtunnelId} />
|
|
527
|
+
` : null}
|
|
528
|
+
</div>
|
|
529
|
+
` : null}
|
|
530
|
+
${provider === 'cloudflared' ? html`
|
|
531
|
+
<div class="field">
|
|
532
|
+
<span class="label">Cloudflare Tunnel</span>
|
|
533
|
+
<div class="remote-status-line">
|
|
534
|
+
<${ProviderStatus} id="cloudflared" info=${cf}
|
|
535
|
+
onInstall=${() => onInstall('cloudflared')} />
|
|
536
|
+
</div>
|
|
469
537
|
</div>
|
|
470
538
|
` : null}
|
|
471
539
|
</div>
|
package/public/js/util.js
CHANGED
|
@@ -22,3 +22,47 @@ export function displayTitle(label, fallback) {
|
|
|
22
22
|
export function nowClock() {
|
|
23
23
|
return new Date().toLocaleTimeString(undefined, { hour12: false });
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
// Shell-style argv tokenizer / formatter used by the CLI editor's
|
|
27
|
+
// args / resumeIdArgs / newSessionIdArgs fields. Modeled on POSIX sh
|
|
28
|
+
// word splitting + bash quoting (the rules every dev already has in
|
|
29
|
+
// muscle memory) — not a full shell parser. Handles:
|
|
30
|
+
// bare token -Model → "-Model"
|
|
31
|
+
// double-quoted "a b c" → "a b c"
|
|
32
|
+
// \\ and \" are escapes inside ""; any
|
|
33
|
+
// other backslash is kept literal, so
|
|
34
|
+
// "C:\Users\foo" survives intact (bash
|
|
35
|
+
// rule, matters for Windows paths).
|
|
36
|
+
// single-quoted 'a b c' → "a b c" literal, no escapes
|
|
37
|
+
// mixed -Foo "x y" 'z' → ["-Foo","x y","z"]
|
|
38
|
+
// Anything malformed (unclosed quote, etc.) falls through to a bare
|
|
39
|
+
// best-effort match so the user can keep typing without the field
|
|
40
|
+
// nuking their input mid-type.
|
|
41
|
+
export function parseArgs(input) {
|
|
42
|
+
const s = String(input || '');
|
|
43
|
+
const out = [];
|
|
44
|
+
const re = /'([^']*)'|"((?:[^"\\]|\\.)*)"|(\S+)/g;
|
|
45
|
+
let m;
|
|
46
|
+
while ((m = re.exec(s)) !== null) {
|
|
47
|
+
if (m[1] !== undefined) out.push(m[1]);
|
|
48
|
+
else if (m[2] !== undefined) out.push(m[2].replace(/\\([\\"])/g, '$1'));
|
|
49
|
+
else out.push(m[3]);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Inverse of parseArgs — used when re-populating the textarea from a
|
|
55
|
+
// stored array. Bare-emit when the token has no shell-significant chars;
|
|
56
|
+
// otherwise double-quote with \" and \\ escapes. Round-trip is stable
|
|
57
|
+
// (parse(format(arr)) === arr) for any string array.
|
|
58
|
+
export function formatArgs(arr) {
|
|
59
|
+
if (!Array.isArray(arr)) return '';
|
|
60
|
+
return arr.map((a) => {
|
|
61
|
+
const s = String(a ?? '');
|
|
62
|
+
if (s === '') return '""';
|
|
63
|
+
if (/[\s"'\\`$]/.test(s)) {
|
|
64
|
+
return '"' + s.replace(/([\\"])/g, '\\$1') + '"';
|
|
65
|
+
}
|
|
66
|
+
return s;
|
|
67
|
+
}).join(' ');
|
|
68
|
+
}
|
package/server.js
CHANGED
|
@@ -1092,6 +1092,20 @@ app.post('/api/tunnel/devtunnel/login/dismiss', asyncH(async (_req, res) => {
|
|
|
1092
1092
|
tunnel.clearDevtunnelLogin();
|
|
1093
1093
|
res.json({ ok: true });
|
|
1094
1094
|
}));
|
|
1095
|
+
// Wipe the persisted devtunnel tunnel id (and the remote tunnel
|
|
1096
|
+
// resource itself, best-effort) so the next /api/tunnel/start mints
|
|
1097
|
+
// a fresh one. Used by the Reset button in the Remote page when the
|
|
1098
|
+
// user wants to rotate the public URL. Tunnel must be stopped first
|
|
1099
|
+
// — refuse otherwise so we don't yank state out from under a live
|
|
1100
|
+
// `devtunnel host` child.
|
|
1101
|
+
app.post('/api/tunnel/devtunnel/reset', asyncH(async (_req, res) => {
|
|
1102
|
+
const s = await tunnel.status();
|
|
1103
|
+
if (s.running && s.provider === 'devtunnel') {
|
|
1104
|
+
return res.status(409).json({ error: 'stop the tunnel before resetting its id' });
|
|
1105
|
+
}
|
|
1106
|
+
const r = await tunnel.resetDevtunnelTunnelId();
|
|
1107
|
+
res.json({ ok: true, ...r, ...(await tunnel.status()) });
|
|
1108
|
+
}));
|
|
1095
1109
|
|
|
1096
1110
|
// ---- devices ----
|
|
1097
1111
|
//
|
|
@@ -1555,6 +1569,12 @@ function openInBrowser(url) {
|
|
|
1555
1569
|
// a slow Import dialog cold-open. Fire in the background; the lib also
|
|
1556
1570
|
// starts its own 15s refresh loop.
|
|
1557
1571
|
try { localCliSessions.prewarmLivePids(['claude.exe']); } catch {}
|
|
1572
|
+
// Prewarm tunnel provider probe. First /api/tunnel/status round-trip
|
|
1573
|
+
// shells out to where.exe / --version / devtunnel user show — ~700ms
|
|
1574
|
+
// of synchronous work that the user otherwise waits on the moment
|
|
1575
|
+
// they open the Remote tab. Fire in the background here so the cache
|
|
1576
|
+
// is warm by the time anyone clicks.
|
|
1577
|
+
try { tunnel.probe(true).catch(() => {}); } catch {}
|
|
1558
1578
|
|
|
1559
1579
|
if (webTerminal.available) {
|
|
1560
1580
|
let WebSocketServer;
|