@@ -706,9 +723,7 @@ const KeyboardShortcutsSection: React.FC = () => {
size="xs"
tone={mergedTabConflict ? 'danger' : 'neutral'}
variant={recordingId === MERGED_TAB_RECORD_ID ? 'primary' : 'outline'}
- onClick={() =>
- setRecordingId(recordingId === MERGED_TAB_RECORD_ID ? null : MERGED_TAB_RECORD_ID)
- }
+ onClick={event => toggleRecording(event, MERGED_TAB_RECORD_ID)}
>
{recordingId === MERGED_TAB_RECORD_ID ? t('keyboard.recording') : mergedTabKeyLabel}
@@ -775,9 +790,7 @@ const KeyboardShortcutsSection: React.FC = () => {
size="xs"
tone={mergedSceneConflict ? 'danger' : 'neutral'}
variant={recordingId === MERGED_SCENE_RECORD_ID ? 'primary' : 'outline'}
- onClick={() =>
- setRecordingId(recordingId === MERGED_SCENE_RECORD_ID ? null : MERGED_SCENE_RECORD_ID)
- }
+ onClick={event => toggleRecording(event, MERGED_SCENE_RECORD_ID)}
>
{recordingId === MERGED_SCENE_RECORD_ID ? t('keyboard.recording') : mergedSceneKeyLabel}
@@ -849,7 +862,7 @@ const KeyboardShortcutsSection: React.FC = () => {
size="xs"
tone={conflict ? 'danger' : 'neutral'}
variant={isRecording ? 'primary' : 'outline'}
- onClick={() => setRecordingId(isRecording ? null : reg.id)}
+ onClick={event => toggleRecording(event, reg.id)}
>
{isRecording
? t('keyboard.recording')
diff --git a/src/web-ui/src/app/workbench/ContentResourceView.tsx b/src/web-ui/src/app/workbench/ContentResourceView.tsx
index d82a8ec344..815247ac98 100644
--- a/src/web-ui/src/app/workbench/ContentResourceView.tsx
+++ b/src/web-ui/src/app/workbench/ContentResourceView.tsx
@@ -9,7 +9,7 @@ import { confirmDialogChoice } from '@/infrastructure/confirm-dialog';
import { useI18n } from '@/infrastructure/i18n';
import { useSceneStore } from '../stores/sceneStore';
import { getActiveSurfaceId } from '@/infrastructure/peer-device/deviceSurface';
-import { useShortcut } from '@/infrastructure/hooks/useShortcut';
+import { useTabCloseShortcut } from '../hooks/useTabCloseShortcut';
import { useWorkbenchResize } from './workbenchResize';
export default function ContentResourceView({ resourceId, isActive }: { resourceId: string; isActive: boolean }) {
@@ -41,7 +41,7 @@ export default function ContentResourceView({ resourceId, isActive }: { resource
await document.save();
return !useContentResourceStore.getState().resources[resourceId]?.isDirty;
}), [document, resourceId, t]);
- useShortcut('tab.close', { key: 'w', ctrl: true, scope: 'workbench-content', allowInInput: true },
+ useTabCloseShortcut('workbench-content',
() => useSceneStore.getState().closeScene(`content:${resourceId}`),
{ enabled: isActive && focused, priority: 20, description: 'keyboard.shortcuts.tab.close' });
if (!resource || !document) return null;
diff --git a/src/web-ui/src/app/workbench/WorkbenchLayout.tsx b/src/web-ui/src/app/workbench/WorkbenchLayout.tsx
index f4b9541fe1..e375fcce76 100644
--- a/src/web-ui/src/app/workbench/WorkbenchLayout.tsx
+++ b/src/web-ui/src/app/workbench/WorkbenchLayout.tsx
@@ -79,7 +79,7 @@ export default function WorkbenchLayout({ workspacePath, children, ...windowProp
const renderTabs = (location: WorkbenchPane) =>
{(location === 'primary' || hasSecondaryScenes) &&
}
- {location === canvasPane &&
useSceneStore.getState().selectCanvas()}
onFocusCapture={() => useSceneStore.getState().selectCanvas()}>
diff --git a/src/web-ui/src/infrastructure/hooks/useShortcut.ts b/src/web-ui/src/infrastructure/hooks/useShortcut.ts
index 087108e018..6abbad7ae6 100644
--- a/src/web-ui/src/infrastructure/hooks/useShortcut.ts
+++ b/src/web-ui/src/infrastructure/hooks/useShortcut.ts
@@ -8,6 +8,8 @@ export interface UseShortcutOptions {
priority?: number;
/** Set to false to temporarily disable this shortcut without unregistering. */
enabled?: boolean;
+ /** Restrict this mounted handler to the event's owning surface. */
+ when?: (event: KeyboardEvent) => boolean;
}
/**
@@ -29,6 +31,11 @@ export function useShortcut(
options: UseShortcutOptions = {}
): void {
const { description, priority, enabled = true } = options;
+ const hasOwnerPredicate = Boolean(options.when);
+ const owner = useRef(Symbol(id));
+ const whenRef = useRef(options.when);
+ whenRef.current = options.when;
+ const stableWhen = useRef((event: KeyboardEvent) => whenRef.current?.(event) ?? true);
// Keep the latest callback in a ref so changes don't trigger re-registration.
const callbackRef = useRef(callback);
@@ -46,6 +53,7 @@ export function useShortcut(
meta: config.meta,
scope: config.scope,
allowInInput: config.allowInInput,
+ inheritInChildScopes: config.inheritInChildScopes,
});
useEffect(() => {
@@ -54,9 +62,11 @@ export function useShortcut(
const unregister = shortcutManager.register(id, config, stableCallback.current, {
description,
priority,
+ owner: hasOwnerPredicate ? owner.current : undefined,
+ when: stableWhen.current,
});
return unregister;
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [id, configKey, enabled, description, priority]);
+ }, [id, configKey, enabled, description, priority, hasOwnerPredicate]);
}
diff --git a/src/web-ui/src/infrastructure/services/ShortcutManager.test.ts b/src/web-ui/src/infrastructure/services/ShortcutManager.test.ts
index fc6162704b..bd1a989b99 100644
--- a/src/web-ui/src/infrastructure/services/ShortcutManager.test.ts
+++ b/src/web-ui/src/infrastructure/services/ShortcutManager.test.ts
@@ -101,6 +101,50 @@ describe('ShortcutManager platform primary modifier', () => {
expect(side).not.toHaveBeenCalled();
});
+ it('inherits only opted-in commands through terminal scope and preserves the terminal event otherwise', () => {
+ setPlatform('Win32');
+ const close = vi.fn(), escape = vi.fn(), main = vi.fn();
+ shortcutManager.register('tab.close', { key: 'w', ctrl: true, scope: 'app', allowInInput: true }, main);
+ shortcutManager.register('tab.close', { key: 'w', ctrl: true, scope: 'canvas', allowInInput: true, inheritInChildScopes: true }, close);
+ shortcutManager.register('canvas.escape', { key: 'Escape', scope: 'canvas', allowInInput: true }, escape);
+ const canvas = document.createElement('div');
+ canvas.dataset.shortcutScope = 'canvas';
+ const terminal = document.createElement('textarea');
+ terminal.dataset.shortcutScope = 'terminal';
+ canvas.append(terminal);
+ document.body.append(canvas);
+ terminal.dispatchEvent(new KeyboardEvent('keydown', { key: 'w', ctrlKey: true, bubbles: true, cancelable: true }));
+ const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
+ terminal.dispatchEvent(escapeEvent);
+ expect(close).toHaveBeenCalledOnce();
+ expect(main).not.toHaveBeenCalled();
+ expect(escape).not.toHaveBeenCalled();
+ expect(escapeEvent.defaultPrevented).toBe(false);
+ });
+
+ it('keeps separately owned handlers through override changes and another owner unmounting', () => {
+ setPlatform('Win32');
+ const right = document.createElement('div'), bottom = document.createElement('div');
+ right.dataset.shortcutScope = bottom.dataset.shortcutScope = 'canvas';
+ document.body.append(right, bottom);
+ const onRight = vi.fn(), onBottom = vi.fn();
+ const config = { key: 'w', ctrl: true, scope: 'canvas' as const };
+ shortcutManager.register('tab.close', config, onRight, { owner: Symbol(), when: event => right.contains(event.target as Node) });
+ const removeBottom = shortcutManager.register('tab.close', config, onBottom, { owner: Symbol(), when: event => bottom.contains(event.target as Node) });
+ shortcutManager.loadUserOverrides({ 'tab.close': { key: 'q', alt: true } });
+ const press = (element: HTMLElement) => element.dispatchEvent(new KeyboardEvent('keydown', { key: 'q', altKey: true, bubbles: true, cancelable: true }));
+ press(right); press(bottom);
+ expect(onRight).toHaveBeenCalledOnce();
+ expect(onBottom).toHaveBeenCalledOnce();
+ removeBottom();
+ press(right); press(bottom);
+ expect(onRight).toHaveBeenCalledTimes(2);
+ expect(onBottom).toHaveBeenCalledOnce();
+ expect(shortcutManager.unregister('tab.close')).toBe(true);
+ press(right);
+ expect(onRight).toHaveBeenCalledTimes(2);
+ });
+
afterEach(() => {
shortcutManager.clear();
vi.restoreAllMocks();
diff --git a/src/web-ui/src/infrastructure/services/ShortcutManager.ts b/src/web-ui/src/infrastructure/services/ShortcutManager.ts
index fb906196f2..63e96a43cf 100644
--- a/src/web-ui/src/infrastructure/services/ShortcutManager.ts
+++ b/src/web-ui/src/infrastructure/services/ShortcutManager.ts
@@ -15,6 +15,16 @@ export interface ShortcutRegistration {
callback: ShortcutCallback;
description?: string;
priority: number;
+ /** Restrict a registration to its owning surface before choosing a winner. */
+ when?: (event: KeyboardEvent) => boolean;
+}
+
+export interface ShortcutRegistrationOptions {
+ description?: string;
+ priority?: number;
+ /** Distinguish mounted instances of the same command without changing its settings ID. */
+ owner?: symbol;
+ when?: (event: KeyboardEvent) => boolean;
}
/**
@@ -151,7 +161,9 @@ function makeEventKey(scope: ShortcutScope, event: KeyboardEvent): string {
const c = ctrl ? '1' : '0';
const s = event.shiftKey ? '1' : '0';
const a = event.altKey ? '1' : '0';
- const m = !isMacPlatform() && event.metaKey ? '1' : '0';
+ // Physical Control on macOS is an additional modifier, not logical Ctrl/Cmd.
+ // Keep it distinct so Ctrl+Cmd+W cannot match Cmd+W (or Ctrl+W a plain W).
+ const m = (isMacPlatform() ? event.ctrlKey : event.metaKey) ? '1' : '0';
return `${scope}:${eventKeyForLookup(event)}:${c}${s}${a}${m}`;
}
@@ -159,9 +171,9 @@ export class ShortcutManager {
private static instance: ShortcutManager;
/**
- * All registrations, keyed by shortcut id.
+ * Registrations keyed by scope/command, or by an explicitly mounted owner.
*/
- private registrations: Map = new Map();
+ private registrations: Map = new Map();
/**
* O(1) lookup index: mapKey → sorted registrations (descending priority).
@@ -171,6 +183,7 @@ export class ShortcutManager {
private keyDownHandler: ((e: KeyboardEvent) => void) | null = null;
private isEnabled: boolean = true;
+ private captures = new Map>();
/**
* User overrides from configManager (id → key+modifiers).
@@ -212,12 +225,12 @@ export class ShortcutManager {
id: string,
config: ShortcutConfig,
callback: ShortcutCallback,
- options?: { description?: string; priority?: number }
+ options?: ShortcutRegistrationOptions
): () => void {
// Apply user override if present
const effectiveConfig = this.applyOverride(id, config);
- const registrationKey = `${config.scope ?? 'app'}:${id}`;
+ const registrationKey = options?.owner ?? `${config.scope ?? 'app'}:${id}`;
const existing = this.registrations.get(registrationKey);
if (existing) {
this.removeFromLookupMap(existing);
@@ -230,6 +243,7 @@ export class ShortcutManager {
callback,
description: options?.description,
priority: options?.priority ?? 0,
+ when: options?.when,
};
this.registrations.set(registrationKey, registration);
@@ -281,9 +295,27 @@ export class ShortcutManager {
// ─── Event handling ────────────────────────────────────────────────────────
+ /** A keybinding recorder owns matching events before commands and native fallbacks. */
+ public capture(callback: ShortcutCallback, when: (event: KeyboardEvent) => boolean): () => void {
+ const owner = Symbol('shortcut-capture');
+ this.captures.set(owner, { callback, when });
+ return () => { this.captures.delete(owner); };
+ }
+
private handleKeyDown(event: KeyboardEvent): void {
- if (!this.isEnabled) return;
if (event.defaultPrevented) return;
+ const capture = [...this.captures.values()].reverse().find(owner => owner.when?.(event));
+ if (capture) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ try {
+ capture.callback(event);
+ } catch (error) {
+ log.error('Shortcut capture failed', { error });
+ }
+ return;
+ }
+ if (!this.isEnabled) return;
if (this.isImeOwnedKey(event)) return;
// The latest overlay owns Escape even if focus is still in its trigger.
if (event.key === 'Escape' && hasOverlayLayers()) return;
@@ -319,6 +351,7 @@ export class ShortcutManager {
const appList = this.lookupMap.get(appKey) ?? [];
for (const reg of appList) {
if (inInput && !reg.config.allowInInput) continue;
+ if (reg.when && !reg.when(event)) continue;
results.push(reg);
}
@@ -331,12 +364,25 @@ export class ShortcutManager {
? 'workbench-content' : 'canvas';
const panelScopes: ShortcutScope[] =
activeScope === 'editor' ? ['editor', editorOwner] : [activeScope];
+ // Only opted-in commands cross a nested panel boundary. In particular,
+ // closing a terminal tab belongs to its canvas, while terminal Escape
+ // and other terminal commands retain their own scope.
+ const ancestorScopes = new Set(panelScopes);
+ let ancestor = event.target instanceof Element
+ ? event.target.closest('[data-shortcut-scope]') : null;
+ while (ancestor) {
+ const scope = this.detectScope(ancestor);
+ if (scope !== 'app') ancestorScopes.add(scope);
+ ancestor = ancestor.parentElement?.closest('[data-shortcut-scope]') ?? null;
+ }
const panelRegs: ShortcutRegistration[] = [];
- for (const scope of panelScopes) {
+ for (const scope of ancestorScopes) {
for (const reg of this.lookupMap.get(makeEventKey(scope, event)) ?? []) {
+ if (!panelScopes.includes(scope) && !reg.config.inheritInChildScopes) continue;
if (inInput && !reg.config.allowInInput) {
continue;
}
+ if (reg.when && !reg.when(event)) continue;
panelRegs.push(reg);
}
}
@@ -562,6 +608,7 @@ export class ShortcutManager {
}
public clear(): void {
+ this.captures.clear();
this.registrations.clear();
this.lookupMap.clear();
this.notifyRegistrationListeners();
diff --git a/src/web-ui/src/shared/constants/shortcuts.ts b/src/web-ui/src/shared/constants/shortcuts.ts
index 1fb7daced6..1c01911b0e 100644
--- a/src/web-ui/src/shared/constants/shortcuts.ts
+++ b/src/web-ui/src/shared/constants/shortcuts.ts
@@ -30,6 +30,11 @@ function mod(
// navigation and layout operations that must always be reachable.
export const APP_SHORTCUTS: ShortcutDef[] = [
+ {
+ id: 'tab.close',
+ config: mod('W', { scope: 'app', allowInInput: true }),
+ descriptionKey: 'keyboard.shortcuts.tab.close',
+ },
// Panel layout
{
id: 'panel.toggleLeft',
@@ -114,11 +119,6 @@ export const CANVAS_SHORTCUTS: ShortcutDef[] = [
descriptionKey: 'keyboard.shortcuts.canvas.maximize',
},
// Tab management
- {
- id: 'tab.close',
- config: mod('W', { scope: 'canvas', allowInInput: true }),
- descriptionKey: 'keyboard.shortcuts.tab.close',
- },
{
id: 'tab.reopenClosed',
config: mod('T', { shift: true, scope: 'canvas', allowInInput: true }),
diff --git a/src/web-ui/src/shared/types/shortcut.ts b/src/web-ui/src/shared/types/shortcut.ts
index d686ca9382..965475a1f6 100644
--- a/src/web-ui/src/shared/types/shortcut.ts
+++ b/src/web-ui/src/shared/types/shortcut.ts
@@ -33,4 +33,6 @@ export interface ShortcutConfig {
* element is focused. Defaults to false.
*/
allowInInput?: boolean;
+ /** Also handle nested panel scopes, such as a terminal inside this canvas. */
+ inheritInChildScopes?: boolean;
}