diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts
index 219d5eb..c666a43 100644
--- a/src/hooks/useKeyboardShortcuts.ts
+++ b/src/hooks/useKeyboardShortcuts.ts
@@ -7,6 +7,7 @@ import { captureClaudeInput } from '../lib/terminalInput';
import { reportInvokeFailure } from '../lib/errorReporter';
import { readClipboardText } from '../lib/clipboard';
import { cyclableTabIds, nextTabId } from '../lib/tabCycle';
+import { matchesKeyCode } from '../lib/keymap';
/**
* Return true when the focused element is an editable surface that is NOT
@@ -63,7 +64,11 @@ export function useKeyboardShortcuts() {
// top-level document, which throws away all open terminals (they
// aren't persisted). If the preview panel is open, route to the
// preview reload instead.
- if (e.key === 'F5' || (ctrl && !shift && (e.key === 'r' || e.key === 'R'))) {
+ //
+ // Letter accelerators use `matchesKeyCode` so they still fire on
+ // non-Latin layouts (Hebrew/Russian/Arabic/...), where `e.key` would
+ // hold the localized character instead of 'r'. See lib/keymap.ts.
+ if (e.key === 'F5' || (ctrl && !shift && matchesKeyCode(e, 'R'))) {
e.preventDefault();
const previewOpen = usePreviewStore.getState().globalOpen;
const activeId = activeIdRef.current;
@@ -73,19 +78,19 @@ export function useKeyboardShortcuts() {
return;
}
- if (ctrl && shift && e.key === 'N') {
+ if (ctrl && shift && matchesKeyCode(e, 'N')) {
e.preventDefault();
useAppStore.getState().openNewTerminalModal();
}
// Command Palette: Ctrl+P
- if (ctrl && e.key === 'p') {
+ if (ctrl && !shift && matchesKeyCode(e, 'P')) {
e.preventDefault();
useAppStore.getState().toggleCommandPalette();
}
// Snippets: Ctrl+Shift+S
- if (ctrl && shift && e.key === 'S') {
+ if (ctrl && shift && matchesKeyCode(e, 'S')) {
e.preventDefault();
useAppStore.getState().openSnippetsModal();
}
@@ -93,7 +98,7 @@ export function useKeyboardShortcuts() {
// Prompt Editor: Ctrl+Shift+E - compose a prompt for the active terminal,
// seeded with whatever is already typed in its input line. Can be turned
// off in Settings (the status-bar pencil still works).
- if (ctrl && shift && e.key === 'E' && useAppStore.getState().promptEditorShortcutEnabled) {
+ if (ctrl && shift && matchesKeyCode(e, 'E') && useAppStore.getState().promptEditorShortcutEnabled) {
e.preventDefault();
const activeId = activeIdRef.current;
const term = activeId ? terminalsRef.current.get(activeId)?.xterm : undefined;
@@ -102,7 +107,7 @@ export function useKeyboardShortcuts() {
}
// Paste as file: Ctrl+Shift+V
- if (ctrl && shift && e.key === 'V') {
+ if (ctrl && shift && matchesKeyCode(e, 'V')) {
e.preventDefault();
const activeId = activeIdRef.current;
(async () => {
@@ -116,7 +121,7 @@ export function useKeyboardShortcuts() {
}
// Preview panel toggle: Ctrl+Alt+P (Ctrl+Shift+V and Ctrl+Shift+G are taken).
- if (ctrl && e.altKey && !shift && (e.key === 'p' || e.key === 'P')) {
+ if (ctrl && e.altKey && !shift && matchesKeyCode(e, 'P')) {
e.preventDefault();
usePreviewStore.getState().toggleGlobal();
}
@@ -142,17 +147,17 @@ export function useKeyboardShortcuts() {
}
// Global file/content search (VS Code style): Ctrl+Shift+F
- if (ctrl && shift && e.key === 'F') {
+ if (ctrl && shift && matchesKeyCode(e, 'F')) {
e.preventDefault();
useAppStore.getState().toggleGlobalSearch();
}
- if (ctrl && e.key === 'b') {
+ if (ctrl && !shift && matchesKeyCode(e, 'B')) {
e.preventDefault();
useAppStore.getState().toggleSidebar();
}
- if (ctrl && e.key === 'w') {
+ if (ctrl && !shift && matchesKeyCode(e, 'W')) {
e.preventDefault();
const activeId = activeIdRef.current;
if (activeId) {
@@ -164,7 +169,7 @@ export function useKeyboardShortcuts() {
}
// Duplicate active terminal: Ctrl+Shift+D
- if (ctrl && shift && e.key === 'D') {
+ if (ctrl && shift && matchesKeyCode(e, 'D')) {
e.preventDefault();
const activeId = activeIdRef.current;
if (activeId) {
@@ -242,13 +247,13 @@ export function useKeyboardShortcuts() {
}
// Toggle Grid Mode: Ctrl+G
- if (ctrl && e.key === 'g') {
+ if (ctrl && !shift && matchesKeyCode(e, 'G')) {
e.preventDefault();
useAppStore.getState().toggleGridMode();
}
// Push modal: Ctrl+Shift+K (IntelliJ parity)
- if (ctrl && shift && e.key === 'K') {
+ if (ctrl && shift && matchesKeyCode(e, 'K')) {
e.preventDefault();
const activeId = activeIdRef.current;
if (!activeId) {
@@ -270,7 +275,7 @@ export function useKeyboardShortcuts() {
}
// Worktree Modal: Ctrl+Shift+W
- if (ctrl && shift && e.key === 'W') {
+ if (ctrl && shift && matchesKeyCode(e, 'W')) {
e.preventDefault();
const activeId = activeIdRef.current;
if (activeId) {
@@ -286,7 +291,7 @@ export function useKeyboardShortcuts() {
}
// Add current terminal to grid: Ctrl+Shift+G
- if (ctrl && shift && e.key === 'G') {
+ if (ctrl && shift && matchesKeyCode(e, 'G')) {
e.preventDefault();
const activeId = activeIdRef.current;
if (activeId) {
@@ -319,7 +324,12 @@ export function useKeyboardShortcuts() {
// Terminal font zoom. Ctrl+= / Ctrl++ / Ctrl+- / Ctrl+0.
// Skip when the user is in a non-terminal editable surface so that
// e.g. Ctrl+- in a Settings input still selects characters natively.
- if (ctrl && !shift && (e.key === '=' || e.key === '-' || e.key === '0')) {
+ //
+ // Digit 0 uses `matchesKeyCode` so Ctrl+0 zooms-reset even under a layout
+ // that remaps the digit row. `=` and `-` stay on `e.key` because their
+ // physical location varies across layouts (US Equal vs. AZERTY), but
+ // `e.key` reports the intended character reliably.
+ if (ctrl && !shift && (e.key === '=' || e.key === '-' || matchesKeyCode(e, '0'))) {
if (isFocusInNonTerminalEditable()) return;
e.preventDefault();
const { terminalFontSize, setTerminalFontSize } = useAppStore.getState();
diff --git a/src/lib/keymap.test.ts b/src/lib/keymap.test.ts
index 5cbe9f2..7ded92e 100644
--- a/src/lib/keymap.test.ts
+++ b/src/lib/keymap.test.ts
@@ -1,5 +1,14 @@
import { describe, expect, it } from 'vitest';
-import { KEYMAP, keymapByGroup } from './keymap';
+import { KEYMAP, keymapByGroup, matchesKeyCode } from './keymap';
+
+// Build a synthetic KeyboardEvent-like object with just the fields
+// `matchesKeyCode` reads. Using a plain object keeps the tests running under
+// jsdom (real KeyboardEvent needs a Window) and mirrors the shape of what the
+// browser dispatches. Adding `type: 'keydown'` isn't required by the helper
+// but documents intent.
+function evt(code: string, key = ''): KeyboardEvent {
+ return { code, key, type: 'keydown' } as unknown as KeyboardEvent;
+}
describe('keymap', () => {
it('every entry has the required fields', () => {
@@ -40,3 +49,64 @@ describe('keymap', () => {
expect(zoomIn?.shortcut).toMatch(/^(Ctrl|Cmd)\+=$/);
});
});
+
+// #57 - Ctrl+letter shortcuts silently broke under non-Latin layouts because
+// the handlers compared `e.key` (the LOCALIZED character) against a Latin
+// letter. `matchesKeyCode` compares `e.code` (the PHYSICAL key) instead, so
+// Ctrl+V works whether the user is on English, Hebrew, Cyrillic, Arabic, etc.
+describe('matchesKeyCode', () => {
+ it('matches the physical letter key regardless of what character the layout produces', () => {
+ // The physical V key. `e.key` differs per layout:
+ // English → 'v'
+ // Hebrew → 'ה' (Heh)
+ // Cyrillic → 'м' (Cyrillic em)
+ // Arabic → 'ر' (Reh)
+ // Greek → 'ω' (Omega)
+ // Only the physical position ('KeyV') is layout-invariant.
+ expect(matchesKeyCode(evt('KeyV', 'v'), 'V')).toBe(true);
+ expect(matchesKeyCode(evt('KeyV', 'ה'), 'V')).toBe(true);
+ expect(matchesKeyCode(evt('KeyV', 'м'), 'V')).toBe(true);
+ expect(matchesKeyCode(evt('KeyV', 'ر'), 'V')).toBe(true);
+ expect(matchesKeyCode(evt('KeyV', 'ω'), 'V')).toBe(true);
+ });
+
+ it('accepts both lowercase and uppercase letter arguments', () => {
+ // Ergonomic - callers can write matchesKeyCode(e, 'v') or 'V' equivalently.
+ expect(matchesKeyCode(evt('KeyC'), 'c')).toBe(true);
+ expect(matchesKeyCode(evt('KeyC'), 'C')).toBe(true);
+ });
+
+ it('is case-invariant on the code side - CapsLock does not break it', () => {
+ // Under a real CapsLock the browser still emits code 'KeyV'; the change
+ // is in `e.key` only, which the helper does not read. This is why the old
+ // `key.toLowerCase()` hack in TerminalView is no longer necessary.
+ expect(matchesKeyCode(evt('KeyV', 'V'), 'V')).toBe(true);
+ expect(matchesKeyCode(evt('KeyV', 'v'), 'V')).toBe(true);
+ });
+
+ it('matches digits via DigitN codes', () => {
+ // Ctrl+0 zoom-reset. Physical digit row still reports DigitN codes even
+ // when a layout remaps the shifted characters.
+ expect(matchesKeyCode(evt('Digit0'), '0')).toBe(true);
+ expect(matchesKeyCode(evt('Digit9'), '9')).toBe(true);
+ });
+
+ it('does not match other physical keys with the same character', () => {
+ // If a layout happens to place 'V' on a different physical key (or the
+ // user hits a different key producing 'v'), the helper must NOT match -
+ // that would defeat the whole point of using `code`.
+ expect(matchesKeyCode(evt('KeyB', 'v'), 'V')).toBe(false);
+ expect(matchesKeyCode(evt('KeyC', 'v'), 'V')).toBe(false);
+ expect(matchesKeyCode(evt('Numpad0'), '0')).toBe(false);
+ });
+
+ it('rejects non-letter/non-digit arguments defensively', () => {
+ // Callers should use e.key for symbols/function keys; the helper only
+ // handles letters + digits. Returning false (not throwing) keeps the
+ // dispatcher safe if someone passes an unexpected string.
+ expect(matchesKeyCode(evt('KeyV'), 'VV')).toBe(false);
+ expect(matchesKeyCode(evt('KeyV'), '')).toBe(false);
+ expect(matchesKeyCode(evt('Comma', ','), ',')).toBe(false);
+ expect(matchesKeyCode(evt('F1'), 'F1')).toBe(false);
+ });
+});
diff --git a/src/lib/keymap.ts b/src/lib/keymap.ts
index 28008ac..9c76965 100644
--- a/src/lib/keymap.ts
+++ b/src/lib/keymap.ts
@@ -1,6 +1,26 @@
// Shared keymap definitions. Hooks/useKeyboardShortcuts.ts is the actual handler;
// this file is the single source of truth for displayed labels and groups.
+// Layout-independent Ctrl+letter matcher. `KeyboardEvent.key` returns the
+// character produced by the CURRENT keyboard layout - on a Hebrew (or Cyrillic,
+// Arabic, Greek, ...) layout the physical V key produces `e.key === 'ה'`, so
+// `key === 'v'` never matches and every Ctrl+letter accelerator (paste, copy,
+// close, sidebar, ...) silently breaks. `KeyboardEvent.code` reports the
+// physical key's position on a US-QWERTY layout (`'KeyV'`, `'Digit0'`), which
+// is stable across layouts and matches how users think about shortcuts (they
+// find them by physical position, same as VS Code / browsers).
+//
+// Use for letters and digits only - non-letter accelerators (Tab, F1..F8, ,,
+// =, -, \, +) should keep reading `e.key`, whose semantic identity is layout-
+// independent for those keys.
+export function matchesKeyCode(e: KeyboardEvent, letterOrDigit: string): boolean {
+ const s = letterOrDigit.toUpperCase();
+ if (s.length !== 1) return false;
+ if (s >= 'A' && s <= 'Z') return e.code === `Key${s}`;
+ if (s >= '0' && s <= '9') return e.code === `Digit${s}`;
+ return false;
+}
+
export interface KeymapEntry {
id: string;
label: string;