@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 +7 -0
- package/package.json +1 -1
- package/public/js/keybindings.js +152 -0
- package/public/js/main.js +2 -0
- package/public/js/pages/ConfigurePage.js +77 -1
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.
|
|
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
|
+
}
|