@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.
@@ -1,6 +1,13 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2
+ <!-- The terminal-window body lifts on a dark browser/OS chrome so the
3
+ near-black mark doesn't vanish in the tab strip. Mirrors the in-app
4
+ BrandMark's [data-theme="dark"] .brand-rect treatment. -->
5
+ <style>
6
+ .body { fill: #1a1815; }
7
+ @media (prefers-color-scheme: dark) { .body { fill: #38342f; } }
8
+ </style>
2
9
  <!-- terminal window body -->
3
- <rect x="2" y="4" width="28" height="24" rx="3" fill="#1a1815"/>
10
+ <rect class="body" x="2" y="4" width="28" height="24" rx="3"/>
4
11
  <!-- title bar divider -->
5
12
  <line x1="2" y1="10" x2="30" y2="10" stroke="#faf9f5" stroke-width="0.6" opacity="0.45"/>
6
13
  <!-- traffic-light dots -->
package/public/index.html CHANGED
@@ -27,37 +27,69 @@
27
27
  redirect to ../ on backend upgrade would fall out of scope and
28
28
  the OS would re-show an address bar. -->
29
29
  <link rel="manifest" href="../manifest.webmanifest" />
30
- <!-- Apply accent color BEFORE stylesheets/paint to avoid a flash
31
- of the default warm-cream tokens.css bg. Mirrors
32
- applyAccentCssVars() in state.js. Falls back to the Ocean
33
- default (#2f6fa3) when no accent is saved so first-time
34
- visitors also see the correct bg from the first frame. -->
30
+ <!-- Apply theme (accent + light/dark) BEFORE stylesheets/paint to
31
+ avoid a flash of the default light tokens.css bg. Mirrors
32
+ applyTheme()/applyAccentCssVars() in state.js keep the two in
33
+ sync. Resolves 'system' against the OS, sets data-theme so the
34
+ [data-theme="dark"] CSS overrides apply from the first frame, and
35
+ derives the accent-tinted palette for the chosen ground. -->
35
36
  <script>
36
37
  (function () {
37
38
  try {
38
39
  var hex = localStorage.getItem('ccsm.accent');
39
40
  if (!/^#[0-9a-fA-F]{6}$/.test(hex || '')) hex = '#2f6fa3';
41
+ var mode = localStorage.getItem('ccsm.theme');
42
+ if (mode !== 'light' && mode !== 'dark' && mode !== 'system') mode = 'system';
43
+ var dark = mode === 'dark' || (mode === 'system'
44
+ && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
45
+
40
46
  var n = parseInt(hex.slice(1), 16);
41
- var r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
47
+ var A = { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
42
48
  var toHex = function (v) { v = Math.max(0, Math.min(255, Math.round(v))); var s = v.toString(16); return s.length < 2 ? '0' + s : s; };
43
- var mix = function (t) { return '#' + toHex(r * t + 255 * (1 - t)) + toHex(g * t + 255 * (1 - t)) + toHex(b * t + 255 * (1 - t)); };
44
- var darken = function (a) { return '#' + toHex(r * (1 - a)) + toHex(g * (1 - a)) + toHex(b * (1 - a)); };
45
- var bg = mix(0.04);
49
+ var rgb = function (c) { return '#' + toHex(c.r) + toHex(c.g) + toHex(c.b); };
50
+ var lerp = function (c1, c2, t) { return { r: c1.r + (c2.r - c1.r) * t, g: c1.g + (c2.g - c1.g) * t, b: c1.b + (c2.b - c1.b) * t }; };
51
+ var WHITE = { r: 255, g: 255, b: 255 }, BLACK = { r: 0, g: 0, b: 0 };
52
+ var DARK_BASE = { r: 0x18, g: 0x16, b: 0x12 }, LIGHT_INK = { r: 0xec, g: 0xe7, b: 0xda };
53
+
46
54
  var root = document.documentElement.style;
47
- root.setProperty('--accent', hex);
48
- root.setProperty('--accent-deep', darken(0.2));
49
- root.setProperty('--accent-soft', 'rgba(' + r + ',' + g + ',' + b + ',0.10)');
50
- root.setProperty('--accent-softer', 'rgba(' + r + ',' + g + ',' + b + ',0.04)');
51
- root.setProperty('--bg', bg);
52
- root.setProperty('--sidebar-bg', bg);
53
- root.setProperty('--sidebar-hover', mix(0.10));
54
- root.setProperty('--sidebar-active', mix(0.15));
55
- root.setProperty('--border', mix(0.15));
56
- root.setProperty('--border-soft', mix(0.12));
57
- root.setProperty('--border-strong', mix(0.25));
58
- root.setProperty('--ui-bg', mix(0.10));
55
+ var set = function (o) { for (var k in o) root.setProperty(k, o[k]); };
56
+ var vars;
57
+ if (dark) {
58
+ var bg = lerp(DARK_BASE, A, 0.06);
59
+ var lift = function (t) { return rgb(lerp(bg, LIGHT_INK, t)); };
60
+ vars = {
61
+ '--accent': hex,
62
+ '--accent-deep': rgb(lerp(A, LIGHT_INK, 0.18)),
63
+ '--accent-soft': 'rgba(' + A.r + ',' + A.g + ',' + A.b + ',0.18)',
64
+ '--accent-softer': 'rgba(' + A.r + ',' + A.g + ',' + A.b + ',0.07)',
65
+ '--bg': rgb(bg), '--bg-elev': lift(0.05), '--sidebar-bg': rgb(bg),
66
+ '--sidebar-hover': lift(0.09), '--sidebar-active': lift(0.15),
67
+ '--border': lift(0.14), '--border-soft': lift(0.09), '--border-strong': lift(0.24),
68
+ '--ui-bg': lift(0.05), '--ui-border': lift(0.16), '--ui-border-soft': lift(0.10),
69
+ '--ink': rgb(LIGHT_INK),
70
+ '--ink-mid': rgb(lerp(LIGHT_INK, DARK_BASE, 0.28)),
71
+ '--ink-muted': rgb(lerp(LIGHT_INK, DARK_BASE, 0.45)),
72
+ '--ink-faint': rgb(lerp(LIGHT_INK, DARK_BASE, 0.60)),
73
+ };
74
+ } else {
75
+ var mix = function (t) { return rgb(lerp(WHITE, A, t)); };
76
+ vars = {
77
+ '--accent': hex,
78
+ '--accent-deep': rgb(lerp(A, BLACK, 0.2)),
79
+ '--accent-soft': 'rgba(' + A.r + ',' + A.g + ',' + A.b + ',0.10)',
80
+ '--accent-softer': 'rgba(' + A.r + ',' + A.g + ',' + A.b + ',0.04)',
81
+ '--bg': mix(0.04), '--bg-elev': '#ffffff', '--sidebar-bg': mix(0.04),
82
+ '--sidebar-hover': mix(0.10), '--sidebar-active': mix(0.15),
83
+ '--border': mix(0.15), '--border-soft': mix(0.12), '--border-strong': mix(0.25),
84
+ '--ui-bg': mix(0.10), '--ui-border': '#d8d4c6', '--ui-border-soft': '#e6e2d4',
85
+ '--ink': '#1a1815', '--ink-mid': '#534e44', '--ink-muted': '#8a8475', '--ink-faint': '#b5af9d',
86
+ };
87
+ }
88
+ document.documentElement.dataset.theme = dark ? 'dark' : 'light';
89
+ document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
90
+ set(vars);
59
91
  var meta = document.querySelector('meta[name="theme-color"]');
60
- if (meta) meta.setAttribute('content', bg);
92
+ if (meta) meta.setAttribute('content', vars['--bg']);
61
93
  } catch (_) {}
62
94
  })();
63
95
  </script>
@@ -80,6 +112,8 @@
80
112
  <link rel="stylesheet" href="./css/terminals.css" />
81
113
  <link rel="stylesheet" href="./css/wco.css" />
82
114
  <link rel="stylesheet" href="./css/responsive.css" />
115
+ <!-- Loaded last so its [data-theme="dark"] rules win the cascade. -->
116
+ <link rel="stylesheet" href="./css/dark.css" />
83
117
 
84
118
  <script type="importmap">
85
119
  {
@@ -11,29 +11,50 @@ import { WebLinksAddon } from '@xterm/addon-web-links';
11
11
  import { ClipboardAddon } from '@xterm/addon-clipboard';
12
12
  import { WebglAddon } from '@xterm/addon-webgl';
13
13
  import { wsBase, getToken, getDeviceId } from '../backend.js';
14
+ import { isDarkTheme, themeMode } from '../state.js';
14
15
  import { TerminalKeyBar } from './TerminalKeyBar.js';
15
16
 
16
- // Dark xterm theme. We give the terminal a near-black ink background to
17
- // match what claude code's TUI assumes (it paints its own input box +
18
- // prompt with hardcoded dark backgrounds a light terminal makes those
19
- // regions look like black blocks). Cursor uses the favorite-star gold so
20
- // it pops against the ink without dragging brand orange back in.
21
- const THEME = {
22
- background: '#1a1815',
23
- foreground: '#e8e3d5',
24
- cursor: '#e3b341',
25
- cursorAccent: '#1a1815',
26
- selectionBackground: '#3a3530',
27
- black: '#1a1815', brightBlack: '#534e44',
28
- red: '#e07b6e', brightRed: '#f0a098',
29
- green: '#7fb670', brightGreen: '#a0d28f',
30
- yellow: '#e3b341', brightYellow: '#f0c860',
31
- blue: '#7d9fc4', brightBlue: '#9bb8d8',
32
- magenta: '#c08fd0', brightMagenta: '#d8aae2',
33
- cyan: '#6fb0b0', brightCyan: '#90c8c8',
34
- white: '#e8e3d5', brightWhite: '#faf9f5',
17
+ // Dark xterm theme VSCode's Dark+ terminal palette, verbatim (see
18
+ // microsoft/vscode src/.../terminal/common/terminalColorRegistry.ts).
19
+ // #1e1e1e ground, #ccc ink, the standard saturated ANSI set.
20
+ const THEME_DARK = {
21
+ background: '#1e1e1e',
22
+ foreground: '#cccccc',
23
+ cursor: '#aeafad',
24
+ cursorAccent: '#1e1e1e',
25
+ selectionBackground: '#264f78',
26
+ black: '#000000', brightBlack: '#666666',
27
+ red: '#cd3131', brightRed: '#f14c4c',
28
+ green: '#0dbc79', brightGreen: '#23d18b',
29
+ yellow: '#e5e510', brightYellow: '#f5f543',
30
+ blue: '#2472c8', brightBlue: '#3b8eea',
31
+ magenta: '#bc3fbc', brightMagenta: '#d670d6',
32
+ cyan: '#11a8cd', brightCyan: '#29b8db',
33
+ white: '#e5e5e5', brightWhite: '#e5e5e5',
35
34
  };
36
35
 
36
+ // Light xterm theme — VSCode's Light+ terminal palette, verbatim (see
37
+ // microsoft/vscode src/.../terminal/common/terminalColorRegistry.ts). Pure
38
+ // white ground, #333 ink, the classic saturated ANSI set tuned for legible
39
+ // contrast on white. The surrounding chrome (terminals.css --term-* light
40
+ // defaults) follows the same neutral light grays so it reads as one panel.
41
+ const THEME_LIGHT = {
42
+ background: '#ffffff',
43
+ foreground: '#333333',
44
+ cursor: '#000000',
45
+ cursorAccent: '#ffffff',
46
+ selectionBackground: '#add6ff',
47
+ black: '#000000', brightBlack: '#666666',
48
+ red: '#cd3131', brightRed: '#cd3131',
49
+ green: '#107c10', brightGreen: '#14ce14',
50
+ yellow: '#949800', brightYellow: '#b5ba00',
51
+ blue: '#0451a5', brightBlue: '#0451a5',
52
+ magenta: '#bc05bc', brightMagenta: '#bc05bc',
53
+ cyan: '#0598bc', brightCyan: '#0598bc',
54
+ white: '#555555', brightWhite: '#a5a5a5',
55
+ };
56
+ const themeFor = (dark) => (dark ? THEME_DARK : THEME_LIGHT);
57
+
37
58
  export function TerminalView({ terminalId, cliType }) {
38
59
  const hostRef = useRef(null);
39
60
  const termRef = useRef(null);
@@ -45,6 +66,11 @@ export function TerminalView({ terminalId, cliType }) {
45
66
  // currently holds the session.
46
67
  const [displaced, setDisplaced] = useState(false);
47
68
  const [reattachNonce, setReattach] = useState(0);
69
+ // Subscribe to the theme signal so a Settings toggle re-renders us and
70
+ // the theme-sync effect below re-runs. Holds the xterm theme currently
71
+ // applied so the IME handlers can re-issue it with a transparent cursor.
72
+ const mode = themeMode.value;
73
+ const themeRef = useRef(themeFor(isDarkTheme()));
48
74
 
49
75
  // Raw escape-sequence injector for the mobile key bar. Reads wsRef at
50
76
  // call time so it stays valid across reattaches without re-binding.
@@ -53,6 +79,24 @@ export function TerminalView({ terminalId, cliType }) {
53
79
  if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data }));
54
80
  };
55
81
 
82
+ // Swap the xterm canvas palette when the resolved theme flips — both on
83
+ // an explicit Settings toggle (mode dep) and on an OS change while in
84
+ // 'system' mode (matchMedia listener). No remount: xterm re-rasterizes
85
+ // its glyph atlas from the new options.theme in place.
86
+ useEffect(() => {
87
+ const apply = () => {
88
+ const term = termRef.current;
89
+ if (!term) return;
90
+ const theme = themeFor(isDarkTheme());
91
+ themeRef.current = theme;
92
+ try { term.options.theme = theme; } catch {}
93
+ };
94
+ apply();
95
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
96
+ mq.addEventListener('change', apply);
97
+ return () => mq.removeEventListener('change', apply);
98
+ }, [mode, reattachNonce]);
99
+
56
100
  useEffect(() => {
57
101
  if (!terminalId || !hostRef.current) return;
58
102
 
@@ -63,6 +107,8 @@ export function TerminalView({ terminalId, cliType }) {
63
107
  // next mount (rare; users typically don't rotate mid-session).
64
108
  const isMobile = window.matchMedia('(max-width: 640px)').matches;
65
109
  const baseFontSize = isMobile ? 11 : 13;
110
+ const initialTheme = themeFor(isDarkTheme());
111
+ themeRef.current = initialTheme;
66
112
  const term = new Terminal({
67
113
  fontFamily: '"Cascadia Mono", "Geist Mono", "JetBrains Mono", Consolas, monospace',
68
114
  fontSize: baseFontSize,
@@ -71,7 +117,7 @@ export function TerminalView({ terminalId, cliType }) {
71
117
  cursorStyle: 'bar',
72
118
  scrollback: 5000,
73
119
  allowProposedApi: true,
74
- theme: THEME,
120
+ theme: initialTheme,
75
121
  // Modern keyboard protocols. Without these, xterm.js encodes
76
122
  // Shift+Enter, Ctrl+Enter, Ctrl+Shift+key etc. the same as their
77
123
  // unmodified versions (e.g. both Enter and Shift+Enter send \r),
@@ -137,8 +183,26 @@ export function TerminalView({ terminalId, cliType }) {
137
183
 
138
184
  const host = hostRef.current;
139
185
  term.open(host);
140
- // Defer fit one tick so the container has measured layout
141
- requestAnimationFrame(() => { try { fit.fit(); } catch {} });
186
+ // Robust fit scheduler. A single requestAnimationFrame works most
187
+ // of the time but races on tab/session switches: the .tab-panel
188
+ // just flipped from display:none to display:flex and although the
189
+ // browser has laid the element out by the next frame, xterm's
190
+ // canvas measurement occasionally still reports the pre-display
191
+ // size (Chromium quirk — the WebGL renderer caches its viewport
192
+ // before the layout flush propagates through ResizeObserver).
193
+ // Result: visible "wrong cols/rows until I resize the window" bug.
194
+ // Spraying fits at 0 / one rAF / 60ms / 200ms covers every
195
+ // measurement-arrival path without being expensive — fit.fit() is
196
+ // a no-op when cols/rows match the previous call.
197
+ const scheduleFit = () => {
198
+ try { fit.fit(); } catch {}
199
+ requestAnimationFrame(() => {
200
+ try { fit.fit(); } catch {}
201
+ setTimeout(() => { try { fit.fit(); } catch {} }, 60);
202
+ setTimeout(() => { try { fit.fit(); } catch {} }, 200);
203
+ });
204
+ };
205
+ scheduleFit();
142
206
  termRef.current = term;
143
207
 
144
208
  // Browser WS API can't set Authorization headers — token + device
@@ -163,7 +227,7 @@ export function TerminalView({ terminalId, cliType }) {
163
227
  // wrapped at 80 cols, and the follow-up resize from the rAF fit
164
228
  // wouldn't reflow the already-emitted bytes. Visible as squeezed
165
229
  // text on every session switch.
166
- try { fit.fit(); } catch {}
230
+ scheduleFit();
167
231
  ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
168
232
  };
169
233
  ws.onmessage = (ev) => {
@@ -202,6 +266,20 @@ export function TerminalView({ terminalId, cliType }) {
202
266
  const ro = new ResizeObserver(() => { try { fit.fit(); } catch {} });
203
267
  ro.observe(hostRef.current);
204
268
 
269
+ // Mobile soft-keyboard resize. When the IME slides up on iOS /
270
+ // Android, the layout viewport doesn't change but `visualViewport`
271
+ // does — the page now has less vertical room before the keyboard
272
+ // covers the bottom. xterm's host element keeps its old layout
273
+ // height (we use 100vh-derived sizing) so half the terminal sits
274
+ // behind the keyboard with no resize callback fired. Listening
275
+ // here covers it: any visualViewport size change triggers a fit
276
+ // so the cell grid matches the visible area. Cheap; fit.fit() is
277
+ // a no-op when nothing changed.
278
+ const vv = window.visualViewport;
279
+ const onVisualResize = () => scheduleFit();
280
+ vv?.addEventListener?.('resize', onVisualResize);
281
+ vv?.addEventListener?.('scroll', onVisualResize);
282
+
205
283
  // Tab-switch refresh. The terminal lives inside a .tab-panel which gets
206
284
  // display:none when another tab is active. WebGL renderers keep a glyph
207
285
  // texture atlas in GPU memory; when the canvas hides + redisplays at a
@@ -217,7 +295,7 @@ export function TerminalView({ terminalId, cliType }) {
217
295
  if (panel.hasAttribute('data-active')) {
218
296
  requestAnimationFrame(() => {
219
297
  try { term.clearTextureAtlas?.(); } catch {}
220
- try { fit.fit(); } catch {}
298
+ scheduleFit();
221
299
  try { term.refresh(0, term.rows - 1); } catch {}
222
300
  });
223
301
  }
@@ -349,16 +427,17 @@ export function TerminalView({ terminalId, cliType }) {
349
427
  // the CSS in terminals.css does the rest.
350
428
  const onCompStart = () => {
351
429
  if (host) host.classList.add('is-composing');
352
- // The terminal cursor is rendered on canvas (THEME.cursor), so CSS
430
+ // The terminal cursor is rendered on canvas (theme.cursor), so CSS
353
431
  // can't hide it. Theme swap alone doesn't reliably stop the blink
354
432
  // frame loop, so also issue the DECTCEM hide sequence which the
355
- // renderer honours immediately.
356
- try { term.options.theme = { ...THEME, cursor: 'transparent', cursorAccent: 'transparent' }; } catch {}
433
+ // renderer honours immediately. Use the live theme (themeRef) so the
434
+ // restore on compEnd matches whatever light/dark is current.
435
+ try { term.options.theme = { ...themeRef.current, cursor: 'transparent', cursorAccent: 'transparent' }; } catch {}
357
436
  try { term.write('\x1b[?25l'); } catch {}
358
437
  };
359
438
  const onCompEnd = () => {
360
439
  if (host) host.classList.remove('is-composing');
361
- try { term.options.theme = THEME; } catch {}
440
+ try { term.options.theme = themeRef.current; } catch {}
362
441
  try { term.write('\x1b[?25h'); } catch {}
363
442
  };
364
443
  const helper = host?.querySelector('.xterm-helper-textarea');
@@ -377,6 +456,8 @@ export function TerminalView({ terminalId, cliType }) {
377
456
  }
378
457
  ro.disconnect();
379
458
  if (panelMo) panelMo.disconnect();
459
+ vv?.removeEventListener?.('resize', onVisualResize);
460
+ vv?.removeEventListener?.('scroll', onVisualResize);
380
461
  try { ws.close(); } catch {}
381
462
  try { term.dispose(); } catch {}
382
463
  termRef.current = null;
@@ -199,7 +199,7 @@ export const StarSmallFilled = ({ size = 14 } = {}) => html`
199
199
  // brand mark (terminal window + ccsm text — matches /favicon.svg)
200
200
  export const BrandMark = () => html`
201
201
  <svg viewBox="0 0 32 32" width="32" height="32">
202
- <rect x="2" y="4" width="28" height="24" rx="3" fill="#1a1815"/>
202
+ <rect class="brand-rect" x="2" y="4" width="28" height="24" rx="3" fill="#1a1815"/>
203
203
  <line x1="2" y1="10" x2="30" y2="10" stroke="#faf9f5" stroke-width="0.6" opacity="0.45"/>
204
204
  <!-- macOS traffic-light style: red / yellow / green -->
205
205
  <circle cx="6" cy="7" r="1" fill="#ed6a5e"/>
@@ -6,8 +6,8 @@ import { html } from '../html.js';
6
6
  import { useEffect, useState } from 'preact/hooks';
7
7
  import {
8
8
  config, configDirty, accentColor, folders, workspaces, serverHealth,
9
- restartInFlight,
10
- setAccentColor, ACCENT_DEFAULT,
9
+ restartInFlight, themeMode,
10
+ setAccentColor, ACCENT_DEFAULT, setThemeMode,
11
11
  } from '../state.js';
12
12
  import {
13
13
  api, loadConfig, loadWorkspaces, loadFolders,
@@ -182,6 +182,10 @@ export function ConfigurePage() {
182
182
 
183
183
  <${Section} title="General">
184
184
  <div class="config-grid">
185
+ <div class="field">
186
+ <span class="label">Appearance</span>
187
+ <${ThemeToggle} />
188
+ </div>
185
189
  <div class="field">
186
190
  <span class="label">Theme accent</span>
187
191
  <${AccentPicker} />
@@ -577,6 +581,23 @@ function RestartButton() {
577
581
  `;
578
582
  }
579
583
 
584
+ function ThemeToggle() {
585
+ const mode = themeMode.value;
586
+ const opts = [
587
+ { id: 'light', label: 'Light' },
588
+ { id: 'dark', label: 'Dark' },
589
+ { id: 'system', label: 'System' },
590
+ ];
591
+ return html`
592
+ <div class="seg" role="group" aria-label="Appearance">
593
+ ${opts.map((o) => html`
594
+ <button key=${o.id} type="button"
595
+ class=${`seg-btn${mode === o.id ? ' is-active' : ''}`}
596
+ aria-pressed=${mode === o.id}
597
+ onClick=${() => setThemeMode(o.id)}>${o.label}</button>`)}
598
+ </div>`;
599
+ }
600
+
580
601
  function AccentPicker() {
581
602
  const current = (accentColor.value || '').toLowerCase();
582
603
  const matchedPreset = PRESETS.find((p) => p.hex.toLowerCase() === current);
@@ -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
- function initRepoSelection(repos) {
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
- const [cliId, setCliId] = useState(defaultCli);
36
- const [folderId, setFolderId] = useState('');
37
- const [mode, setMode] = useState('auto'); // 'auto' = workspace + repos, 'cwd' = pick existing dir
38
- const [cwd, setCwd] = useState(''); // only used when mode === 'cwd'
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
- const [status, setStatus] = useState(null);
265
- const [provider, setProvider] = useState('cloudflared');
266
- const [token, setTokenLocal] = useState('');
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
- return cur || 'cloudflared';
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>