@bakapiano/ccsm 0.15.4 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.15.4",
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
+ }