diff --git a/src/web/public/app.js b/src/web/public/app.js
index 2004a9c1..935d9900 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -4350,6 +4350,77 @@ class CodemanApp {
}
}
+ // ─── Shortcut Registry ───────────────────────────────────────────────────────
+ // Returns the merged shortcut list: DEFAULT_SHORTCUTS with any per-shortcut
+ // overrides from settings.shortcutOverrides applied on top.
+
+ getShortcutRegistry() {
+ const settings = this.loadAppSettingsFromStorage();
+ const shortcutOverrides = settings.shortcutOverrides || {};
+ return DEFAULT_SHORTCUTS.map((shortcut) => {
+ const override = shortcutOverrides[shortcut.id];
+ if (!override) return shortcut;
+ return { ...shortcut, ...override };
+ });
+ }
+
+ matchesShortcutEvent(e, shortcut) {
+ if (!shortcut.bindings) return false;
+ return shortcut.bindings.some((binding) => {
+ const mods = binding.modifiers || [];
+ if (mods.includes('ctrl') && !e.ctrlKey) return false;
+ if (mods.includes('meta') && !e.metaKey) return false;
+ if (mods.includes('shift') && !e.shiftKey) return false;
+ if (mods.includes('alt') && !e.altKey) return false;
+ if (!mods.includes('ctrl') && !mods.includes('meta') && (e.ctrlKey || e.metaKey)) return false;
+ if (binding.code) return e.code === binding.code;
+ if (binding.key) return e.key === binding.key || e.key.toLowerCase() === binding.key.toLowerCase();
+ return false;
+ });
+ }
+
+ // ─── Shortcut Overlay Modal ───────────────────────────────────────────────────
+ // Ctrl/Alt+? opens a floating overlay listing all keyboard shortcuts, grouped
+ // by category. Uses the merged registry so user overrides are reflected.
+
+ showShortcutOverlay() {
+ const modal = document.getElementById('shortcutOverlayModal');
+ if (!modal) return;
+ this.renderShortcutOverlay();
+ modal.classList.add('active');
+ modal.focus?.();
+ }
+
+ renderShortcutOverlay() {
+ const list = document.getElementById('shortcutOverlayList');
+ if (!list) return;
+ const registry = this.getShortcutRegistry();
+ const groups = {};
+ for (const shortcut of registry) {
+ const g = shortcut.group || 'General';
+ if (!groups[g]) groups[g] = [];
+ groups[g].push(shortcut);
+ }
+ const fmtBindings = (s) => {
+ if (s.displayBindings) return s.displayBindings.map((b) => `${escapeHtml(b)}`).join(' / ');
+ if (!s.bindings) return '';
+ return s.bindings.map((b) => {
+ const parts = [...(b.modifiers || []).map((m) => m.charAt(0).toUpperCase() + m.slice(1)), b.key || b.code || ''];
+ return `${escapeHtml(parts.join('+'))}`;
+ }).join(' / ');
+ };
+ list.innerHTML = Object.entries(groups).map(([group, items]) =>
+ `
+
+
+
+ Customize keyboard shortcuts. Click the binding to capture a new key combination.
+
+
+
+
+
+
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js
index 1bad3e17..30383f78 100644
--- a/src/web/public/session-ui.js
+++ b/src/web/public/session-ui.js
@@ -668,6 +668,8 @@ Object.assign(CodemanApp.prototype, {
caseData = createCaseData.data.case;
}
+ const selectedCase = (this.cases || []).find(c => c.name === caseName);
+ const isRemoteCase = caseData.location === 'remote' || selectedCase?.location === 'remote';
const workingDir = caseData.path;
if (!workingDir) throw new Error('Case path not found');
@@ -694,7 +696,7 @@ Object.assign(CodemanApp.prototype, {
fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ workingDir, mode: 'shell', name })
+ body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name })
}).then(r => r.json())
);
const createResults = await Promise.all(createPromises);
@@ -1669,7 +1671,14 @@ Object.assign(CodemanApp.prototype, {
// Refresh the dropdown
const select = document.getElementById('quickStartCase');
const currentCase = select.value;
+ if (currentCase === name) {
+ // Blur the native picker before reload so it doesn't show the stale value
+ select.blur?.();
+ }
await this.loadQuickStartCases(currentCase === name ? null : currentCase);
+ if (currentCase === name) {
+ await this.saveLastUsedCase(document.getElementById('quickStartCase')?.value || 'testcase');
+ }
} else {
this.showToast(data.error || 'Failed to delete case', 'error');
}
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js
index 305390ba..ccaf27da 100644
--- a/src/web/public/settings-ui.js
+++ b/src/web/public/settings-ui.js
@@ -2363,6 +2363,80 @@ Object.assign(CodemanApp.prototype, {
}
},
+ // ─── Shortcut Settings (App Settings → Shortcuts tab) ────────────────────────
+ // Renders the list of shortcuts with capture buttons for key rebinding,
+ // and persists overrides under settings.shortcutOverrides.
+
+ renderShortcutSettingsList() {
+ const list = document.getElementById('appSettingsShortcutsList');
+ if (!list) return;
+ const registry = this.getShortcutRegistry ? this.getShortcutRegistry() : (typeof DEFAULT_SHORTCUTS !== 'undefined' ? DEFAULT_SHORTCUTS : []);
+ list.innerHTML = registry.map((shortcut) => {
+ const bindingLabel = shortcut.displayBindings
+ ? shortcut.displayBindings.join(' / ')
+ : (shortcut.bindings || []).map((b) => [...(b.modifiers || []), b.key || b.code || ''].join('+')).join(' / ');
+ return `
+
+
+
+
+
+
`;
+ }).join('');
+ },
+
+ readShortcutOverridesFromSettings() {
+ const settings = this.loadAppSettingsFromStorage();
+ return settings.shortcutOverrides || {};
+ },
+
+ startShortcutCapture(shortcutId) {
+ const input = document.querySelector(`.shortcut-binding-input[data-id="${shortcutId}"]`);
+ if (!input) return;
+ input.value = 'Press keys…';
+ input.focus();
+ this._capturingShortcutId = shortcutId;
+ input.addEventListener('keydown', (e) => this.onShortcutCaptureKeydown(e, shortcutId), { once: true });
+ },
+
+ onShortcutCaptureKeydown(e, shortcutId) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.key === 'Escape') {
+ this.renderShortcutSettingsList();
+ return;
+ }
+ const settings = this.loadAppSettingsFromStorage();
+ const shortcutOverrides = settings.shortcutOverrides || {};
+ const modifiers = [];
+ if (e.ctrlKey) modifiers.push('ctrl');
+ if (e.metaKey) modifiers.push('meta');
+ if (e.shiftKey) modifiers.push('shift');
+ if (e.altKey) modifiers.push('alt');
+ shortcutOverrides[shortcutId] = { bindings: [{ modifiers, key: e.key, code: e.code }] };
+ settings.shortcutOverrides = shortcutOverrides;
+ localStorage.setItem('codeman:settings', JSON.stringify(settings));
+ this._capturingShortcutId = null;
+ this.renderShortcutSettingsList();
+ },
+
+ resetShortcutOverride(shortcutId) {
+ const settings = this.loadAppSettingsFromStorage();
+ const shortcutOverrides = settings.shortcutOverrides || {};
+ delete shortcutOverrides[shortcutId];
+ settings.shortcutOverrides = shortcutOverrides;
+ localStorage.setItem('codeman:settings', JSON.stringify(settings));
+ this.renderShortcutSettingsList();
+ },
+
+ toggleShortcutEnabled(shortcutId, enabled) {
+ const settings = this.loadAppSettingsFromStorage();
+ const shortcutOverrides = settings.shortcutOverrides || {};
+ shortcutOverrides[shortcutId] = { ...(shortcutOverrides[shortcutId] || {}), disabled: !enabled };
+ settings.shortcutOverrides = shortcutOverrides;
+ localStorage.setItem('codeman:settings', JSON.stringify(settings));
+ },
+
closeAllPanels() {
this.closeSessionOptions();
this.closeAppSettings();
diff --git a/test/command-palette-ui.test.ts b/test/command-palette-ui.test.ts
index 88eaf4be..4b68ba9a 100644
--- a/test/command-palette-ui.test.ts
+++ b/test/command-palette-ui.test.ts
@@ -192,9 +192,10 @@ describe('Command-K session palette', () => {
const results = app.buildCommandPaletteItems('bill');
- expect(results.map((item: any) => item.id)).toEqual(['session:sess-beta', 'new-session']);
+ expect(results.map((item: any) => item.id)).toEqual(['session:sess-beta', 'new-session', 'browse-sessions']);
expect(results[0]).toMatchObject({ type: 'session', sessionId: 'sess-beta', title: 'Billing prompt polish' });
expect(results[1]).toMatchObject({ type: 'new-session', title: 'New session' });
+ expect(results[2]).toMatchObject({ type: 'browse-sessions' });
});
it('uses the tab name instead of the short session id for unnamed sessions', () => {