@bakapiano/ccsm 0.15.3 → 0.16.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/CLAUDE.md CHANGED
@@ -445,6 +445,13 @@ achieves the same thing without leaving the browser.
445
445
 
446
446
  ### Release process
447
447
 
448
+ **Do not release or push without explicit permission from the user.**
449
+ This means: don't run `git push`, don't run `npm version`, don't run
450
+ `gh release edit --draft=false`, don't commit + tag in one breath
451
+ because "the fix is ready". Wait for the user to say so. The instinct
452
+ to ship is wrong here — a half-baked release on the public npm registry
453
+ is much worse than a few minutes of waiting.
454
+
448
455
  Three artifacts ship per release: a git tag, a GitHub Release, and an
449
456
  npm publish. The whole thing is CI-driven — you never `npm publish`
450
457
  locally — but it requires you to drive three steps in order:
package/lib/workspace.js CHANGED
@@ -95,8 +95,11 @@ function nextWorkspaceName(existing) {
95
95
  throw new Error('Could not allocate workspace name');
96
96
  }
97
97
 
98
- async function findOrCreateWorkspace({ workDir, repos, requireUnused = true }) {
99
- const all = await listWorkspaces({ workDir, repos });
98
+ async function findOrCreateWorkspace({ workDir, repos, busyPaths = [], requireUnused = true }) {
99
+ // Without busyPaths, every workspace looks free → find() always
100
+ // returns ws-1 → every new session piles into ws-1. Callers must
101
+ // pass the cwds of currently-running persisted sessions.
102
+ const all = await listWorkspaces({ workDir, repos, busyPaths });
100
103
  if (requireUnused) {
101
104
  const free = all.find((w) => !w.inUse);
102
105
  if (free) return { workspace: free, created: false };
@@ -104,7 +107,7 @@ async function findOrCreateWorkspace({ workDir, repos, requireUnused = true }) {
104
107
  const name = nextWorkspaceName(all);
105
108
  const wsPath = path.join(workDir, name);
106
109
  await ensureDir(wsPath);
107
- const ws = await describeWorkspace(wsPath, repos, []);
110
+ const ws = await describeWorkspace(wsPath, repos, busyPaths);
108
111
  return { workspace: ws, created: true };
109
112
  }
110
113
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.15.3",
3
+ "version": "0.16.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,152 @@
1
+ // Global keyboard shortcuts.
2
+ //
3
+ // Stored in localStorage under `ccsm.keybindings` as `{ action: combo }`.
4
+ // Combos are strings like "Ctrl+Alt+ArrowDown" — order doesn't matter
5
+ // at parse time but we normalize when emitting so the UI shows a stable
6
+ // representation.
7
+ //
8
+ // The terminal grabs most keys for itself; we use Ctrl+Alt as the
9
+ // default modifier set because xterm.js / claude / pwsh basically
10
+ // never bind those combos.
11
+
12
+ import { signal } from '@preact/signals';
13
+ import { activeSessionId, sessions, folders, sessionsByFolder, selectSession } from './state.js';
14
+
15
+ export const ACTIONS = {
16
+ 'session-next': { label: 'Next session', defaultCombo: 'Ctrl+Alt+ArrowDown' },
17
+ 'session-prev': { label: 'Previous session', defaultCombo: 'Ctrl+Alt+ArrowUp' },
18
+ };
19
+
20
+ const LS_KEY = 'ccsm.keybindings';
21
+
22
+ function defaults() {
23
+ const out = {};
24
+ for (const [id, def] of Object.entries(ACTIONS)) out[id] = def.defaultCombo;
25
+ return out;
26
+ }
27
+
28
+ function load() {
29
+ try {
30
+ const raw = localStorage.getItem(LS_KEY);
31
+ if (!raw) return defaults();
32
+ const saved = JSON.parse(raw);
33
+ return { ...defaults(), ...saved };
34
+ } catch { return defaults(); }
35
+ }
36
+
37
+ export const keybindings = signal(load());
38
+
39
+ export function setBinding(actionId, combo) {
40
+ if (!ACTIONS[actionId]) return;
41
+ const next = { ...keybindings.value, [actionId]: combo };
42
+ keybindings.value = next;
43
+ try { localStorage.setItem(LS_KEY, JSON.stringify(next)); } catch {}
44
+ }
45
+
46
+ export function resetBinding(actionId) {
47
+ const def = ACTIONS[actionId]?.defaultCombo;
48
+ if (!def) return;
49
+ setBinding(actionId, def);
50
+ }
51
+
52
+ // Normalize a keydown event into a canonical combo string. Order:
53
+ // Ctrl, Alt, Shift, Meta — then the key. Letter keys are uppercased.
54
+ // Arrow keys retain "ArrowUp" / "ArrowDown" form.
55
+ export function comboFromEvent(ev) {
56
+ if (!ev) return '';
57
+ const parts = [];
58
+ if (ev.ctrlKey) parts.push('Ctrl');
59
+ if (ev.altKey) parts.push('Alt');
60
+ if (ev.shiftKey) parts.push('Shift');
61
+ if (ev.metaKey) parts.push('Meta');
62
+ // Skip pure-modifier keydowns — those happen when the user presses
63
+ // just Ctrl/Alt/Shift/Meta on its own and we'd record a useless
64
+ // "Ctrl" combo. The recorder UI uses this; the matcher doesn't.
65
+ if (['Control', 'Alt', 'Shift', 'Meta'].includes(ev.key)) return '';
66
+ let key = ev.key;
67
+ if (/^[a-z]$/.test(key)) key = key.toUpperCase();
68
+ parts.push(key);
69
+ return parts.join('+');
70
+ }
71
+
72
+ // Build a flat list of sessions in the order they appear in the
73
+ // sidebar — folder order first, then sessions within each folder,
74
+ // then the unsorted bucket. Mirrors Sidebar's render order so
75
+ // Next/Prev moves "down then up" visually.
76
+ function flatSidebarOrder() {
77
+ const grouped = sessionsByFolder.value;
78
+ const out = [];
79
+ for (const f of folders.value) {
80
+ const list = grouped.get(f.id) || [];
81
+ for (const s of list) out.push(s.id);
82
+ }
83
+ for (const s of grouped.get(null) || []) out.push(s.id);
84
+ return out;
85
+ }
86
+
87
+ function moveSelection(delta) {
88
+ const ids = flatSidebarOrder();
89
+ if (ids.length === 0) return;
90
+ const cur = activeSessionId.value;
91
+ const idx = cur ? ids.indexOf(cur) : -1;
92
+ let next;
93
+ if (idx < 0) {
94
+ next = delta > 0 ? ids[0] : ids[ids.length - 1];
95
+ } else {
96
+ next = ids[(idx + delta + ids.length) % ids.length];
97
+ }
98
+ if (next) selectSession(next);
99
+ }
100
+
101
+ const HANDLERS = {
102
+ 'session-next': () => moveSelection(+1),
103
+ 'session-prev': () => moveSelection(-1),
104
+ };
105
+
106
+ // Should we suppress shortcut handling because the user is typing into
107
+ // an input / textarea? Terminal's xterm hidden textarea counts too,
108
+ // but we deliberately let our Ctrl+Alt combos through there because
109
+ // the terminal child process never uses them.
110
+ function shouldSuppress(target) {
111
+ if (!target) return false;
112
+ const tag = (target.tagName || '').toLowerCase();
113
+ if (tag === 'input' || tag === 'textarea') {
114
+ // Exception: xterm's helper textarea is a hidden input — we want
115
+ // shortcuts to fire there.
116
+ if (target.classList?.contains('xterm-helper-textarea')) return false;
117
+ return true;
118
+ }
119
+ if (target.isContentEditable) return true;
120
+ return false;
121
+ }
122
+
123
+ let installed = false;
124
+ export function installGlobalKeybindings() {
125
+ if (installed) return;
126
+ installed = true;
127
+ window.addEventListener('keydown', (ev) => {
128
+ if (shouldSuppress(ev.target)) return;
129
+ const combo = comboFromEvent(ev);
130
+ if (!combo) return;
131
+ const map = keybindings.value;
132
+ for (const [action, expected] of Object.entries(map)) {
133
+ if (expected === combo && HANDLERS[action]) {
134
+ ev.preventDefault();
135
+ ev.stopPropagation();
136
+ HANDLERS[action]();
137
+ return;
138
+ }
139
+ }
140
+ }, true);
141
+ }
142
+
143
+ // Pretty-print a combo for display in Settings. Replace verbose key
144
+ // names with shorter symbols where it helps.
145
+ export function formatCombo(combo) {
146
+ if (!combo) return '(unset)';
147
+ return combo
148
+ .replace(/ArrowUp/g, '↑')
149
+ .replace(/ArrowDown/g, '↓')
150
+ .replace(/ArrowLeft/g, '←')
151
+ .replace(/ArrowRight/g, '→');
152
+ }
package/public/js/main.js CHANGED
@@ -9,8 +9,10 @@ import { httpBase } from './backend.js';
9
9
  import { loadConfig, refreshAll, loadSessions, loadFolders, loadWorkspaces, pollHealth } from './api.js';
10
10
  import { setToast } from './toast.js';
11
11
  import { App } from './components/App.js';
12
+ import { installGlobalKeybindings } from './keybindings.js';
12
13
 
13
14
  loadPersisted();
15
+ installGlobalKeybindings();
14
16
  // Window/tab title pinned to "CCSM". A MutationObserver guards against
15
17
  // Chromium standalone builds that occasionally try to inject the URL
16
18
  // into the title bar.
@@ -17,11 +17,12 @@ import {
17
17
  } from '../api.js';
18
18
  import { setToast } from '../toast.js';
19
19
  import { ccsmConfirm } from '../dialog.js';
20
+ import { keybindings, setBinding, resetBinding, ACTIONS, formatCombo, comboFromEvent } from '../keybindings.js';
20
21
  import { Card } from '../components/Card.js';
21
22
  import { PageTitleBar } from '../components/PageTitleBar.js';
22
23
  import { EntityFormModal } from '../components/EntityFormModal.js';
23
24
  import { useDragSort } from '../components/useDragSort.js';
24
- import { IconPlus, IconPencil, IconClose, IconTerminal, IconFolder, IconBranch, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor } from '../icons.js';
25
+ import { IconPlus, IconPencil, IconClose, IconTerminal, IconFolder, IconBranch, IconRefresh, IconChevronUp, IconChevronDown, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor } from '../icons.js';
25
26
 
26
27
  // Type → smart defaults. Choosing a type in the form auto-fills resumeArgs
27
28
  // (and command if blank) so users don't need to remember the per-CLI flag.
@@ -251,6 +252,11 @@ export function ConfigurePage() {
251
252
  <${WorkspaceList} />
252
253
  </${Section}>
253
254
 
255
+ <${Section} title="Keyboard shortcuts"
256
+ meta="Click a binding to record a new combo. Press Esc to cancel.">
257
+ <${KeybindingsList} />
258
+ </${Section}>
259
+
254
260
  </div>
255
261
 
256
262
  ${edit?.kind === 'cli-new' ? html`
@@ -511,3 +517,73 @@ function AccentPicker() {
511
517
  </div>` : null}
512
518
  </div>`;
513
519
  }
520
+
521
+
522
+ // ── Keyboard shortcuts ───────────────────────────────────────────────
523
+ const ACTION_ICONS = {
524
+ 'session-next': IconChevronDown,
525
+ 'session-prev': IconChevronUp,
526
+ };
527
+
528
+ function KeybindingsList() {
529
+ const map = keybindings.value;
530
+ const [recording, setRecording] = useState(null); // actionId or null
531
+
532
+ // While recording, swallow every keydown globally and feed it into
533
+ // comboFromEvent. We use the capture phase so the global shortcut
534
+ // listener never sees the keys and never fires actions mid-record.
535
+ useEffect(() => {
536
+ if (!recording) return;
537
+ const onKey = (ev) => {
538
+ if (ev.key === 'Escape') {
539
+ ev.preventDefault();
540
+ ev.stopPropagation();
541
+ setRecording(null);
542
+ return;
543
+ }
544
+ const combo = comboFromEvent(ev);
545
+ if (!combo) return; // pure-modifier keydown, keep listening
546
+ ev.preventDefault();
547
+ ev.stopPropagation();
548
+ setBinding(recording, combo);
549
+ setRecording(null);
550
+ };
551
+ window.addEventListener('keydown', onKey, true);
552
+ return () => window.removeEventListener('keydown', onKey, true);
553
+ }, [recording]);
554
+
555
+ return html`
556
+ <div class="entity-list">
557
+ ${Object.entries(ACTIONS).map(([id, def]) => {
558
+ const combo = map[id];
559
+ const isRec = recording === id;
560
+ const isCustom = combo !== def.defaultCombo;
561
+ const Icon = ACTION_ICONS[id] || IconTerminal;
562
+ const badges = [{
563
+ label: isRec ? 'press keys…' : formatCombo(combo),
564
+ tone: isRec ? 'warn' : 'accent',
565
+ }];
566
+ return html`
567
+ <div class="entity-row" key=${id}>
568
+ <span class="entity-row-icon"><${Icon} /></span>
569
+ <span class="entity-row-main">
570
+ <span class="entity-row-primary">
571
+ ${def.label}
572
+ ${badges.map((b) => html`
573
+ <span class=${`entity-row-badge tone-${b.tone}`}>${b.label}</span>`)}
574
+ </span>
575
+ <span class="entity-row-secondary">
576
+ <span class="mono">${id}</span> · default <span class="mono">${formatCombo(def.defaultCombo)}</span>
577
+ </span>
578
+ </span>
579
+ <span class="entity-row-actions">
580
+ <button class="entity-row-action" title=${isRec ? 'Cancel (Esc)' : 'Rebind'}
581
+ onClick=${() => setRecording(isRec ? null : id)}><${IconPencil} /></button>
582
+ ${isCustom ? html`
583
+ <button class="entity-row-action" title="Reset to default"
584
+ onClick=${() => resetBinding(id)}><${IconRefresh} /></button>` : null}
585
+ </span>
586
+ </div>`;
587
+ })}
588
+ </div>`;
589
+ }
package/server.js CHANGED
@@ -652,9 +652,17 @@ app.post('/api/sessions/new', async (req, res) => {
652
652
  workspace = all.find((w) => w.name === req.body.workspace);
653
653
  if (!workspace) return fail(`workspace ${req.body.workspace} not found`);
654
654
  } else {
655
+ // Collect cwds of currently-running persisted sessions so
656
+ // findOrCreateWorkspace can flag those workspaces as in-use and
657
+ // skip past ws-1 when it's already occupied.
658
+ const running = await persistedSessions.loadAll();
659
+ const busyPaths = running
660
+ .filter((s) => s.status === 'running')
661
+ .map((s) => s.cwd);
655
662
  const r = await findOrCreateWorkspace({
656
663
  workDir: cfg.workDir,
657
664
  repos: cfg.repos,
665
+ busyPaths,
658
666
  requireUnused: true,
659
667
  });
660
668
  workspace = r.workspace;