Merge pull request #617 from opticon454/feat/custom-keybindings

feat(shortcuts): rebindable previous/next session, duplicate-chord refusal, rebinds stay out of the PTY

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-16 01:59:51 +02:00
commit d080004436
11 files changed
+416 -39

No files matched your search

+67 -29
View File
@@ -533,8 +533,8 @@ const DEFAULT_SHORTCUTS = [
group: 'Session',
label: 'Toggle Session Sidebar',
// Alt+B, not Ctrl+B: Ctrl+B must reach the terminal (tmux prefix,
// readline backward-char). The Alt block below claims only Digit1-9 and
// the brackets, and the registry claims Alt for KeyK and Slash only.
// readline backward-char). The Alt block below claims only Digit1-9; the
// brackets are registry entries, and the registry claims Alt for KeyK and Slash only.
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
action: 'toggleSessionSidebar',
},
@@ -638,11 +638,22 @@ const DEFAULT_SHORTCUTS = [
bindings: [],
action: 'removeTile',
},
// Physical key codes, so macOS Option layouts that print "“" for Option+[ still match.
{
id: 'previous-next-session',
id: 'previous-session',
group: 'Session',
label: 'Previous / Next Session',
displayBindings: ['Alt/Option+[', 'Alt/Option+]'],
label: 'Previous Session',
displayBindings: ['Alt/Option+['],
bindings: [{ modifiers: ['alt'], code: 'BracketLeft' }],
action: 'previousSession',
},
{
id: 'next-session-alt',
group: 'Session',
label: 'Next Session (alternate)',
displayBindings: ['Alt/Option+]'],
bindings: [{ modifiers: ['alt'], code: 'BracketRight' }],
action: 'nextSession',
},
{
id: 'switch-tab-n',
@@ -1388,19 +1399,7 @@ class CodemanApp {
// getShortcutRegistry()). The command palette chord is deliberately NOT in
// this map — shouldOpenCommandPaletteFromShortcut() dispatches it above with
// focus-target awareness (it must fire from the terminal but not from inputs).
const SHORTCUT_ACTIONS = {
showShortcutOverlay: () => this.showShortcutOverlay(),
killActiveSession: () => this.killActiveSession(),
nextSession: () => this.nextSession(),
clearTerminal: () => this.clearTerminal(),
restoreTerminalSize: () => this.restoreTerminalSize(),
increaseFontSize: () => this.increaseFontSize(),
decreaseFontSize: () => this.decreaseFontSize(),
toggleVoiceInput: () => VoiceInput.toggle(),
moveActiveTabLeft: () => this.moveActiveTabLeft(),
moveActiveTabRight: () => this.moveActiveTabRight(),
toggleSessionSidebar: () => this.toggleSessionSidebar(),
};
const SHORTCUT_ACTIONS = this._shortcutActions();
// Use capture to handle before terminal
document.addEventListener('keydown', (e) => {
@@ -1463,7 +1462,8 @@ class CodemanApp {
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
// keyboard layouts that emit special characters under Option (Option+1 -> ¡,
// Option+[ -> "“") still switch sessions. e.code is the physical key regardless
// of layout. Option+1-9 = switch by index; Option+[ / Option+] = prev / next.
// of layout. Option+1-9 = switch by index. Option+[ / ] (prev / next) live in the
// shortcut registry, so they can be rebound.
if (e.altKey && !e.ctrlKey && !e.shiftKey) {
const code = e.code || '';
const digitMatch = code.match(/^Digit([1-9])$/);
@@ -1489,16 +1489,6 @@ class CodemanApp {
}
return;
}
if (e.code === 'BracketLeft') {
e.preventDefault();
this.prevSession();
return;
}
if (e.code === 'BracketRight') {
e.preventDefault();
this.nextSession();
return;
}
}
// Tile grid chords: only where they apply (tile-grid.js tileShortcutFor),
@@ -10698,6 +10688,54 @@ class CodemanApp {
}
// ─── Shortcut Registry ───────────────────────────────────────────────────────
/**
* Action name -> handler for the shortcut registry entries the document keydown
* handler dispatches generically (DEFAULT_SHORTCUTS + the user's rebinds). Entries
* absent here (copy selection, the palette, the tile chords) have their own dispatch.
*/
_shortcutActions() {
return {
showShortcutOverlay: () => this.showShortcutOverlay(),
killActiveSession: () => this.killActiveSession(),
nextSession: () => this.nextSession(),
previousSession: () => this.prevSession(),
clearTerminal: () => this.clearTerminal(),
restoreTerminalSize: () => this.restoreTerminalSize(),
increaseFontSize: () => this.increaseFontSize(),
decreaseFontSize: () => this.decreaseFontSize(),
toggleVoiceInput: () => VoiceInput.toggle(),
moveActiveTabLeft: () => this.moveActiveTabLeft(),
moveActiveTabRight: () => this.moveActiveTabRight(),
toggleSessionSidebar: () => this.toggleSessionSidebar(),
};
}
/**
* True when `e` is a chord the USER rebound to a generically dispatched action.
* The document handler has already run the action, but its preventDefault() does not
* stop xterm, so without this gate a rebind to e.g. Alt+J would also type ESC j into the
* session. Defaults are not covered on purpose: several (Ctrl+L) rely on reaching the PTY,
* and neither is a chord that matches the entry's own default (a re-capture of it).
*/
isUserBoundShortcutEvent(e) {
if (!e.ctrlKey && !e.metaKey && !e.altKey) return false;
const overrides = this.loadAppSettingsFromStorage().shortcutOverrides || {};
const actions = this._shortcutActions();
return this.getShortcutRegistry().some((shortcut) => {
if (shortcut.disabled || !Array.isArray(overrides[shortcut.id]?.bindings)) return false;
// toggleSessionSidebar has its own gate (shouldToggleSessionSidebarFromShortcut) that
// lets the chord through when the sidebar layout is off; swallowing it here would make a
// rebound sidebar key dead in the header layout.
if (shortcut.action === 'toggleSessionSidebar') return false;
if (typeof actions[shortcut.action] !== 'function') return false;
if (!this.matchesShortcutEvent(e, shortcut)) return false;
// Re-capturing the entry's own default (Ctrl+L for Clear Terminal) must keep reaching the
// CLI: clearTerminal() relies on ^L for the redraw.
const fallback = DEFAULT_SHORTCUTS.find((d) => d.id === shortcut.id);
return !this.matchesShortcutEvent(e, fallback);
});
}
// Returns the merged shortcut list: DEFAULT_SHORTCUTS with any per-shortcut
// overrides from settings.shortcutOverrides applied on top.
+2
View File
@@ -1179,6 +1179,8 @@
'Close Panels': '关闭面板',
'Previous / Next Session': '上一个 / 下一个会话',
'Next Session': '下一个会话',
'Previous Session': '上一个会话',
'Next Session (alternate)': '下一个会话(备用)',
'Switch to Tab N': '切换到第 N 个标签页',
'Move Active Tab Left': '向左移动当前标签页',
'Move Active Tab Right': '向右移动当前标签页',
+82
View File
@@ -4719,6 +4719,14 @@ Object.assign(CodemanApp.prototype, {
this.showToast?.('Shortcut must include Ctrl, Cmd, or Alt', 'error');
return;
}
// Refuse a chord something else already owns: the registry dispatches the first match,
// so a duplicate would silently take over (or lose to) the other action.
const taken = this.findShortcutConflict(e, shortcutId);
if (taken) {
this.renderShortcutSettingsList();
this.showToast?.(`${this.describeShortcutEvent(e)} is already used by ${taken}`, 'error');
return;
}
const modifiers = [];
if (e.ctrlKey) modifiers.push('ctrl');
if (e.metaKey) modifiers.push('meta');
@@ -4735,7 +4743,70 @@ Object.assign(CodemanApp.prototype, {
this.renderShortcutSettingsList();
},
/** Label of the action that already owns the chord in `e`, or '' when it is free. */
findShortcutConflict(e, shortcutId) {
// Chords the keydown handlers claim before (or outside) the registry: they are fixed, so a
// rebind onto one of them would be dead or would hijack it. Alt+1-9 switch tabs by index
// (the document handler does not exclude Cmd); Ctrl/Shift+Enter insert a newline and
// Ctrl/Cmd+V pastes, and the user-bound gate sits above those branches in the xterm handler.
if (e.altKey && !e.ctrlKey && !e.shiftKey && /^Digit[1-9]$/.test(e.code || '')) {
return 'Switch to Tab N';
}
// (Alt+Shift+Enter is the tile grid's Zoom chord, a registry entry, so Alt stays free.)
if (e.key === 'Enter' && !e.altKey && (e.ctrlKey || e.shiftKey)) return 'Insert Newline';
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && String(e.key).toLowerCase() === 'v') return 'Paste';
if (typeof this.getShortcutRegistry !== 'function' || typeof this.matchesShortcutEvent !== 'function') return '';
const other = this.getShortcutRegistry().find(
(shortcut) =>
shortcut.id !== shortcutId && !shortcut.disabled && Array.isArray(shortcut.bindings) && this.matchesShortcutEvent(e, shortcut)
);
return other ? other.label : '';
},
/**
* Label of the action that already owns a chord in `shortcut`'s bindings, or ''. Used when a
* binding comes back without a capture (re-enable, Reset), where the chord may have been
* given to another action in the meantime.
*/
findShortcutBindingConflict(shortcut) {
for (const binding of shortcut?.bindings || []) {
const mods = binding.modifiers || [];
const taken = this.findShortcutConflict(
{
ctrlKey: mods.includes('ctrl'),
metaKey: mods.includes('meta'),
shiftKey: mods.includes('shift'),
altKey: mods.includes('alt'),
key: binding.key || '',
code: binding.code || '',
},
shortcut.id
);
if (taken) return taken;
}
return '';
},
describeShortcutEvent(e) {
const parts = [];
if (e.ctrlKey) parts.push('Ctrl');
if (e.metaKey) parts.push('Meta');
if (e.shiftKey) parts.push('Shift');
if (e.altKey) parts.push('Alt');
parts.push(e.key && e.key.length === 1 ? e.key.toUpperCase() : e.key || e.code);
return parts.join('+');
},
resetShortcutOverride(shortcutId) {
// Reset puts the default chord back (and re-enables the row): refuse when another action
// has taken that chord since, instead of leaving two actions on one key.
const fallback = typeof DEFAULT_SHORTCUTS !== 'undefined' ? DEFAULT_SHORTCUTS.find((d) => d.id === shortcutId) : null;
const taken = fallback ? this.findShortcutBindingConflict(fallback) : '';
if (taken) {
this.renderShortcutSettingsList();
this.showToast?.(`The default chord is already used by ${taken}`, 'error');
return;
}
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
delete shortcutOverrides[shortcutId];
@@ -4745,6 +4816,17 @@ Object.assign(CodemanApp.prototype, {
},
toggleShortcutEnabled(shortcutId, enabled) {
if (enabled) {
// Re-enabling brings the row's chord back into play: leave it disabled when another action
// has been bound to that chord while it was off.
const shortcut = this.getShortcutRegistry?.().find((s) => s.id === shortcutId);
const taken = this.findShortcutBindingConflict(shortcut);
if (taken) {
this.renderShortcutSettingsList();
this.showToast?.(`Its chord is already used by ${taken}`, 'error');
return;
}
}
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
shortcutOverrides[shortcutId] = { ...(shortcutOverrides[shortcutId] || {}), disabled: !enabled };
+1
View File
@@ -327,6 +327,7 @@
) {
return false;
}
if (ev.type === 'keydown' && global.app?.isUserBoundShortcutEvent?.(ev)) return false;
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
return false;
}
+3
View File
@@ -749,6 +749,9 @@ Object.assign(CodemanApp.prototype, {
return false;
}
// A chord the user rebound to an app action (Settings -> Shortcuts) must not also type into the PTY.
if (ev.type === 'keydown' && this.isUserBoundShortcutEvent?.(ev)) return false;
// Command palette chord (COD-153): keep it out of the PTY. The document
// CAPTURE handler has already opened the palette by the time xterm sees
// this keydown, but its preventDefault() does NOT stop xterm — without