@bakapiano/ccsm 0.16.0 → 0.17.2

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.2",
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,118 @@
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
+ /* HealthOverlay reuses .offline-overlay / .offline-card above. The
306
+ only extra is the spinner shown during the "Checking…" phase — sized
307
+ to fill the .offline-brand slot in place of the BrandMark logo. */
308
+ .health-spinner {
309
+ width: 36px;
310
+ height: 36px;
311
+ border: 3px solid var(--border);
312
+ border-top-color: var(--ink);
313
+ border-radius: 50%;
314
+ animation: health-spin 0.8s linear infinite;
315
+ }
316
+ @keyframes health-spin {
317
+ to { transform: rotate(360deg); }
318
+ }
319
+
320
+ /* ── Keybinding recorder modal ───────────────────────────────────── */
321
+ .kbd-recorder-overlay {
322
+ position: fixed;
323
+ inset: 0;
324
+ z-index: 9999;
325
+ background: rgba(26, 24, 21, 0.5);
326
+ backdrop-filter: blur(4px);
327
+ -webkit-backdrop-filter: blur(4px);
328
+ display: flex;
329
+ align-items: center;
330
+ justify-content: center;
331
+ animation: kbd-rec-fade-in 0.16s ease-out;
332
+ }
333
+ @keyframes kbd-rec-fade-in {
334
+ from { opacity: 0; }
335
+ to { opacity: 1; }
336
+ }
337
+ .kbd-recorder-card {
338
+ background: var(--bg-elev);
339
+ border: 1px solid var(--border);
340
+ border-radius: 14px;
341
+ padding: 32px 40px;
342
+ min-width: 380px;
343
+ max-width: 520px;
344
+ text-align: center;
345
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
346
+ font-family: var(--body);
347
+ }
348
+ .kbd-recorder-label {
349
+ font-size: 12.5px;
350
+ color: var(--ink-mid);
351
+ letter-spacing: 0.02em;
352
+ text-transform: uppercase;
353
+ margin-bottom: 6px;
354
+ }
355
+ .kbd-recorder-action {
356
+ font-size: 18px;
357
+ font-weight: 500;
358
+ letter-spacing: -0.01em;
359
+ color: var(--ink);
360
+ margin-bottom: 24px;
361
+ }
362
+ .kbd-recorder-keys {
363
+ display: flex;
364
+ align-items: center;
365
+ justify-content: center;
366
+ gap: 8px;
367
+ min-height: 48px;
368
+ margin-bottom: 20px;
369
+ flex-wrap: wrap;
370
+ }
371
+ .kbd-recorder-key {
372
+ display: inline-flex;
373
+ align-items: center;
374
+ justify-content: center;
375
+ min-width: 36px;
376
+ height: 36px;
377
+ padding: 0 10px;
378
+ background: var(--bg);
379
+ border: 1px solid var(--border);
380
+ border-bottom-width: 2px;
381
+ border-radius: 6px;
382
+ font-family: var(--mono);
383
+ font-size: 13px;
384
+ font-weight: 500;
385
+ color: var(--ink);
386
+ animation: kbd-rec-pop 0.12s ease-out;
387
+ }
388
+ @keyframes kbd-rec-pop {
389
+ from { transform: scale(0.85); opacity: 0.4; }
390
+ to { transform: scale(1); opacity: 1; }
391
+ }
392
+ .kbd-recorder-plus {
393
+ color: var(--ink-muted);
394
+ font-size: 14px;
395
+ font-weight: 300;
396
+ }
397
+ .kbd-recorder-placeholder {
398
+ color: var(--ink-muted);
399
+ font-size: 13px;
400
+ }
401
+ .kbd-recorder-actions {
402
+ display: flex;
403
+ justify-content: center;
404
+ gap: 8px;
405
+ margin-bottom: 14px;
406
+ }
407
+ .kbd-recorder-hint {
408
+ font-size: 11.5px;
409
+ color: var(--ink-muted);
410
+ }
411
+ .kbd-recorder-hint kbd {
412
+ font-family: var(--mono);
413
+ font-size: 11px;
414
+ background: var(--bg);
415
+ border: 1px solid var(--border);
416
+ border-radius: 3px;
417
+ padding: 1px 5px;
418
+ }
@@ -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
  }
@@ -3,7 +3,7 @@ import { activeTab } from '../state.js';
3
3
  import { Sidebar } from './Sidebar.js';
4
4
  import { Toast } from './Toast.js';
5
5
  import { DialogHost } from './DialogHost.js';
6
- import { OfflineBanner } from './OfflineBanner.js';
6
+ import { HealthOverlay } from './HealthOverlay.js';
7
7
  import { SessionsPage } from '../pages/SessionsPage.js';
8
8
  import { LaunchPage } from '../pages/LaunchPage.js';
9
9
  import { ConfigurePage } from '../pages/ConfigurePage.js';
@@ -28,8 +28,8 @@ export function App() {
28
28
  ${tab === 'about' ? html`<${Panel} name="about"><${AboutPage} /></${Panel}>` : null}
29
29
  </div>
30
30
  </main>
31
- <${OfflineBanner} />
32
31
  <${Toast} />
33
32
  <${DialogHost} />
33
+ <${HealthOverlay} />
34
34
  </div>`;
35
35
  }
@@ -26,7 +26,19 @@ export function EntityFormModal({
26
26
  const [testing, setTesting] = useState(false);
27
27
  const [testResult, setTestResult] = useState(null);
28
28
 
29
- const isReadOnly = (key) => readOnlyKeys.includes(key);
29
+ // A field is read-only if its key is in the static `readOnlyKeys`
30
+ // prop OR its own `readOnly` predicate (called with the current
31
+ // draft) returns true. The predicate lets a field react to other
32
+ // fields' values — e.g. lock newSessionIdArgs once a known `type`
33
+ // is picked, since those args are an integration contract with the
34
+ // upstream CLI, not a user knob.
35
+ const isReadOnly = (field) => {
36
+ if (readOnlyKeys.includes(field.key)) return true;
37
+ if (typeof field.readOnly === 'function') {
38
+ try { return !!field.readOnly(draft); } catch { return false; }
39
+ }
40
+ return !!field.readOnly;
41
+ };
30
42
 
31
43
  const submit = async (ev) => {
32
44
  ev?.preventDefault?.();
@@ -61,7 +73,7 @@ export function EntityFormModal({
61
73
  <span class="entity-field-label">${f.label}</span>
62
74
  ${f.type === 'select' ? html`
63
75
  <select class="input" value=${draft[f.key] || ''}
64
- disabled=${isReadOnly(f.key)}
76
+ disabled=${isReadOnly(f)}
65
77
  onChange=${(e) => {
66
78
  const next = { ...draft, [f.key]: e.target.value };
67
79
  const sideEffects = f.onChange?.(e.target.value, next);
@@ -71,13 +83,13 @@ export function EntityFormModal({
71
83
  <option value=${opt.value}>${opt.label}</option>`)}
72
84
  </select>
73
85
  ` : f.type === 'iconRadio' ? html`
74
- <div class=${`icon-radio${isReadOnly(f.key) ? ' is-disabled' : ''}`}>
86
+ <div class=${`icon-radio${isReadOnly(f) ? ' is-disabled' : ''}`}>
75
87
  ${(f.options || []).map((opt) => html`
76
88
  <button type="button" key=${opt.value}
77
89
  class=${`icon-radio-opt${draft[f.key] === opt.value ? ' is-active' : ''}`}
78
- disabled=${isReadOnly(f.key)}
90
+ disabled=${isReadOnly(f)}
79
91
  onClick=${() => {
80
- if (isReadOnly(f.key)) return;
92
+ if (isReadOnly(f)) return;
81
93
  const next = { ...draft, [f.key]: opt.value };
82
94
  const sideEffects = f.onChange?.(opt.value, next);
83
95
  setDraft(sideEffects ? { ...next, ...sideEffects } : next);
@@ -89,20 +101,20 @@ export function EntityFormModal({
89
101
  ` : f.type === 'checkbox' ? html`
90
102
  <span class="entity-checkbox-row">
91
103
  <input type="checkbox" checked=${!!draft[f.key]}
92
- disabled=${isReadOnly(f.key)}
104
+ disabled=${isReadOnly(f)}
93
105
  onChange=${(e) => setDraft({ ...draft, [f.key]: e.target.checked })} />
94
- ${f.hint ? html`<span class="entity-field-hint">${f.hint}</span>` : null}
106
+ ${f.hint ? html`<span class="entity-field-hint">${typeof f.hint === 'function' ? f.hint(draft) : f.hint}</span>` : null}
95
107
  </span>
96
108
  ` : html`
97
109
  <input type=${f.type || 'text'}
98
110
  class=${`input${f.mono ? ' mono' : ''}`}
99
111
  placeholder=${f.placeholder || ''}
100
112
  value=${draft[f.key] || ''}
101
- readonly=${isReadOnly(f.key)}
113
+ readonly=${isReadOnly(f)}
102
114
  onInput=${(e) => setDraft({ ...draft, [f.key]: e.target.value })}
103
- autoFocus=${f.autoFocus && !isReadOnly(f.key)} />`}
115
+ autoFocus=${f.autoFocus && !isReadOnly(f)} />`}
104
116
  ${f.hint && f.type !== 'checkbox' ? html`
105
- <span class="entity-field-hint">${f.hint}</span>` : null}
117
+ <span class="entity-field-hint">${typeof f.hint === 'function' ? f.hint(draft) : f.hint}</span>` : null}
106
118
  </label>`)}
107
119
  ${testResult ? html`
108
120
  <div class=${`entity-test-result ${testResult.ok ? 'is-ok' : 'is-fail'}`}>
@@ -0,0 +1,91 @@
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
+ import { BrandMark } from '../icons.js';
24
+
25
+ const THRESHOLD = 3; // failures before we switch from "checking" to "not running"
26
+ const FAST_POLL_MS = 1500;
27
+
28
+ export function HealthOverlay() {
29
+ const h = serverHealth.value;
30
+ const offline = h.state === 'offline';
31
+ const count = h.failureCount || 0;
32
+ const everSeen = hasBootedOnline.value;
33
+
34
+ useEffect(() => {
35
+ if (!offline) return;
36
+ const id = setInterval(() => { pollHealth(); }, FAST_POLL_MS);
37
+ return () => clearInterval(id);
38
+ }, [offline]);
39
+
40
+ useEffect(() => {
41
+ if (!offline && everSeen) {
42
+ refreshAll().catch(() => {});
43
+ }
44
+ }, [offline]);
45
+
46
+ if (!offline || !everSeen) return null;
47
+
48
+ const showStart = count >= THRESHOLD;
49
+
50
+ // Reuses the .offline-overlay / .offline-card classes so the card
51
+ // layout (brand mark, big title, copy, primary action button,
52
+ // collapsible npm-install fallback) matches what the OfflineBanner
53
+ // used to render. HealthOverlay differs only in the two states:
54
+ // early polls show a spinner + "Checking…" instead of the static
55
+ // "Backend not running" card.
56
+ return html`
57
+ <div class="offline-overlay" role="dialog" aria-modal="true" aria-live="polite">
58
+ <div class="offline-card">
59
+ <div class="offline-brand">${
60
+ showStart
61
+ ? html`<${BrandMark} />`
62
+ : html`<div class="health-spinner" aria-hidden="true"></div>`
63
+ }</div>
64
+ ${!showStart ? html`
65
+ <h1 class="offline-title">Checking backend health…</h1>
66
+ <p class="offline-copy">
67
+ ${count === 0 ? 'Probing localhost:7777.' : `${count} attempt${count > 1 ? 's' : ''}. Hang tight.`}
68
+ </p>
69
+ ` : html`
70
+ <h1 class="offline-title">Backend not running</h1>
71
+ <p class="offline-copy">
72
+ ccsm's local backend isn't reachable. Wake it manually below — we won't
73
+ auto-restart. Windows may ask once for permission; tick <em>Always allow</em>
74
+ to silence future prompts.
75
+ </p>
76
+ <div class="offline-actions">
77
+ <a class="action primary big" href="ccsm://start">Start backend</a>
78
+ </div>
79
+ <details class="offline-fallback">
80
+ <summary>Don't have ccsm installed?</summary>
81
+ <div class="offline-fallback-body">
82
+ <p>Install once via npm, then come back here:</p>
83
+ <pre><code>npm i -g @bakapiano/ccsm</code></pre>
84
+ <p>Or run a one-shot trial without installing:</p>
85
+ <pre><code>npx @bakapiano/ccsm</code></pre>
86
+ </div>
87
+ </details>
88
+ `}
89
+ </div>
90
+ </div>`;
91
+ }
@@ -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
+ }