@bakapiano/ccsm 0.19.3 → 0.20.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/lib/config.js +9 -0
- package/lib/tunnel.js +149 -3
- package/package.json +1 -1
- package/public/css/dark.css +120 -0
- package/public/css/forms.css +30 -0
- package/public/css/layout.css +4 -5
- package/public/css/terminals.css +71 -38
- package/public/css/widgets.css +42 -0
- package/public/favicon.svg +8 -1
- package/public/index.html +56 -22
- package/public/js/components/TerminalView.js +109 -28
- package/public/js/icons.js +1 -1
- package/public/js/pages/ConfigurePage.js +23 -2
- package/public/js/pages/LaunchPage.js +51 -6
- package/public/js/pages/RemotePage.js +88 -20
- package/public/js/state.js +107 -38
- 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.
|
|
3
|
+
"version": "0.20.0",
|
|
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",
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/* Dark-mode overrides.
|
|
2
|
+
*
|
|
3
|
+
* The bulk of the UI flips automatically: surfaces, ink, and borders all
|
|
4
|
+
* read from CSS vars that state.js (and the pre-paint script in index.html)
|
|
5
|
+
* re-derive for the dark ground. This file only patches the stragglers —
|
|
6
|
+
* places that hardcoded a light-assuming literal color instead of a var,
|
|
7
|
+
* plus a few values that need a genuinely different treatment on dark
|
|
8
|
+
* (focus rings, primary-button hover, status text contrast, scrims).
|
|
9
|
+
*
|
|
10
|
+
* Loaded LAST so these win the cascade. Everything is scoped under
|
|
11
|
+
* [data-theme="dark"] on <html>, set by applyTheme().
|
|
12
|
+
*
|
|
13
|
+
* NOT touched here (intentionally dark already): the terminal pane, the
|
|
14
|
+
* session tabs, and the mobile key bar — those are dark in both themes. */
|
|
15
|
+
|
|
16
|
+
/* ── buttons ─────────────────────────────────────────────────────── */
|
|
17
|
+
/* .action.primary is bg:var(--ink)/text:var(--bg-elev) — already inverts
|
|
18
|
+
correctly (light slab, dark text) when the vars flip. Only its hover
|
|
19
|
+
hardcoded #000, which would darken the wrong way; send it brighter. */
|
|
20
|
+
[data-theme="dark"] .action.primary:hover {
|
|
21
|
+
background: #ffffff;
|
|
22
|
+
border-color: #ffffff;
|
|
23
|
+
box-shadow: 0 4px 14px -4px rgba(0, 0, 0, 0.6);
|
|
24
|
+
}
|
|
25
|
+
/* Focus rings / hover shadows used a dark ink wash that vanishes on a dark
|
|
26
|
+
ground — switch to a light wash so the affordance stays visible. */
|
|
27
|
+
[data-theme="dark"] .action:hover { box-shadow: 0 2px 4px -2px rgba(0, 0, 0, 0.5); }
|
|
28
|
+
[data-theme="dark"] .action:focus-visible { box-shadow: 0 0 0 3px rgba(236, 231, 218, 0.16); }
|
|
29
|
+
[data-theme="dark"] .input:focus,
|
|
30
|
+
[data-theme="dark"] input:focus,
|
|
31
|
+
[data-theme="dark"] select:focus,
|
|
32
|
+
[data-theme="dark"] textarea:focus { box-shadow: 0 0 0 3px rgba(236, 231, 218, 0.12); }
|
|
33
|
+
[data-theme="dark"] .action.danger:hover { background: #c75050; border-color: #c75050; }
|
|
34
|
+
|
|
35
|
+
/* The select chevron SVG is baked with a mid-gray stroke; lighten it. */
|
|
36
|
+
[data-theme="dark"] select {
|
|
37
|
+
background-image: url("data:image/svg+xml;utf8,<svg viewBox='0 0 12 8' xmlns='http://www.w3.org/2000/svg' fill='none' stroke='%23b4ab98' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='1,1 6,7 11,1'/></svg>");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* ── brand mark ──────────────────────────────────────────────────── */
|
|
41
|
+
/* The logo's "terminal window" rect is near-black (#1a1815) and vanishes
|
|
42
|
+
against the dark page. Lift its fill so it reads as a small elevated
|
|
43
|
+
panel — no hard outline (a border looks boxy at this size); the lighter
|
|
44
|
+
fill alone separates it. (Light mode leaves the presentation-attribute
|
|
45
|
+
fill untouched — it's already legible there.) */
|
|
46
|
+
[data-theme="dark"] .brand-rect { fill: #38342f; }
|
|
47
|
+
|
|
48
|
+
/* ── paper grain ─────────────────────────────────────────────────── */
|
|
49
|
+
/* The noise texture is a dark-tinted SVG multiplied over the surface —
|
|
50
|
+
invisible (and wrong blend) on a dark ground. Screen-blend it at low
|
|
51
|
+
opacity so it adds faint light speckle instead. */
|
|
52
|
+
[data-theme="dark"] body::before { mix-blend-mode: screen; opacity: 0.4; }
|
|
53
|
+
|
|
54
|
+
/* ── Microsoft sign-in button ────────────────────────────────────── */
|
|
55
|
+
/* Microsoft's brand guidance ships a dark-theme variant of the sign-in
|
|
56
|
+
button (dark fill, light text). The four-square logo stays as-is. */
|
|
57
|
+
[data-theme="dark"] .btn-signin-microsoft {
|
|
58
|
+
background: #2b2b2b;
|
|
59
|
+
border-color: #5e5e5e;
|
|
60
|
+
color: #ffffff;
|
|
61
|
+
}
|
|
62
|
+
[data-theme="dark"] .btn-signin-microsoft:hover { background: #383838; border-color: #6f6f6f; }
|
|
63
|
+
[data-theme="dark"] .btn-signin-microsoft:active { background: #1f1f1f; }
|
|
64
|
+
|
|
65
|
+
/* ── status / semantic text contrast ─────────────────────────────── */
|
|
66
|
+
/* These literals were tuned for dark-text-on-light. On a dark ground they
|
|
67
|
+
read as near-black mud — lift each to a legible tint of the same hue. */
|
|
68
|
+
[data-theme="dark"] .provider-status-state.is-ok,
|
|
69
|
+
[data-theme="dark"] .status-link.ok,
|
|
70
|
+
[data-theme="dark"] .status-label.ok { color: #7fc77f; }
|
|
71
|
+
[data-theme="dark"] .provider-status-state.is-warn,
|
|
72
|
+
[data-theme="dark"] .status-label.warn,
|
|
73
|
+
[data-theme="dark"] .signin-error-msg { color: #e89090; }
|
|
74
|
+
[data-theme="dark"] .signin-error-msg.is-active,
|
|
75
|
+
[data-theme="dark"] .tunnel-stop-link:hover { color: #f0a8a8; }
|
|
76
|
+
[data-theme="dark"] .status-label.blue { color: #6fb0e8; }
|
|
77
|
+
[data-theme="dark"] .remote-status-line .warn,
|
|
78
|
+
[data-theme="dark"] .warning-bg { color: #d9a066; }
|
|
79
|
+
[data-theme="dark"] .warning-tag { border-color: #b87a3a; background: rgba(217, 160, 102, 0.08); }
|
|
80
|
+
[data-theme="dark"] .warning-bg { background: rgba(217, 160, 102, 0.16); }
|
|
81
|
+
|
|
82
|
+
/* Light result panels (success/error) → translucent tints on dark. */
|
|
83
|
+
[data-theme="dark"] .signin-card-result.is-ok { background: rgba(74, 138, 74, 0.14); }
|
|
84
|
+
[data-theme="dark"] .signin-card-result.is-error { background: rgba(183, 63, 63, 0.16); }
|
|
85
|
+
|
|
86
|
+
/* Scrims sit over content — a touch deeper reads better against dark UI. */
|
|
87
|
+
[data-theme="dark"] .modal-backdrop,
|
|
88
|
+
[data-theme="dark"] .offline-overlay,
|
|
89
|
+
[data-theme="dark"] .kbd-recorder-overlay { background: rgba(0, 0, 0, 0.6); }
|
|
90
|
+
|
|
91
|
+
/* ── terminal chrome ─────────────────────────────────────────────── */
|
|
92
|
+
/* Dark values for the --term-* palette (terminals.css holds the light
|
|
93
|
+
defaults). These restore the original near-black terminal surround,
|
|
94
|
+
tab strip, empty/displaced states, and mobile key bar. Keep in lockstep
|
|
95
|
+
with TerminalView.js's THEME_DARK. */
|
|
96
|
+
[data-theme="dark"] {
|
|
97
|
+
/* VSCode Dark+ neutral panel grays around the #1e1e1e terminal canvas. */
|
|
98
|
+
--term-surface: #1e1e1e; /* matches THEME_DARK.background */
|
|
99
|
+
--term-on: #cccccc;
|
|
100
|
+
--term-on-dim: rgba(204, 204, 204, 0.72);
|
|
101
|
+
--term-on-faint: rgba(204, 204, 204, 0.45);
|
|
102
|
+
--term-heading: #ffffff;
|
|
103
|
+
--term-prompt: #f14c4c; /* VSCode ansiBrightRed */
|
|
104
|
+
--term-cta-bg: #cccccc;
|
|
105
|
+
--term-cta-fg: #1e1e1e;
|
|
106
|
+
--term-cta-bg-hover: #e5e5e5;
|
|
107
|
+
--term-tabstrip: #252526;
|
|
108
|
+
--term-tab: #2d2d2d;
|
|
109
|
+
--term-tab-hover: #333333;
|
|
110
|
+
--term-tab-text: rgba(204, 204, 204, 0.65);
|
|
111
|
+
--term-keybar-bg: #252526;
|
|
112
|
+
--term-key-fg: #cccccc;
|
|
113
|
+
--term-key-bg: rgba(255, 255, 255, 0.06);
|
|
114
|
+
--term-key-border: rgba(255, 255, 255, 0.14);
|
|
115
|
+
--term-key-active-bg: rgba(255, 255, 255, 0.20);
|
|
116
|
+
--term-key-active-border: rgba(255, 255, 255, 0.32);
|
|
117
|
+
--term-key-hint: rgba(204, 204, 204, 0.5);
|
|
118
|
+
--term-pop-bg: #252526;
|
|
119
|
+
--term-pop-border: rgba(255, 255, 255, 0.16);
|
|
120
|
+
}
|
package/public/css/forms.css
CHANGED
|
@@ -103,6 +103,36 @@ textarea {
|
|
|
103
103
|
line-height: 1.55;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/* Segmented control — a row of mutually-exclusive pills sharing one
|
|
107
|
+
border (used for the Appearance light/dark/system toggle). */
|
|
108
|
+
.seg {
|
|
109
|
+
display: inline-flex;
|
|
110
|
+
padding: 2px;
|
|
111
|
+
gap: 2px;
|
|
112
|
+
background: var(--ui-bg);
|
|
113
|
+
border: 1px solid var(--border-strong);
|
|
114
|
+
border-radius: 8px;
|
|
115
|
+
}
|
|
116
|
+
.seg-btn {
|
|
117
|
+
appearance: none;
|
|
118
|
+
background: transparent;
|
|
119
|
+
border: 0;
|
|
120
|
+
color: var(--ink-mid);
|
|
121
|
+
font-family: var(--body);
|
|
122
|
+
font-size: 12.5px;
|
|
123
|
+
font-weight: 500;
|
|
124
|
+
padding: 5px 14px;
|
|
125
|
+
border-radius: 6px;
|
|
126
|
+
cursor: pointer;
|
|
127
|
+
transition: background .14s ease, color .14s ease;
|
|
128
|
+
}
|
|
129
|
+
.seg-btn:hover { color: var(--ink); }
|
|
130
|
+
.seg-btn.is-active {
|
|
131
|
+
background: var(--bg-elev);
|
|
132
|
+
color: var(--ink);
|
|
133
|
+
box-shadow: var(--shadow-sm);
|
|
134
|
+
}
|
|
135
|
+
|
|
106
136
|
input[type="checkbox"] {
|
|
107
137
|
appearance: none;
|
|
108
138
|
width: 16px;
|
package/public/css/layout.css
CHANGED
|
@@ -125,11 +125,10 @@ body.is-resizing-sidebar .app {
|
|
|
125
125
|
box-sizing: border-box;
|
|
126
126
|
margin: 0 calc(-1 * var(--s-4)) 0;
|
|
127
127
|
padding: 0 var(--s-5);
|
|
128
|
-
/*
|
|
129
|
-
|
|
130
|
-
divider. */
|
|
131
|
-
|
|
132
|
-
linear-gradient(to bottom, rgba(216, 212, 198, 0.0) 0%, rgba(216, 212, 198, 0.0) calc(100% - 1px), var(--ui-border-soft) 100%);
|
|
128
|
+
/* Bottom divider matches the sidebar's right border exactly — same
|
|
129
|
+
1px solid var(--ui-border) — so the header underline and the
|
|
130
|
+
sidebar/main divider read as one continuous frame. */
|
|
131
|
+
border-bottom: 1px solid var(--ui-border);
|
|
133
132
|
color: var(--ink);
|
|
134
133
|
font-size: 13px;
|
|
135
134
|
font-weight: 400;
|
package/public/css/terminals.css
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
/* Terminals tab · left rail (active sessions) + right pane (xterm host) */
|
|
2
2
|
|
|
3
|
+
/* Terminal-chrome palette. The xterm canvas itself is painted from a JS
|
|
4
|
+
theme object (TerminalView.js); these vars colour everything AROUND it —
|
|
5
|
+
the pane backdrop, the tab strip, the empty/displaced states, and the
|
|
6
|
+
mobile key bar — so the chrome tracks the canvas. Light defaults here;
|
|
7
|
+
dark.css overrides under [data-theme="dark"] with the original dark set.
|
|
8
|
+
Keep the two in lockstep with the JS THEME_LIGHT / THEME_DARK objects. */
|
|
9
|
+
:root {
|
|
10
|
+
/* VSCode Light+ neutral panel grays, to sit seamlessly around the
|
|
11
|
+
white VSCode terminal canvas (THEME_LIGHT). */
|
|
12
|
+
--term-surface: #ffffff; /* matches THEME_LIGHT.background */
|
|
13
|
+
--term-on: #333333;
|
|
14
|
+
--term-on-dim: rgba(51, 51, 51, 0.70);
|
|
15
|
+
--term-on-faint: rgba(51, 51, 51, 0.45);
|
|
16
|
+
--term-heading: #1a1a1a;
|
|
17
|
+
--term-prompt: #cd3131; /* VSCode ansiRed */
|
|
18
|
+
--term-cta-bg: #2c2c2c;
|
|
19
|
+
--term-cta-fg: #ffffff;
|
|
20
|
+
--term-cta-bg-hover: #000000;
|
|
21
|
+
--term-tabstrip: #f0f0f0;
|
|
22
|
+
--term-tab: #e4e4e4;
|
|
23
|
+
--term-tab-hover: #d8d8d8;
|
|
24
|
+
--term-tab-text: rgba(51, 51, 51, 0.70);
|
|
25
|
+
--term-keybar-bg: #f0f0f0;
|
|
26
|
+
--term-key-fg: #333333;
|
|
27
|
+
--term-key-bg: rgba(0, 0, 0, 0.05);
|
|
28
|
+
--term-key-border: rgba(0, 0, 0, 0.14);
|
|
29
|
+
--term-key-active-bg: rgba(0, 0, 0, 0.12);
|
|
30
|
+
--term-key-active-border: rgba(0, 0, 0, 0.28);
|
|
31
|
+
--term-key-hint: rgba(0, 0, 0, 0.5);
|
|
32
|
+
--term-pop-bg: #f6f6f6;
|
|
33
|
+
--term-pop-border: rgba(0, 0, 0, 0.16);
|
|
34
|
+
}
|
|
35
|
+
|
|
3
36
|
.terminals-layout {
|
|
4
37
|
display: grid;
|
|
5
38
|
grid-template-columns: 240px 1fr;
|
|
@@ -246,7 +279,7 @@
|
|
|
246
279
|
terminal. Negative horizontal margin cancels .main's padding so
|
|
247
280
|
the strip is full-bleed like the terminal underneath. */
|
|
248
281
|
margin: 0 calc(-1 * var(--s-4)) calc(-1 * var(--s-4));
|
|
249
|
-
background:
|
|
282
|
+
background: var(--term-tabstrip);
|
|
250
283
|
border-bottom: 0;
|
|
251
284
|
}
|
|
252
285
|
.session-tabs-list {
|
|
@@ -272,7 +305,7 @@
|
|
|
272
305
|
}
|
|
273
306
|
.session-tab {
|
|
274
307
|
appearance: none;
|
|
275
|
-
background:
|
|
308
|
+
background: var(--term-tab);
|
|
276
309
|
border: 0;
|
|
277
310
|
border-bottom: 2px solid transparent;
|
|
278
311
|
margin-bottom: -1px; /* overlap container border-bottom */
|
|
@@ -282,17 +315,17 @@
|
|
|
282
315
|
gap: 6px;
|
|
283
316
|
font: inherit;
|
|
284
317
|
font-size: 12px;
|
|
285
|
-
color:
|
|
318
|
+
color: var(--term-tab-text);
|
|
286
319
|
cursor: pointer;
|
|
287
320
|
max-width: 200px;
|
|
288
321
|
min-width: 0;
|
|
289
322
|
transition: background-color .12s, color .12s;
|
|
290
323
|
}
|
|
291
|
-
.session-tab:hover { background:
|
|
324
|
+
.session-tab:hover { background: var(--term-tab-hover); color: var(--term-on); }
|
|
292
325
|
.session-tab.is-active {
|
|
293
|
-
background: var(--
|
|
294
|
-
color:
|
|
295
|
-
border-bottom-color: var(--
|
|
326
|
+
background: var(--term-surface);
|
|
327
|
+
color: var(--term-on);
|
|
328
|
+
border-bottom-color: var(--term-surface);
|
|
296
329
|
}
|
|
297
330
|
.session-tab-icon { display: inline-flex; flex-shrink: 0; }
|
|
298
331
|
.session-tab-icon svg { width: 14px; height: 14px; }
|
|
@@ -303,7 +336,7 @@
|
|
|
303
336
|
text-overflow: ellipsis;
|
|
304
337
|
min-width: 0;
|
|
305
338
|
}
|
|
306
|
-
.session-tab-meta { color:
|
|
339
|
+
.session-tab-meta { color: var(--term-tab-text); font-size: 11px; }
|
|
307
340
|
.session-tab.is-active .session-tab-meta { color: rgba(255, 255, 255, 0.6); }
|
|
308
341
|
.session-tab-add {
|
|
309
342
|
background: transparent;
|
|
@@ -412,14 +445,14 @@
|
|
|
412
445
|
.session-pane-body {
|
|
413
446
|
flex: 1;
|
|
414
447
|
min-height: 0;
|
|
415
|
-
background:
|
|
448
|
+
background: var(--term-surface);
|
|
416
449
|
}
|
|
417
450
|
.session-pane-body .terminal-host {
|
|
418
451
|
height: 100%;
|
|
419
452
|
}
|
|
420
453
|
.session-pane-body .terminal-empty {
|
|
421
|
-
background:
|
|
422
|
-
color:
|
|
454
|
+
background: var(--term-surface);
|
|
455
|
+
color: var(--term-on);
|
|
423
456
|
display: flex;
|
|
424
457
|
flex-direction: column;
|
|
425
458
|
align-items: center;
|
|
@@ -429,16 +462,16 @@
|
|
|
429
462
|
font-size: 13px;
|
|
430
463
|
}
|
|
431
464
|
.session-pane-body .terminal-empty .mono {
|
|
432
|
-
color:
|
|
465
|
+
color: var(--term-prompt);
|
|
433
466
|
}
|
|
434
467
|
.session-pane-body .terminal-empty .action.primary {
|
|
435
|
-
background:
|
|
436
|
-
color:
|
|
437
|
-
border-color:
|
|
468
|
+
background: var(--term-cta-bg);
|
|
469
|
+
color: var(--term-cta-fg);
|
|
470
|
+
border-color: var(--term-cta-bg);
|
|
438
471
|
}
|
|
439
472
|
.session-pane-body .terminal-empty .action.primary:hover {
|
|
440
|
-
background:
|
|
441
|
-
border-color:
|
|
473
|
+
background: var(--term-cta-bg-hover);
|
|
474
|
+
border-color: var(--term-cta-bg-hover);
|
|
442
475
|
}
|
|
443
476
|
|
|
444
477
|
/* Displaced state — shown when the server kicks us off because another
|
|
@@ -446,8 +479,8 @@
|
|
|
446
479
|
as terminal-empty so the transition from running terminal → displaced
|
|
447
480
|
doesn't flash a colour change. */
|
|
448
481
|
.terminal-displaced {
|
|
449
|
-
background:
|
|
450
|
-
color:
|
|
482
|
+
background: var(--term-surface);
|
|
483
|
+
color: var(--term-on);
|
|
451
484
|
display: flex;
|
|
452
485
|
align-items: center;
|
|
453
486
|
justify-content: center;
|
|
@@ -465,14 +498,14 @@
|
|
|
465
498
|
margin: 0;
|
|
466
499
|
font-size: 16px;
|
|
467
500
|
font-weight: 600;
|
|
468
|
-
color:
|
|
501
|
+
color: var(--term-heading);
|
|
469
502
|
letter-spacing: -0.005em;
|
|
470
503
|
}
|
|
471
504
|
.terminal-displaced-card p {
|
|
472
505
|
margin: 0;
|
|
473
506
|
font-size: 13px;
|
|
474
507
|
line-height: 1.55;
|
|
475
|
-
color:
|
|
508
|
+
color: var(--term-on-dim);
|
|
476
509
|
}
|
|
477
510
|
.terminal-displaced-actions {
|
|
478
511
|
margin-top: var(--s-2);
|
|
@@ -480,19 +513,19 @@
|
|
|
480
513
|
justify-content: center;
|
|
481
514
|
}
|
|
482
515
|
.terminal-displaced-card .action.primary {
|
|
483
|
-
background:
|
|
484
|
-
color:
|
|
485
|
-
border-color:
|
|
516
|
+
background: var(--term-cta-bg);
|
|
517
|
+
color: var(--term-cta-fg);
|
|
518
|
+
border-color: var(--term-cta-bg);
|
|
486
519
|
padding: 9px 20px;
|
|
487
520
|
font-size: 13px;
|
|
488
521
|
}
|
|
489
522
|
.terminal-displaced-card .action.primary:hover {
|
|
490
|
-
background:
|
|
491
|
-
border-color:
|
|
523
|
+
background: var(--term-cta-bg-hover);
|
|
524
|
+
border-color: var(--term-cta-bg-hover);
|
|
492
525
|
}
|
|
493
526
|
.terminal-displaced-hint {
|
|
494
527
|
font-size: 11.5px !important;
|
|
495
|
-
color:
|
|
528
|
+
color: var(--term-on-faint) !important;
|
|
496
529
|
}
|
|
497
530
|
|
|
498
531
|
/* ─── Mobile terminal accessory bar (TerminalKeyBar.js) ───────────────
|
|
@@ -505,8 +538,8 @@
|
|
|
505
538
|
left: 0;
|
|
506
539
|
right: 0;
|
|
507
540
|
z-index: 215; /* above the mobile FAB (210) */
|
|
508
|
-
background:
|
|
509
|
-
border-top: 1px solid
|
|
541
|
+
background: var(--term-keybar-bg);
|
|
542
|
+
border-top: 1px solid var(--term-key-border);
|
|
510
543
|
padding: 6px 8px;
|
|
511
544
|
touch-action: manipulation; /* kill the 300ms double-tap-zoom delay */
|
|
512
545
|
user-select: none;
|
|
@@ -538,17 +571,17 @@
|
|
|
538
571
|
font-family: var(--mono);
|
|
539
572
|
font-size: 13px;
|
|
540
573
|
line-height: 1;
|
|
541
|
-
color:
|
|
542
|
-
background:
|
|
543
|
-
border: 1px solid
|
|
574
|
+
color: var(--term-key-fg);
|
|
575
|
+
background: var(--term-key-bg);
|
|
576
|
+
border: 1px solid var(--term-key-border);
|
|
544
577
|
border-radius: 8px;
|
|
545
578
|
touch-action: manipulation;
|
|
546
579
|
-webkit-tap-highlight-color: transparent;
|
|
547
580
|
}
|
|
548
581
|
.tkb-key:active,
|
|
549
582
|
.tkb-key.is-active {
|
|
550
|
-
background:
|
|
551
|
-
border-color:
|
|
583
|
+
background: var(--term-key-active-bg);
|
|
584
|
+
border-color: var(--term-key-active-border);
|
|
552
585
|
}
|
|
553
586
|
.tkb-arrow { padding: 0 10px; }
|
|
554
587
|
.tkb-arrow svg { width: 18px; height: 18px; }
|
|
@@ -570,8 +603,8 @@
|
|
|
570
603
|
grid-template-columns: repeat(5, 1fr);
|
|
571
604
|
gap: 6px;
|
|
572
605
|
padding: 8px;
|
|
573
|
-
background:
|
|
574
|
-
border: 1px solid
|
|
606
|
+
background: var(--term-pop-bg);
|
|
607
|
+
border: 1px solid var(--term-pop-border);
|
|
575
608
|
border-radius: 10px;
|
|
576
609
|
box-shadow: 0 -8px 24px -8px rgba(0, 0, 0, 0.5);
|
|
577
610
|
}
|
|
@@ -582,5 +615,5 @@
|
|
|
582
615
|
padding: 7px 4px;
|
|
583
616
|
gap: 2px;
|
|
584
617
|
}
|
|
585
|
-
.tkb-combo-label { font-family: var(--mono); font-size: 13px; color:
|
|
586
|
-
.tkb-combo-hint { font-size: 9.5px; color:
|
|
618
|
+
.tkb-combo-label { font-family: var(--mono); font-size: 13px; color: var(--term-key-fg); }
|
|
619
|
+
.tkb-combo-hint { font-size: 9.5px; color: var(--term-key-hint); 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)
|