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

+5
View File
@@ -0,0 +1,5 @@
---
'aicodeman': minor
---
Shortcut rebinding (App Settings → Shortcuts) now covers Previous Session and the alternate Next Session, which were fixed at Alt/Option+[ and Alt/Option+]. Capturing a chord another action already owns is refused with a message naming that action (Alt+1-9 are reserved for tab switching, Ctrl/Shift+Enter for newline and Ctrl/Cmd+V for paste); re-enabling a disabled shortcut and Reset run the same check. A chord you rebound to an app action no longer also types into the terminal: a rebind to Alt+J used to switch tabs and send ESC j to the session as well.
+1 -1
View File
@@ -683,7 +683,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
### Command palette and shortcut registry
**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `<select>` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ The global Escape handler (app.js) calls every overlay close method on every Escape, in the capture phase and so before the focused element's own Escape handler, which means a close method with side effects beyond hiding must return early when its overlay is not open: `closeCommandPalette()` and `closeSessionManager()` hand focus back on close (`_restoreOverlayFocus()`, panels-ui.js), and run unconditionally that restore stole focus from split Pane B and from any text field, and turned the inline tab rename's Escape into a commit. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over.
**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `<select>` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ The global Escape handler (app.js) calls every overlay close method on every Escape, in the capture phase and so before the focused element's own Escape handler, which means a close method with side effects beyond hiding must return early when its overlay is not open: `closeCommandPalette()` and `closeSessionManager()` hand focus back on close (`_restoreOverlayFocus()`, panels-ui.js), and run unconditionally that restore stole focus from split Pane B and from any text field, and turned the inline tab rename's Escape into a commit. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ Every xterm key handler (Pane A in terminal-ui.js, Pane B in terminal-tile.js) must call `isUserBoundShortcutEvent(ev)`, which is true only for a chord the user REBOUND a generically dispatched action to: the document capture handler's `preventDefault()` does not stop xterm, so a rebind to e.g. Alt+J would otherwise also type `ESC j` into the PTY. Defaults are deliberately not gated (Ctrl+L must reach the CLI for its redraw), so the gate also skips an entry re-captured onto its own default, `toggleSessionSidebar` (it has its own gate, `shouldToggleSessionSidebarFromShortcut`) and copy-selection. Capturing, re-enabling and Reset all go through `findShortcutConflict`/`findShortcutBindingConflict` (settings-ui.js); Alt+1-9, Ctrl/Shift+Enter and Ctrl/Cmd+V are reserved there.
**A key gate on a chord that can carry Shift alone must swallow the KEYPRESS too** (#520). xterm calls the custom handler from `attachCustomKeyEventHandler` for `keydown`, `keypress` AND `keyup`, and its own `_keyPress` drops a keypress carrying Ctrl, Alt or Meta but not one whose only modifier is Shift. So a gate that returns `false` for `ev.type === 'keydown'` alone is safe for a Ctrl or Alt chord (the palette's Ctrl+K, Alt+B, Ctrl+V, Ctrl+Z and the keydown-only smart copy below) and leaks for a Shift-only one. That is how Shift+Enter submitted the prompt from April to #520: its keypress fell through to `onData` as a bare `\r`, which goes out synchronously over the WebSocket ahead of the async `send-key` fetch, so the CR reached the PTY before the newline did. Ctrl+Enter worked all along only because Chrome fires no keypress for it. ⚠️ The Shift/Ctrl+Enter gate in terminal-ui.js and its twin in Pane B (terminal-split.js) return `false` for EVERY event type and key only the `send-key` POST on `keydown`; never narrow either back to keydown, and give any new Shift-only chord the same shape. Swallowing the keyup as well is harmless (xterm only skips a focus and cursor-style refresh for that one event). Tests: `test/shift-enter-keypress-swallowed.test.ts` (CI gate, a source guard on both files) and `test/shift-enter-keypress.browser.test.ts` (browser suite, which the gate does not run: real keys against both real handlers in the real app). Settings → Terminal & Input → **Key tester** shows what a device reports for each of the three events.
+12 -2
View File
@@ -67,6 +67,13 @@ default. See [Tile Grid](Tile-Grid).
**App Settings → Shortcuts.** Bindings live in a registry with per-user overrides, so a
rebind is stored as an override on top of the default rather than replacing the table.
Previous Session (`Alt+[`) and Next Session (alternate, `Alt+]`) are rows like any other,
so they can be rebound too.
A chord another action already uses is refused, and the message names that action. The same
check runs when you re-enable a disabled shortcut and when you press Reset: if its chord was
given to something else in the meantime, it stays as it was. `Alt+1` to `Alt+9`,
`Shift+Enter`/`Ctrl+Enter` and `Ctrl+V`/`Cmd+V` are reserved.
Two things are deliberately not rebindable:
@@ -79,8 +86,11 @@ Two things are deliberately not rebindable:
The terminal sees keystrokes before the app does. Any chord the app claims has to also be
swallowed at the terminal layer, or xterm writes the control byte into the session as well
as triggering the action. If you rebind something to a chord the terminal cares about
(`Ctrl+D`, say), expect the CLI to see it too.
as triggering the action. A chord **you** rebind an action to is kept out of the terminal
too, so rebinding Previous Session to `Alt+J` switches the session and sends nothing to the
CLI. The built-in defaults are deliberately not held back this way: `Ctrl+L` clears the
screen and also reaches the CLI, which redraws on it. Binding an action back onto its own
default behaves like the default.
`Alt+1` through `Alt+9` are matched on **physical key position** rather than the character
produced, so macOS Option layouts that produce `¡™£` still switch tabs.
+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
+6 -5
View File
@@ -14,10 +14,11 @@ describe('keyboard shortcuts', () => {
});
it('provides Option+bracket shortcuts for previous and next session', () => {
expect(appSource).toContain("e.code === 'BracketLeft'");
expect(appSource).toContain("e.code === 'BracketRight'");
expect(appSource).toContain('this.prevSession()');
expect(appSource).toContain('this.nextSession()');
// Registry entries (so they can be rebound), matched by physical key code.
expect(appSource).toMatch(/id: 'previous-session'[\s\S]*?code: 'BracketLeft'[\s\S]*?action: 'previousSession'/);
expect(appSource).toMatch(/id: 'next-session-alt'[\s\S]*?code: 'BracketRight'[\s\S]*?action: 'nextSession'/);
expect(appSource).toContain('previousSession: () => this.prevSession()');
expect(appSource).toContain('nextSession: () => this.nextSession()');
});
it('suppresses xterm PTY injection for the same physical Alt nav codes (no ESC leak)', () => {
@@ -53,7 +54,7 @@ describe('keyboard shortcuts', () => {
// getShortcutRegistry() + matchesShortcutEvent() so overrides and per-shortcut
// disables (App Settings → Shortcuts) actually take effect.
expect(appSource).not.toContain('const SHORTCUTS = [');
expect(appSource).toContain('const SHORTCUT_ACTIONS = {');
expect(appSource).toContain('const SHORTCUT_ACTIONS = this._shortcutActions();');
expect(appSource).toContain('for (const shortcut of this.getShortcutRegistry())');
expect(appSource).toContain('if (this.matchesShortcutEvent(e, shortcut))');
expect(appSource).toContain('if (shortcut.disabled || !shortcut.action) continue;');
+235
View File
@@ -0,0 +1,235 @@
/**
* @fileoverview Behaviour of shortcut rebinding: Alt+[ / Alt+] as registry entries, the
* user-bound gate that keeps a rebound chord out of the PTY, and refusal of duplicate chords.
*
* Real code under test: constants.js + app.js + settings-ui.js in one `vm` context (the
* makeApp() harness of ctrl-w-never-closes.test.ts, with settings-ui.js loaded after app.js).
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
type Shortcut = { id: string; label: string; action?: string; bindings?: unknown[]; disabled?: boolean };
type App = {
getShortcutRegistry(): Shortcut[];
matchesShortcutEvent(e: Record<string, unknown>, s: Shortcut): boolean;
isUserBoundShortcutEvent(e: Record<string, unknown>): boolean;
findShortcutConflict(e: Record<string, unknown>, id: string): string;
onShortcutCaptureKeydown(e: Record<string, unknown>, id: string): void;
toggleShortcutEnabled(id: string, enabled: boolean): void;
resetShortcutOverride(id: string): void;
loadAppSettingsFromStorage: () => Record<string, any>;
saveAppSettingsToStorage: (s: Record<string, any>) => void;
renderShortcutSettingsList: () => void;
showToast: (msg: string, type?: string) => void;
};
function makeApp(initial: Record<string, any> = {}) {
const settings: Record<string, any> = structuredClone(initial);
const context = vm.createContext({
console,
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: vi.fn(),
document: { addEventListener: vi.fn(), getElementById: () => null, querySelector: () => null },
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: { getDeviceType: () => 'desktop', isMobile: () => false, isTouchDevice: () => false },
});
vm.runInContext(`${read('constants.js')}\n${read('app.js')}\nglobalThis.__CodemanApp = CodemanApp;`, context);
vm.runInContext(read('settings-ui.js'), context, { filename: 'settings-ui.js' });
const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
const app = Object.create(CodemanApp.prototype) as App;
app.loadAppSettingsFromStorage = () => settings;
app.saveAppSettingsToStorage = (s) => {
const next = { ...s };
Object.keys(settings).forEach((k) => delete settings[k]);
Object.assign(settings, next);
};
app.renderShortcutSettingsList = vi.fn();
app.showToast = vi.fn();
return { app, settings };
}
const key = (o: Record<string, unknown>) => ({
type: 'keydown',
key: '',
code: '',
ctrlKey: false,
metaKey: false,
shiftKey: false,
altKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...o,
});
const ownerOf = (app: App, e: Record<string, unknown>) =>
app.getShortcutRegistry().find((s) => !s.disabled && s.action && app.matchesShortcutEvent(e, s))?.action;
describe('Alt+[ and Alt+] are registry entries', () => {
it('resolve to previousSession and nextSession by physical key', () => {
const { app } = makeApp();
expect(ownerOf(app, key({ altKey: true, key: '[', code: 'BracketLeft' }))).toBe('previousSession');
expect(ownerOf(app, key({ altKey: true, key: ']', code: 'BracketRight' }))).toBe('nextSession');
});
it('match the macOS Option layout that prints a curly quote for Option+[', () => {
const { app } = makeApp();
expect(ownerOf(app, key({ altKey: true, key: '“', code: 'BracketLeft' }))).toBe('previousSession');
});
it('keep their Alt/Option+[ labels until rebound', () => {
const { app } = makeApp({
shortcutOverrides: { 'next-session-alt': { bindings: [{ modifiers: ['alt'], key: 'j' }] } },
});
const byId = (id: string) =>
app.getShortcutRegistry().find((s) => s.id === id) as Shortcut & { displayBindings?: string[] };
expect(byId('previous-session').displayBindings).toEqual(['Alt/Option+[']);
expect(byId('next-session-alt').displayBindings).toBeUndefined();
});
});
describe('isUserBoundShortcutEvent', () => {
const altJ = key({ altKey: true, key: 'j', code: 'KeyJ' });
it('is false with no overrides, even for a default chord', () => {
const { app } = makeApp();
expect(app.isUserBoundShortcutEvent(key({ altKey: true, key: '[', code: 'BracketLeft' }))).toBe(false);
expect(app.isUserBoundShortcutEvent(key({ ctrlKey: true, key: 'l', code: 'KeyL' }))).toBe(false);
});
it('is true for a chord the user rebound an action to', () => {
const { app } = makeApp({
shortcutOverrides: { 'previous-session': { bindings: [{ modifiers: ['alt'], key: 'j', code: 'KeyJ' }] } },
});
expect(app.isUserBoundShortcutEvent(altJ)).toBe(true);
});
it('is false for the copy-selection rebind, which has its own dispatch', () => {
const { app } = makeApp({
shortcutOverrides: { 'copy-selection': { bindings: [{ modifiers: ['alt'], key: 'j', code: 'KeyJ' }] } },
});
expect(app.isUserBoundShortcutEvent(altJ)).toBe(false);
});
it('is false for a rebound sidebar toggle, whose own gate lets the chord through', () => {
const { app } = makeApp({
shortcutOverrides: { 'toggle-session-sidebar': { bindings: [{ modifiers: ['alt'], key: 's', code: 'KeyS' }] } },
});
expect(app.isUserBoundShortcutEvent(key({ altKey: true, key: 's', code: 'KeyS' }))).toBe(false);
});
it('is false when the entry was re-captured onto its own default (Ctrl+L still reaches the CLI)', () => {
const { app } = makeApp({
shortcutOverrides: { 'clear-terminal': { bindings: [{ modifiers: ['ctrl'], key: 'l', code: 'KeyL' }] } },
});
expect(app.isUserBoundShortcutEvent(key({ ctrlKey: true, key: 'l', code: 'KeyL' }))).toBe(false);
});
it('is false for a disabled rebound entry', () => {
const { app } = makeApp({
shortcutOverrides: {
'previous-session': { bindings: [{ modifiers: ['alt'], key: 'j', code: 'KeyJ' }], disabled: true },
},
});
expect(app.isUserBoundShortcutEvent(altJ)).toBe(false);
});
});
describe('duplicate chords are refused', () => {
const ctrlL = key({ ctrlKey: true, key: 'l', code: 'KeyL' });
it("a capture onto another action's chord names the owner and stores nothing", () => {
const { app, settings } = makeApp();
app.onShortcutCaptureKeydown(ctrlL, 'next-session-alt');
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('already used by Clear Terminal'), 'error');
expect(settings.shortcutOverrides).toBeUndefined();
});
it("a shortcut's own id and a disabled owner are free", () => {
const { app } = makeApp({ shortcutOverrides: { 'previous-session': { disabled: true } } });
expect(app.findShortcutConflict(ctrlL, 'clear-terminal')).toBe('');
expect(app.findShortcutConflict(key({ altKey: true, key: '[', code: 'BracketLeft' }), 'next-session-alt')).toBe('');
});
it.each([
['Alt+1', { altKey: true, key: '¡', code: 'Digit1' }],
['Cmd+Alt+1', { altKey: true, metaKey: true, key: '¡', code: 'Digit1' }],
['Ctrl+Enter', { ctrlKey: true, key: 'Enter', code: 'Enter' }],
['Shift+Enter', { shiftKey: true, key: 'Enter', code: 'Enter' }],
['Ctrl+V', { ctrlKey: true, key: 'v', code: 'KeyV' }],
['Cmd+V', { metaKey: true, key: 'v', code: 'KeyV' }],
])('%s is reserved', (_n, ev) => {
const { app } = makeApp();
expect(app.findShortcutConflict(key(ev), 'previous-session')).not.toBe('');
});
it('no two default shortcuts share a chord', () => {
const { app } = makeApp();
for (const s of app.getShortcutRegistry()) {
for (const b of (s.bindings || []) as { modifiers?: string[]; key?: string; code?: string }[]) {
const m = b.modifiers || [];
const ev = key({
ctrlKey: m.includes('ctrl'),
metaKey: m.includes('meta'),
shiftKey: m.includes('shift'),
altKey: m.includes('alt'),
key: b.key || '',
code: b.code || '',
});
expect({ id: s.id, owner: app.findShortcutConflict(ev, s.id) }).toEqual({ id: s.id, owner: '' });
}
}
});
it('re-enabling a disabled action whose chord was taken is refused and leaves it disabled', () => {
const { app, settings } = makeApp({
shortcutOverrides: {
'clear-terminal': { disabled: true },
'next-session-alt': { bindings: [{ modifiers: ['ctrl'], key: 'l', code: 'KeyL' }] },
},
});
app.toggleShortcutEnabled('clear-terminal', true);
expect(app.showToast).toHaveBeenCalledWith(
expect.stringContaining('already used by Next Session (alternate)'),
'error'
);
expect(settings.shortcutOverrides['clear-terminal'].disabled).toBe(true);
});
it('re-enabling with no clash enables it', () => {
const { app, settings } = makeApp({ shortcutOverrides: { 'clear-terminal': { disabled: true } } });
app.toggleShortcutEnabled('clear-terminal', true);
expect(settings.shortcutOverrides['clear-terminal'].disabled).toBe(false);
});
it('Reset is refused while another action owns the default chord, and works once it is free', () => {
const { app, settings } = makeApp({
shortcutOverrides: {
'clear-terminal': { bindings: [{ modifiers: ['ctrl'], key: 'j', code: 'KeyJ' }] },
'next-session-alt': { bindings: [{ modifiers: ['ctrl'], key: 'l', code: 'KeyL' }] },
},
});
app.resetShortcutOverride('clear-terminal');
expect(app.showToast).toHaveBeenCalledWith(
expect.stringContaining('already used by Next Session (alternate)'),
'error'
);
expect(settings.shortcutOverrides['clear-terminal']).toBeDefined();
app.resetShortcutOverride('next-session-alt');
app.resetShortcutOverride('clear-terminal');
expect(settings.shortcutOverrides['clear-terminal']).toBeUndefined();
});
});
+2 -2
View File
@@ -138,8 +138,8 @@ describe('smart-copy wiring invariants', () => {
// the copy action were reachable from there, Ctrl+C would be swallowed with
// no selection and the user would lose the interrupt key.
const actionsBlock = APP_SOURCE.slice(
APP_SOURCE.indexOf('const SHORTCUT_ACTIONS = {'),
APP_SOURCE.indexOf('// Use capture to handle before terminal')
APP_SOURCE.indexOf('_shortcutActions() {'),
APP_SOURCE.indexOf('isUserBoundShortcutEvent(e) {')
);
expect(actionsBlock.length).toBeGreaterThan(0);
expect(actionsBlock).not.toContain('copyTerminalSelection');