@bakapiano/ccsm 0.16.0 → 0.17.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/bin/ccsm.js CHANGED
@@ -39,6 +39,15 @@ function loadPreferredPort() {
39
39
  }
40
40
  }
41
41
 
42
+ // Cheap "is this pid still alive" check using kill(pid, 0). Returns
43
+ // true for live pids we own, also true for pids in other security
44
+ // contexts (EPERM means it exists, we just can't signal it).
45
+ function pidAlive(pid) {
46
+ if (!pid) return false;
47
+ try { process.kill(pid, 0); return true; }
48
+ catch (e) { return e.code === 'EPERM'; }
49
+ }
50
+
42
51
  function probe(port, timeoutMs = 800) {
43
52
  return new Promise((resolve) => {
44
53
  const req = http.get(`http://localhost:${port}/api/health`, { timeout: timeoutMs }, (res) => {
@@ -106,6 +115,30 @@ function isSameVersion(running) {
106
115
  const SILENT = !!protocol; // ccsm:// invocations should not open a new browser
107
116
  const port = loadPreferredPort();
108
117
 
118
+ // Upgrade-in-progress guard. The updater helper writes
119
+ // ~/.ccsm/.upgrade.lock at start. If a ccsm:// click (or any other
120
+ // launcher trigger) races during an in-flight install, spawning a
121
+ // new server would: (a) fight npm for the package dir, EBUSY; or
122
+ // (b) bind port 7777 before the helper's own respawn does. Either
123
+ // way the upgrade derails. Bail out instead — the helper's UI on
124
+ // 7779 is already showing the user what's happening.
125
+ const lockPath = path.join(HOME, '.upgrade.lock');
126
+ try {
127
+ const raw = fs.readFileSync(lockPath, 'utf8');
128
+ const lock = JSON.parse(raw);
129
+ const ageMs = Date.now() - (lock.startedAt || 0);
130
+ const ownerAlive = lock.pid ? pidAlive(lock.pid) : false;
131
+ if (ownerAlive && ageMs < 10 * 60_000) {
132
+ console.log(`ccsm: upgrade in progress (helper pid=${lock.pid}, ${Math.round(ageMs/1000)}s ago, target=${lock.target || '?'})`);
133
+ console.log(` see http://localhost:${lock.helperPort || 7779}/ for live progress`);
134
+ process.exit(0);
135
+ }
136
+ // Stale lock (pid dead OR > 10min) — clean up and continue.
137
+ try { fs.unlinkSync(lockPath); } catch {}
138
+ } catch {
139
+ // ENOENT or parse error → no lock, proceed.
140
+ }
141
+
109
142
  // Case 1: existing instance on the preferred port
110
143
  let existing = await probe(port);
111
144
 
package/lib/config.js CHANGED
@@ -152,7 +152,7 @@ function mergeWithDefaults(partial) {
152
152
  // Normalize per-CLI fields.
153
153
  out.clis = out.clis.map((c) => {
154
154
  const { installed, installPath, resumeArgs, ...rest } = c; // strip computed probe fields + v0.x resumeArgs
155
- return {
155
+ const normalized = {
156
156
  ...rest,
157
157
  args: Array.isArray(rest.args) ? rest.args : [],
158
158
  resumeIdArgs: Array.isArray(rest.resumeIdArgs) ? rest.resumeIdArgs : [],
@@ -161,6 +161,25 @@ function mergeWithDefaults(partial) {
161
161
  type: ['claude', 'codex', 'copilot', 'other'].includes(rest.type) ? rest.type : 'other',
162
162
  builtin: !!rest.builtin,
163
163
  };
164
+ // Type-based fallback for non-builtin CLIs (wrappers like `ccp`
165
+ // that just call claude under the hood). If user picked
166
+ // type='claude' but left newSessionIdArgs / resumeIdArgs blank,
167
+ // assume they want the same args claude / copilot / codex use
168
+ // canonically — without this the wrapped CLI gets spawned with
169
+ // no UUID and ccsm can never recapture the upstream session.
170
+ // Builtins are already handled by the loop above with `def`.
171
+ if (!normalized.builtin && normalized.type !== 'other') {
172
+ const template = DEFAULT_CLIS.find((d) => d.type === normalized.type);
173
+ if (template) {
174
+ if (normalized.newSessionIdArgs.length === 0) {
175
+ normalized.newSessionIdArgs = [...template.newSessionIdArgs];
176
+ }
177
+ if (normalized.resumeIdArgs.length === 0) {
178
+ normalized.resumeIdArgs = [...template.resumeIdArgs];
179
+ }
180
+ }
181
+ }
182
+ return normalized;
164
183
  });
165
184
  // Make sure defaultCliId points at an actual CLI; fall back to first.
166
185
  if (!out.clis.find((c) => c.id === out.defaultCliId)) {
package/lib/folders.js CHANGED
@@ -16,15 +16,30 @@ const { atomicWriteJson, withFileLock } = require('./atomicJson');
16
16
 
17
17
  const FILE = path.join(DATA_DIR, 'folders.json');
18
18
 
19
+ // Sentinel for the synthetic "Unsorted" folder. Sessions with
20
+ // folderId === null render under it. We always materialize it in the
21
+ // returned list so the sidebar can drag-reorder it like a real folder,
22
+ // but create/update/delete refuse to touch it.
23
+ const UNSORTED_ID = 'unsorted';
24
+ function unsortedDefault(order) {
25
+ return { id: UNSORTED_ID, name: 'Unsorted', order, builtin: true };
26
+ }
27
+
19
28
  async function loadAll() {
29
+ let list = [];
20
30
  try {
21
31
  const raw = await fs.readFile(FILE, 'utf8');
22
32
  const j = JSON.parse(raw);
23
- return Array.isArray(j) ? j : [];
33
+ if (Array.isArray(j)) list = j;
24
34
  } catch (e) {
25
- if (e.code === 'ENOENT') return [];
26
- throw e;
35
+ if (e.code !== 'ENOENT') throw e;
27
36
  }
37
+ // Ensure the synthetic Unsorted entry is present. New install: append
38
+ // at the end. Existing install pre-Unsorted-draggable: same.
39
+ if (!list.find((f) => f.id === UNSORTED_ID)) {
40
+ list = list.concat(unsortedDefault(list.length));
41
+ }
42
+ return list;
28
43
  }
29
44
 
30
45
  async function saveAll(list) {
@@ -52,13 +67,16 @@ async function create({ name }) {
52
67
  }
53
68
 
54
69
  async function update(id, patch) {
70
+ if (id === UNSORTED_ID && typeof patch.name === 'string') {
71
+ throw new Error('cannot rename the Unsorted bucket');
72
+ }
55
73
  return withFileLock(FILE, async () => {
56
74
  const list = await loadAll();
57
75
  const idx = list.findIndex((f) => f.id === id);
58
76
  if (idx < 0) return null;
59
77
  // Allow rename + reorder, ignore other keys.
60
78
  const allowed = {};
61
- if (typeof patch.name === 'string') allowed.name = patch.name.trim();
79
+ if (id !== UNSORTED_ID && typeof patch.name === 'string') allowed.name = patch.name.trim();
62
80
  if (typeof patch.order === 'number') allowed.order = patch.order;
63
81
  list[idx] = { ...list[idx], ...allowed };
64
82
  await saveAll(list);
@@ -67,6 +85,7 @@ async function update(id, patch) {
67
85
  }
68
86
 
69
87
  async function remove(id) {
88
+ if (id === UNSORTED_ID) throw new Error('cannot delete the Unsorted bucket');
70
89
  return withFileLock(FILE, async () => {
71
90
  const list = await loadAll();
72
91
  const idx = list.findIndex((f) => f.id === id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.16.0",
3
+ "version": "0.17.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",
@@ -301,3 +301,180 @@
301
301
  0%, 100% { box-shadow: 0 0 0 0 rgba(26, 24, 21, 0.25); }
302
302
  50% { box-shadow: 0 0 0 4px rgba(26, 24, 21, 0); }
303
303
  }
304
+
305
+ /* ── Health overlay · full-screen modal while backend is down ─────── */
306
+ .health-overlay {
307
+ position: fixed;
308
+ inset: 0;
309
+ z-index: 9999;
310
+ background: rgba(26, 24, 21, 0.48);
311
+ backdrop-filter: blur(4px);
312
+ -webkit-backdrop-filter: blur(4px);
313
+ display: flex;
314
+ align-items: center;
315
+ justify-content: center;
316
+ animation: health-fade-in 0.2s ease-out;
317
+ }
318
+ @keyframes health-fade-in {
319
+ from { opacity: 0; }
320
+ to { opacity: 1; }
321
+ }
322
+ .health-card {
323
+ background: var(--bg-elev);
324
+ border: 1px solid var(--border);
325
+ border-radius: 14px;
326
+ padding: 36px 44px;
327
+ max-width: 420px;
328
+ text-align: center;
329
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
330
+ font-family: var(--body);
331
+ }
332
+ .health-spinner {
333
+ width: 22px;
334
+ height: 22px;
335
+ border: 2px solid var(--border);
336
+ border-top-color: var(--accent, #4a73a5);
337
+ border-radius: 50%;
338
+ margin: 0 auto 14px;
339
+ animation: health-spin 0.8s linear infinite;
340
+ }
341
+ @keyframes health-spin {
342
+ to { transform: rotate(360deg); }
343
+ }
344
+ .health-dot {
345
+ width: 12px;
346
+ height: 12px;
347
+ border-radius: 50%;
348
+ background: var(--red, #b73f3f);
349
+ margin: 0 auto 14px;
350
+ }
351
+ .health-title {
352
+ font-size: 15px;
353
+ font-weight: 500;
354
+ letter-spacing: -0.005em;
355
+ color: var(--ink);
356
+ margin-bottom: 6px;
357
+ }
358
+ .health-meta {
359
+ font-size: 12.5px;
360
+ color: var(--ink-mid);
361
+ line-height: 1.4;
362
+ margin-bottom: 14px;
363
+ }
364
+ .health-start {
365
+ display: inline-block;
366
+ margin-top: 4px;
367
+ text-decoration: none;
368
+ }
369
+ .health-hint {
370
+ font-size: 11.5px;
371
+ color: var(--ink-muted);
372
+ margin-top: 14px;
373
+ }
374
+ .health-hint code {
375
+ font-family: var(--mono);
376
+ background: var(--bg);
377
+ padding: 1px 5px;
378
+ border-radius: 3px;
379
+ border: 1px solid var(--border);
380
+ }
381
+
382
+ /* ── Keybinding recorder modal ───────────────────────────────────── */
383
+ .kbd-recorder-overlay {
384
+ position: fixed;
385
+ inset: 0;
386
+ z-index: 9999;
387
+ background: rgba(26, 24, 21, 0.5);
388
+ backdrop-filter: blur(4px);
389
+ -webkit-backdrop-filter: blur(4px);
390
+ display: flex;
391
+ align-items: center;
392
+ justify-content: center;
393
+ animation: kbd-rec-fade-in 0.16s ease-out;
394
+ }
395
+ @keyframes kbd-rec-fade-in {
396
+ from { opacity: 0; }
397
+ to { opacity: 1; }
398
+ }
399
+ .kbd-recorder-card {
400
+ background: var(--bg-elev);
401
+ border: 1px solid var(--border);
402
+ border-radius: 14px;
403
+ padding: 32px 40px;
404
+ min-width: 380px;
405
+ max-width: 520px;
406
+ text-align: center;
407
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
408
+ font-family: var(--body);
409
+ }
410
+ .kbd-recorder-label {
411
+ font-size: 12.5px;
412
+ color: var(--ink-mid);
413
+ letter-spacing: 0.02em;
414
+ text-transform: uppercase;
415
+ margin-bottom: 6px;
416
+ }
417
+ .kbd-recorder-action {
418
+ font-size: 18px;
419
+ font-weight: 500;
420
+ letter-spacing: -0.01em;
421
+ color: var(--ink);
422
+ margin-bottom: 24px;
423
+ }
424
+ .kbd-recorder-keys {
425
+ display: flex;
426
+ align-items: center;
427
+ justify-content: center;
428
+ gap: 8px;
429
+ min-height: 48px;
430
+ margin-bottom: 20px;
431
+ flex-wrap: wrap;
432
+ }
433
+ .kbd-recorder-key {
434
+ display: inline-flex;
435
+ align-items: center;
436
+ justify-content: center;
437
+ min-width: 36px;
438
+ height: 36px;
439
+ padding: 0 10px;
440
+ background: var(--bg);
441
+ border: 1px solid var(--border);
442
+ border-bottom-width: 2px;
443
+ border-radius: 6px;
444
+ font-family: var(--mono);
445
+ font-size: 13px;
446
+ font-weight: 500;
447
+ color: var(--ink);
448
+ animation: kbd-rec-pop 0.12s ease-out;
449
+ }
450
+ @keyframes kbd-rec-pop {
451
+ from { transform: scale(0.85); opacity: 0.4; }
452
+ to { transform: scale(1); opacity: 1; }
453
+ }
454
+ .kbd-recorder-plus {
455
+ color: var(--ink-muted);
456
+ font-size: 14px;
457
+ font-weight: 300;
458
+ }
459
+ .kbd-recorder-placeholder {
460
+ color: var(--ink-muted);
461
+ font-size: 13px;
462
+ }
463
+ .kbd-recorder-actions {
464
+ display: flex;
465
+ justify-content: center;
466
+ gap: 8px;
467
+ margin-bottom: 14px;
468
+ }
469
+ .kbd-recorder-hint {
470
+ font-size: 11.5px;
471
+ color: var(--ink-muted);
472
+ }
473
+ .kbd-recorder-hint kbd {
474
+ font-family: var(--mono);
475
+ font-size: 11px;
476
+ background: var(--bg);
477
+ border: 1px solid var(--border);
478
+ border-radius: 3px;
479
+ padding: 1px 5px;
480
+ }
@@ -545,6 +545,23 @@ body.is-resizing-sidebar * {
545
545
  background: var(--sidebar-active);
546
546
  font-weight: 500;
547
547
  }
548
+ /* Drop-line shown above the row when a sibling session is being
549
+ dragged over it for within-folder reorder. Top-only inset shadow
550
+ reads as a 2px insertion mark without shifting layout. */
551
+ .tree-session.is-reorder-target {
552
+ position: relative;
553
+ }
554
+ .tree-session.is-reorder-target::before {
555
+ content: "";
556
+ position: absolute;
557
+ top: -1px;
558
+ left: 4px;
559
+ right: 4px;
560
+ height: 2px;
561
+ background: var(--ink-mid);
562
+ border-radius: 1px;
563
+ pointer-events: none;
564
+ }
548
565
  /* Status dot · deliberately understated. The earlier version had a
549
566
  green dot + soft glow + expanding halo pulse; in a sidebar with
550
567
  eight running sessions it read as a row of strobing alerts. Now:
package/public/js/api.js CHANGED
@@ -187,6 +187,11 @@ export async function setSessionFolder(sessionId, folderId) {
187
187
  await loadSessions();
188
188
  }
189
189
 
190
+ export async function reorderSessions(folderId, ids) {
191
+ await api('POST', '/api/sessions/reorder', { folderId: folderId || null, ids });
192
+ await loadSessions();
193
+ }
194
+
190
195
  export async function setSessionTitle(sessionId, title) {
191
196
  await api('PUT', `/api/sessions/${sessionId}`, { title });
192
197
  await loadSessions();
@@ -280,6 +285,7 @@ export async function restartBackend() {
280
285
  return api('POST', '/api/restart');
281
286
  }
282
287
 
288
+ let consecutiveOffline = 0;
283
289
  export async function pollHealth() {
284
290
  const ctrl = new AbortController();
285
291
  const t = setTimeout(() => ctrl.abort(), 3000);
@@ -287,9 +293,16 @@ export async function pollHealth() {
287
293
  const r = await fetch(httpBase() + '/api/health', { signal: ctrl.signal });
288
294
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
289
295
  const j = await r.json();
290
- S.serverHealth.value = { state: 'online', version: j.version, pid: j.pid };
296
+ consecutiveOffline = 0;
297
+ S.serverHealth.value = { state: 'online', version: j.version, pid: j.pid, failureCount: 0 };
298
+ if (!S.hasBootedOnline.value) S.hasBootedOnline.value = true;
291
299
  } catch (e) {
292
- S.serverHealth.value = { state: 'offline', error: String(e.message || e) };
300
+ consecutiveOffline++;
301
+ S.serverHealth.value = {
302
+ state: 'offline',
303
+ error: String(e.message || e),
304
+ failureCount: consecutiveOffline,
305
+ };
293
306
  } finally {
294
307
  clearTimeout(t);
295
308
  }
@@ -4,6 +4,7 @@ import { Sidebar } from './Sidebar.js';
4
4
  import { Toast } from './Toast.js';
5
5
  import { DialogHost } from './DialogHost.js';
6
6
  import { OfflineBanner } from './OfflineBanner.js';
7
+ import { HealthOverlay } from './HealthOverlay.js';
7
8
  import { SessionsPage } from '../pages/SessionsPage.js';
8
9
  import { LaunchPage } from '../pages/LaunchPage.js';
9
10
  import { ConfigurePage } from '../pages/ConfigurePage.js';
@@ -31,5 +32,6 @@ export function App() {
31
32
  <${OfflineBanner} />
32
33
  <${Toast} />
33
34
  <${DialogHost} />
35
+ <${HealthOverlay} />
34
36
  </div>`;
35
37
  }
@@ -0,0 +1,81 @@
1
+ // Full-screen modal shown while the backend is unreachable.
2
+ //
3
+ // Two phases:
4
+ // - Early (failureCount < THRESHOLD): "Checking backend health…" with
5
+ // a spinner. Most outages resolve in one or two ticks — no need to
6
+ // scare the user.
7
+ // - Persistent (failureCount >= THRESHOLD): "Backend not running.
8
+ // Start backend" with a button that fires the ccsm:// protocol so
9
+ // Windows' registered launcher.vbs spawns ccsm.
10
+ //
11
+ // We never auto-resume. The user has to click the button — protects
12
+ // against repeated wake attempts during an in-flight upgrade or a
13
+ // crash loop.
14
+ //
15
+ // While offline we drive a faster (1.5s) poll loop directly so the
16
+ // modal dismisses promptly when the backend comes back, without
17
+ // waiting for the main 5s refresh interval.
18
+
19
+ import { html } from '../html.js';
20
+ import { useEffect } from 'preact/hooks';
21
+ import { serverHealth, hasBootedOnline } from '../state.js';
22
+ import { pollHealth, refreshAll } from '../api.js';
23
+
24
+ const THRESHOLD = 3; // failures before we switch from "checking" to "not running"
25
+ const FAST_POLL_MS = 1500;
26
+
27
+ export function HealthOverlay() {
28
+ const h = serverHealth.value;
29
+ const offline = h.state === 'offline';
30
+ const count = h.failureCount || 0;
31
+
32
+ // Don't render the overlay during the very first connect attempt
33
+ // (before we've ever been online) — main.js shows nothing prominent
34
+ // there anyway, and the modal flashing on every page load is
35
+ // annoying. Only show after we've seen the backend at least once.
36
+ const everSeen = hasBootedOnline.value;
37
+
38
+ useEffect(() => {
39
+ if (!offline) return;
40
+ const id = setInterval(() => { pollHealth(); }, FAST_POLL_MS);
41
+ return () => clearInterval(id);
42
+ }, [offline]);
43
+
44
+ // When the backend comes back online after we've shown the overlay,
45
+ // refresh all derived state once — sessions/folders/workspaces may
46
+ // have changed during the outage (post-restart, post-upgrade).
47
+ useEffect(() => {
48
+ if (!offline && everSeen) {
49
+ refreshAll().catch(() => {});
50
+ }
51
+ }, [offline]);
52
+
53
+ if (!offline || !everSeen) return null;
54
+
55
+ const showStart = count >= THRESHOLD;
56
+
57
+ return html`
58
+ <div class="health-overlay" role="dialog" aria-modal="true" aria-live="polite">
59
+ <div class="health-card">
60
+ ${!showStart ? html`
61
+ <div class="health-spinner" aria-hidden="true"></div>
62
+ <div class="health-title">Checking backend health…</div>
63
+ <div class="health-meta">
64
+ ${count === 0 ? 'Connecting…' : `${count} attempt${count > 1 ? 's' : ''}`}
65
+ </div>
66
+ ` : html`
67
+ <div class="health-dot" aria-hidden="true"></div>
68
+ <div class="health-title">Backend not running</div>
69
+ <div class="health-meta">
70
+ ${count} failed pings. Wake the backend manually below — we won't auto-restart.
71
+ </div>
72
+ <a class="action primary health-start" href="ccsm://start">
73
+ Start backend
74
+ </a>
75
+ <div class="health-hint">
76
+ Or run <code>ccsm</code> in a terminal.
77
+ </div>
78
+ `}
79
+ </div>
80
+ </div>`;
81
+ }
@@ -0,0 +1,138 @@
1
+ // Full-screen modal that captures a key combo with a live preview of
2
+ // what the user is currently holding. Click to record → push modifiers
3
+ // in real time → release / press a non-modifier key to commit → close.
4
+ // Esc with no modifiers cancels.
5
+
6
+ import { html } from '../html.js';
7
+ import { useEffect, useState } from 'preact/hooks';
8
+
9
+ const MODIFIER_KEYS = new Set(['Control', 'Alt', 'Shift', 'Meta']);
10
+
11
+ function modsFromEvent(ev) {
12
+ const out = [];
13
+ if (ev.ctrlKey) out.push('Ctrl');
14
+ if (ev.altKey) out.push('Alt');
15
+ if (ev.shiftKey) out.push('Shift');
16
+ if (ev.metaKey) out.push('Meta');
17
+ return out;
18
+ }
19
+
20
+ function prettyKey(k) {
21
+ if (k === 'ArrowUp') return '↑';
22
+ if (k === 'ArrowDown') return '↓';
23
+ if (k === 'ArrowLeft') return '←';
24
+ if (k === 'ArrowRight') return '→';
25
+ if (k === ' ') return 'Space';
26
+ if (k === 'Escape') return 'Esc';
27
+ if (/^[a-z]$/.test(k)) return k.toUpperCase();
28
+ return k;
29
+ }
30
+
31
+ export function KeybindingRecorder({ actionLabel, onCommit, onCancel }) {
32
+ // While `captured` is null, we're listening — display reflects whatever
33
+ // is currently held. Once a non-modifier key lands, we freeze that
34
+ // combo into `captured` and surface explicit Confirm / Try again
35
+ // buttons. Pressing another non-modifier key replaces the captured
36
+ // combo (useful when the user mis-pressed and wants to retry without
37
+ // clicking).
38
+ const [mods, setMods] = useState([]);
39
+ const [captured, setCaptured] = useState(null);
40
+
41
+ useEffect(() => {
42
+ const onDown = (ev) => {
43
+ ev.preventDefault();
44
+ ev.stopPropagation();
45
+
46
+ // Esc with no modifiers = cancel. Esc-as-shortcut is allowed when
47
+ // any modifier is also held.
48
+ if (ev.key === 'Escape' && !ev.ctrlKey && !ev.altKey && !ev.shiftKey && !ev.metaKey) {
49
+ onCancel();
50
+ return;
51
+ }
52
+
53
+ // Enter while a combo is captured = confirm (so the user can finish
54
+ // with a single hand on the keyboard, no mouse round-trip).
55
+ if (ev.key === 'Enter' && captured && !ev.ctrlKey && !ev.altKey && !ev.shiftKey && !ev.metaKey) {
56
+ onCommit(captured);
57
+ return;
58
+ }
59
+
60
+ const currentMods = modsFromEvent(ev);
61
+ if (MODIFIER_KEYS.has(ev.key)) {
62
+ setMods(currentMods);
63
+ return;
64
+ }
65
+
66
+ // Real key landed — freeze it; user still has to confirm.
67
+ let k = ev.key;
68
+ if (/^[a-z]$/.test(k)) k = k.toUpperCase();
69
+ const combo = [...currentMods, k].join('+');
70
+ setCaptured(combo);
71
+ };
72
+
73
+ const onUp = (ev) => {
74
+ setMods(modsFromEvent(ev));
75
+ };
76
+
77
+ window.addEventListener('keydown', onDown, true);
78
+ window.addEventListener('keyup', onUp, true);
79
+ return () => {
80
+ window.removeEventListener('keydown', onDown, true);
81
+ window.removeEventListener('keyup', onUp, true);
82
+ };
83
+ }, [onCommit, onCancel, captured]);
84
+
85
+ // The keys shown in the keycap row. While listening: live modifier
86
+ // state + last-pressed key (if captured replaced live state). When
87
+ // captured: parse the frozen combo back into parts so we show the
88
+ // exact thing about to be saved.
89
+ let parts;
90
+ if (captured) {
91
+ parts = captured.split('+');
92
+ } else {
93
+ parts = [...mods];
94
+ }
95
+
96
+ return html`
97
+ <div class="kbd-recorder-overlay" role="dialog" aria-modal="true"
98
+ onClick=${onCancel}>
99
+ <div class="kbd-recorder-card" onClick=${(ev) => ev.stopPropagation()}>
100
+ <div class="kbd-recorder-label">
101
+ ${captured ? 'Captured shortcut for' : 'Press a shortcut for'}
102
+ </div>
103
+ <div class="kbd-recorder-action">${actionLabel}</div>
104
+
105
+ <div class="kbd-recorder-keys">
106
+ ${parts.length === 0
107
+ ? html`<span class="kbd-recorder-placeholder">Press any key combo…</span>`
108
+ : parts.map((p, i) => html`
109
+ <span class="kbd-recorder-key" key=${i}>${prettyKey(p)}</span>
110
+ ${i < parts.length - 1
111
+ ? html`<span class="kbd-recorder-plus">+</span>`
112
+ : null}
113
+ `)}
114
+ </div>
115
+
116
+ ${captured ? html`
117
+ <div class="kbd-recorder-actions">
118
+ <button class="action small subtle" onClick=${() => setCaptured(null)}>
119
+ Try again
120
+ </button>
121
+ <button class="action small subtle" onClick=${onCancel}>
122
+ Cancel
123
+ </button>
124
+ <button class="action small primary" onClick=${() => onCommit(captured)}>
125
+ Confirm
126
+ </button>
127
+ </div>
128
+ <div class="kbd-recorder-hint">
129
+ <kbd>Enter</kbd> to confirm · <kbd>Esc</kbd> to cancel · press another combo to replace
130
+ </div>
131
+ ` : html`
132
+ <div class="kbd-recorder-hint">
133
+ <kbd>Esc</kbd> to cancel · click outside to dismiss
134
+ </div>
135
+ `}
136
+ </div>
137
+ </div>`;
138
+ }