diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d16558f2..36cfd313f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa [#599](https://github.com/devswha/herdr-web-ui/pull/599)) ### Changed +- The default mobile terminal key bar puts Esc, Tab and Ctrl+C first, before the held modifiers + and arrows. Saved layouts keep their order. ([#607](https://github.com/devswha/herdr-web-ui/pull/607)) +- **Settings → Shortcuts** explains which keys control the app and marks known browser/OS + reservations without changing saved bindings. ([#607](https://github.com/devswha/herdr-web-ui/pull/607)) - **Settings → Terminal → Clipboard from a pane** is on again: vim, tmux and Claude Code copy to your clipboard from a pane without a trip to Settings. It is also on for anyone 0.4.1 left off without asking (it saved the switch with any other setting). Turn it off if a pane runs output @@ -34,6 +38,9 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ([#610](https://github.com/devswha/herdr-web-ui/pull/610)) ### Fixed +- Command palette shortcut hints follow changed bindings and disappear for disabled ones. + App shortcuts also recognize physical letter keys on non-Latin layouts outside IME composition. + ([#607](https://github.com/devswha/herdr-web-ui/pull/607)) - macOS Safari direct terminal input preserves Korean syllables when the input method replaces text without emitting composition events, including after switching from English. ([#524](https://github.com/devswha/herdr-web-ui/pull/524) by @suho-han) diff --git a/DESIGN.md b/DESIGN.md index cad51bdde..c93703320 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -949,7 +949,7 @@ One set for both themes: the card is island black wherever it shows. and Escape closes. IME candidate, commit and cancel keys stay with composition. Buttons reached with Tab keep their native activation, and arrow navigation scrolls the selected result into view. - Actions cover new workspace, lens/sidebar/theme, settings, notifications, lock and refresh, with - `.kbd` hints where a global shortcut exists. + `.kbd` hints resolved from this browser's shortcut settings; disabled bindings have no hint. ### Settings dialog - A Settings shortcut opened over a file preview places Settings above it (`--z-modal + 2`, one @@ -973,7 +973,7 @@ One set for both themes: the card is island black wherever it shows. (panes open in, show thinking, chat width, chat font size and family; then **Composer**: Enter sends, suggestion chip; then **Quick replies**), **Terminal** (font size and family, wheel speed, input mode, Key bar), **Alerts**, **Voice input**, **Subscription usage**, - **Shortcuts** (the complete platform-resolved list), **Phone & devices** (the phone address, + **Shortcuts** (the platform-resolved global bindings), **Phone & devices** (the phone address, Keep screen on, Install; then paired devices), **Remote PCs**, **About** (Updates, herdr, the repository links). A button that points at Updates opens the dialog on About. - Every page is built from the same parts (`components/SettingsControls.tsx`). A group is an @@ -1021,7 +1021,7 @@ One set for both themes: the card is island black wherever it shows. - xterm has `scrollback: 0`; wheel/touch gestures reach herdr's alternate-screen scrollback. The mount clips its own gutter and hides the unused xterm scrollbar. - Terminal banners stack top-right for ended, reconnecting, observe and held-draft review states. -- The mobile key bar is Esc, Tab, sticky Ctrl/Alt/Shift, Enter, arrows and `^C`; +- The mobile key bar starts with Esc, Tab and `^C`, then sticky Ctrl/Alt/Shift, Enter and arrows; it never steals typing focus. Modifiers remain highlighted until toggled off, leaving the pane/lens or disconnecting; each exposes `aria-pressed`. - That is the default row. Settings → Terminal → Key bar can add, remove and reorder every @@ -1110,7 +1110,16 @@ One set for both themes: the card is island black wherever it shows. - Touch targets grow to `--touch-target`; fields stay `--fs-input` where mobile zoom is a risk. - `prefers-reduced-motion` is honored. Lucide/inline SVG decoration is hidden from assistive tech. - Global shortcuts use the convention **Mod+Shift+key**: Mod is Command on Apple platforms and Ctrl - elsewhere. The settings table is the discoverable source of the complete mapping. + elsewhere. Settings lists the global bindings and allows changing their final key or disabling + them, with duplicate assignments blocked and known browser/OS reservations warned about. + Reservations vary by browser and installed-app mode; a warning is not a guarantee of delivery. + Bindings belong to this browser, not the remote PC or pane. Hold-to-dictate remains fixed. +- Text-field selection and IME composition keep their keys. Focus-local list/tab navigation and + reordering are separate from global bindings; they must not intercept terminal input. + Global actions target the selected pane, and a handled shortcut must never also reach its PTY. +- On touch screens app actions stay in buttons and menus, including the palette in More on phones. + The key bar sends terminal input only; it is not an app-action toolbar. Hardware keyboards use + the same global bindings on phones and desktops. - `document.title` is ` · herdr` while selected, otherwise `herdr web ui`. ### Accepted debt diff --git a/docs/guide.md b/docs/guide.md index 4b14d6f47..1c3641b77 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -253,7 +253,7 @@ whether the test was sent or failed; a missing subscription offers **Turn alerts On a phone: - Agent panes open in the chat. -- The terminal gets a key bar above the keyboard (Esc, Tab, Ctrl, Alt, Shift, Enter, arrows, Ctrl+C). +- The terminal gets a key bar above the keyboard (Esc, Tab, Ctrl+C, Ctrl, Alt, Shift, Enter, arrows). Ctrl, Alt and Shift stay held until tapped again and combine with each other. Enable direct terminal typing with the keyboard button to use them with letters and symbols. Herdr encodes each chord for the program's keyboard protocol; legacy @@ -448,7 +448,7 @@ A message holds the event (`install` or `update`), a random ID made on this PC, ## Keyboard shortcuts -`Mod` is **⌘** on macOS and **Ctrl** elsewhere. Every shortcut adds Shift, so the terminal keeps its own Ctrl keys. +`Mod` is **⌘** on Apple platforms and **Ctrl** elsewhere. Global app shortcuts add Shift, so the terminal keeps its own Ctrl keys. These are the defaults: | Shortcut | Action | | --- | --- | @@ -459,6 +459,20 @@ A message holds the event (`install` or `update`), a random ID made on this PC, | `Mod+Shift+↑` / `↓` | Previous / next pane | | `Mod+Shift+,` | Settings | +**Settings → Shortcuts** changes the final key of each app binding or turns it off. Settings +are saved in this browser, not on the remote PC. The command palette shows the current bindings +and omits disabled ones. Reset shortcuts restores the defaults. Hold-to-dictate +(`Mod+Shift+Space`) remains fixed. + +Known browser/OS reservations are marked, but other combinations can also be intercepted by +your browser, OS or extensions. Installed-app mode may behave differently. While an IME is +composing, keys stay with it. In text fields, Mod+Shift+arrows keep their text-selection behavior. +List and tab controls also have focus-local keys; those do not become global terminal shortcuts. + +On a phone, use the header and More menu for app actions; Command palette is available there +without a shortcut. The terminal key bar is configured separately under **Settings → Terminal**. +An external keyboard uses the same app bindings as a desktop. + Enter sends and Shift+Enter adds a line. Settings can switch sending to Mod+Enter. ## How it works diff --git a/docs/screenshots/shortcuts/key-bar-phone.png b/docs/screenshots/shortcuts/key-bar-phone.png new file mode 100644 index 000000000..5d98a06fe Binary files /dev/null and b/docs/screenshots/shortcuts/key-bar-phone.png differ diff --git a/docs/screenshots/shortcuts/settings-phone.png b/docs/screenshots/shortcuts/settings-phone.png new file mode 100644 index 000000000..331cddf85 Binary files /dev/null and b/docs/screenshots/shortcuts/settings-phone.png differ diff --git a/docs/terminal-input.md b/docs/terminal-input.md index 8fae9d140..d60e0937d 100644 --- a/docs/terminal-input.md +++ b/docs/terminal-input.md @@ -11,8 +11,10 @@ user previously chose direct typing; a fine pointer uses direct typing. The key button switches modes too, on a touch screen only: a desktop changes the mode in Settings. Settings → Shortcuts can change each app action's Mod+Shift key or return its keys to the terminal. The hold-to-dictate binding remains fixed. Conflicts include the legacy New workspace -alias, and Reset restores the defaults. Browser-reserved keys still depend on the browser and -installed-app mode. +alias, and Reset restores the defaults. Palette hints reflect the saved bindings and disappear +when a binding is off. Known browser/OS reservations are marked; delivery still depends on the +browser, OS and installed-app mode. These settings belong to this browser, not the remote PC. +Global bindings do not replace focus-local list/tab controls or text-field selection keys. The input line keeps its unsent text per `paneStorageId(machineId, paneId)`, including across lens changes and reloads. Storage refusal falls back to memory for view changes. Pending sends @@ -48,12 +50,15 @@ left out whole and the notice says some input was left out, also when nothing el draft is replayed on reconnect. The input-line and chat Send buttons preserve an active composition, and the key bar waits for composition to finish. Leaving the input clears its composition guard. -The key bar defaults to Esc, Tab, Ctrl, Alt, Shift, Enter, the arrows and ^C. +The key bar defaults to Esc, Tab, ^C, Ctrl, Alt, Shift, Enter and the arrows. Esc, Tab and ^C +come first so they stay reachable without scrolling on narrow phones. Settings → Terminal → Key bar → Edit key bar opens the complete list: add or remove keys, move each key up or down, and register a custom combination such as Ctrl+W. The catalog includes editing keys and F1–F12; a custom combination can use any single printable character, including space and `+`. Existing extra-key preferences migrate to the same visible order. Restore defaults returns the -original bar; an empty list stays empty. The keyboard mode button stays first on touch screens. +default bar; an empty list stays empty. Saved custom layouts keep their order. +The keyboard mode button stays first on touch screens. App actions belong in the header and +menus, not this terminal-input row. Ctrl, Alt and Shift stay held until tapped again and can be combined. Removing a modifier button clears its held state immediately. Ordinary key buttons use the held modifiers, while saved combinations send exactly their configured modifiers without changing the held state. diff --git a/scripts/key-bar-customization-demo-regression.ts b/scripts/key-bar-customization-demo-regression.ts index fb9c858b8..c5265299b 100644 --- a/scripts/key-bar-customization-demo-regression.ts +++ b/scripts/key-bar-customization-demo-regression.ts @@ -15,6 +15,7 @@ const app = mkdtempSync(join(tmpdir(), "herdr-key-bar-demo-")); const evidence = process.env.UI_EVIDENCE_DIR; const SETTINGS_KEY = "herdr-web-ui:settings"; const MIGRATED_KEYS = ["direct", "Escape", "Tab", "BackTab", "Control", "Alt", "Shift", "Enter", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown", "ctrl-c", "ctrl-d", "ctrl-z", "pipe", "tilde", "slash"]; +const DEFAULT_KEY_BAR_KEYS = ["direct", "Escape", "Tab", "ctrl-c", "Control", "Alt", "Shift", "Enter", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]; interface InputFrame { type: "input" | "keys"; pane_id: string; text?: string; keys?: string[] } const framesOf = (page: Page): Promise => page.evaluate(() => (window as unknown as { keyBarFrames: InputFrame[] }).keyBarFrames); @@ -271,9 +272,148 @@ try { dialog = await openSettings(page); await dialog.locator(".key-bar-settings").getByRole("button", { name: "Restore defaults", exact: true }).tap(); await closeSettings(page, dialog); - assert.deepEqual(await keysOf(page), ["direct", "Escape", "Tab", "Control", "Alt", "Shift", "Enter", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "ctrl-c"]); + assert.deepEqual(await keysOf(page), DEFAULT_KEY_BAR_KEYS); + for (const width of [320, 390]) { + await page.setViewportSize({ width, height: 844 }); + await settled(page); + const controlC = await page.evaluate(() => { + const bar = document.querySelector(".key-bar"); + const key = bar?.querySelector('[data-key="ctrl-c"]'); + if (!bar || !key) return null; + bar.scrollLeft = 0; + const barRect = bar.getBoundingClientRect(); + const keyRect = key.getBoundingClientRect(); + return { + visibleAtStart: getComputedStyle(bar).display !== "none" + && keyRect.left >= barRect.left + && keyRect.right <= barRect.left + bar.clientWidth, + scrollLeft: bar.scrollLeft, + }; + }); + assert.deepEqual(controlC, { visibleAtStart: true, scrollLeft: 0 }, `Ctrl+C is visible without horizontal scrolling at ${width}px`); + if (evidence && width === 390) { + mkdirSync(evidence, { recursive: true }); + await appFaces(page); + await page.screenshot({ path: join(evidence, "key-bar-defaults-390.png") }); + } + } + + await page.setViewportSize({ width: 1280, height: 844 }); + await settled(page); + const palette = page.getByRole("dialog", { name: "Command palette", exact: true }); + const openSidebarAction = async (): Promise => { + await page.keyboard.press("ControlOrMeta+Shift+k"); + await palette.waitFor({ timeout: 5_000 }); + await palette.getByRole("searchbox", { name: "Search panes and actions", exact: true }).fill("Toggle sidebar"); + const action = palette.getByRole("option").filter({ hasText: "Toggle sidebar" }); + await action.waitFor({ state: "visible", timeout: 5_000 }); + return action; + }; + const closePalette = async (): Promise => { + await page.keyboard.press("Escape"); + await palette.waitFor({ state: "hidden", timeout: 5_000 }); + }; + const modifierLabel = await page.evaluate(() => /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent) ? "⌘" : "Ctrl"); + const expectSidebarHint = async (action: Locator, key: string): Promise => { + const hint = action.locator(".palette-shortcut"); + await hint.waitFor({ state: "visible", timeout: 5_000 }); + assert.equal(await hint.getAttribute("aria-label"), `${modifierLabel} + Shift + ${key}`); + assert.deepEqual(await hint.locator("kbd").allTextContents(), [modifierLabel, "Shift", key]); + }; + const openShortcutSettings = async (): Promise => { + await page.keyboard.press("ControlOrMeta+Shift+Comma"); + const settings = page.getByRole("dialog", { name: "Settings", exact: true }); + await settings.waitFor({ timeout: 5_000 }); + await openSettingsPage(page, "Shortcuts"); + return settings; + }; + let action = await openSidebarAction(); + await expectSidebarHint(action, "B"); + await closePalette(); + + dialog = await openShortcutSettings(); + let sidebarShortcut = dialog.getByRole("combobox", { name: "Toggle sidebar", exact: true }); + await sidebarShortcut.selectOption("x"); + await page.waitForFunction((key) => JSON.parse(localStorage.getItem(key)!).shortcutOverrides["toggle-sidebar"] === "x", SETTINGS_KEY, { timeout: 5_000 }); + await closeSettings(page, dialog); + action = await openSidebarAction(); + await expectSidebarHint(action, "X"); + await closePalette(); + await page.reload(); + await ready(page); + const savedOverrides = (await settingsOf(page)).shortcutOverrides as Record; + assert.equal(savedOverrides["toggle-sidebar"], "x", "a shortcut rebinding survives reload"); + action = await openSidebarAction(); + await expectSidebarHint(action, "X"); + await closePalette(); + + dialog = await openShortcutSettings(); + sidebarShortcut = dialog.getByRole("combobox", { name: "Toggle sidebar", exact: true }); + await sidebarShortcut.selectOption("off"); + await page.waitForFunction((key) => JSON.parse(localStorage.getItem(key)!).shortcutOverrides["toggle-sidebar"] === null, SETTINGS_KEY, { timeout: 5_000 }); + await closeSettings(page, dialog); + action = await openSidebarAction(); + assert.equal(await action.locator(".palette-shortcut").count(), 0, "disabling a shortcut removes its CommandPalette hint"); + await closePalette(); + + dialog = await openShortcutSettings(); + sidebarShortcut = dialog.getByRole("combobox", { name: "Toggle sidebar", exact: true }); + assert.match(await sidebarShortcut.locator('option[value="t"]').innerText(), /Reserved/); + await sidebarShortcut.selectOption("t"); + await dialog.getByText(`Your browser or operating system may intercept ${modifierLabel}+⇧+T.`, { exact: true }).waitFor({ timeout: 5_000 }); + assert.equal(((await settingsOf(page)).shortcutOverrides as Record)["toggle-sidebar"], "t", "a reservation warns without silently changing the configured key"); + await dialog.getByRole("button", { name: "Reset shortcuts", exact: true }).click(); + await page.waitForFunction((key) => Object.keys(JSON.parse(localStorage.getItem(key)!).shortcutOverrides).length === 0, SETTINGS_KEY, { timeout: 5_000 }); + sidebarShortcut = dialog.getByRole("combobox", { name: "Toggle sidebar", exact: true }); + await page.waitForFunction(() => (document.querySelector('.settings-dialog select[aria-label="Toggle sidebar"]')?.value ?? "") === "default", undefined, { timeout: 5_000 }); + if (evidence) { + mkdirSync(evidence, { recursive: true }); + await sidebarShortcut.scrollIntoViewIfNeeded(); + await appFaces(page); + await page.screenshot({ path: join(evidence, "shortcuts-settings-defaults.png") }); + await page.setViewportSize({ width: 390, height: 844 }); + await settled(page); + await dialog.locator(".settings-body").evaluate((node) => { node.scrollTop = 0; }); + await page.screenshot({ path: join(evidence, "shortcuts-settings-phone.png") }); + await page.setViewportSize({ width: 1280, height: 844 }); + await settled(page); + } + await closeSettings(page, dialog); + action = await openSidebarAction(); + await expectSidebarHint(action, "B"); + if (evidence) { + await appFaces(page); + await page.screenshot({ path: join(evidence, "command-palette-shortcut-defaults.png") }); + } + await closePalette(); + + const dispatchPhysicalK = async (isComposing: boolean): Promise => { + await input.focus(); + await input.evaluate((node, composing) => { + const mac = /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent); + node.dispatchEvent(new KeyboardEvent("keydown", { + key: "ㅏ", + code: "KeyK", + ctrlKey: !mac, + metaKey: mac, + shiftKey: true, + isComposing: composing, + bubbles: true, + cancelable: true, + })); + }, isComposing); + }; + const beforePhysicalShortcut = (await framesOf(page)).length; + await dispatchPhysicalK(false); + await palette.waitFor({ state: "visible", timeout: 5_000 }); + await settled(page); + assert.equal((await framesOf(page)).length, beforePhysicalShortcut, "a non-Latin physical Mod+Shift+K opens the palette without sending terminal input"); + await closePalette(); + await dispatchPhysicalK(true); + await settled(page); + assert.equal(await palette.count(), 0, "the composing equivalent does not open the command palette"); assert.deepEqual(errors, []); - console.log("PASS an empty key list keeps the keyboard mode toggle usable, persists, and can restore the defaults"); + console.log("PASS restoring defaults keeps Ctrl+C visible; shortcut hints update, disable and reset; physical non-Latin Mod+Shift+K opens the palette without terminal input, but composition does not"); // The frames above go to an agent pane, which the demo never types into. Its shell must // answer a chord as a terminal would: the demo has no herdr to turn the names into keys. diff --git a/src/AGENTS.md b/src/AGENTS.md index 6a089778f..6684110b1 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -35,6 +35,7 @@ React 18 + xterm.js browser client. The render-state machine, xterm lifecycle an - Snapshot reads in App go through `SnapshotRequests`: an event or a newer read supersedes an in-flight fetch, its error included. - Chat path links (lib/filePaths.ts, ASCII `[\w@.+-]` names) and terminal path links (lib/terminalFileLinks.ts, Unicode letters) are separate matchers; a change to path recognition checks both. - Helpers outside React call the module-level `t`; components take `useT()`, whose identity changes with the language, so a memo that formats text lists it. +- Global shortcut hints resolve through `shortcutDisplayKeys` with the current `shortcutOverrides`, never the defaults alone. Global bindings remain Mod+Shift; focus-local list/tab keys are separate and never intercept terminal input. The touch key bar sends terminal keys, not app actions. - Work-block counts skip todo tools (`isTodoTool`: `TodoWrite` would match the write category). The Agents list never includes a shell just because it reports working or blocked. Pane-status pushes never trigger a worktree inventory poll. ## WHERE TO LOOK @@ -54,7 +55,7 @@ React 18 + xterm.js browser client. The render-state machine, xterm lifecycle an | New workspace / New tab | components/NewSessionDialog.tsx | `createWorkspace`, or `createTab` (POST /api/tab/create) when given a `tab` prop; agent start may take 60s and the dialog stays open while pending; `agent_started:false` + error offers to open the already-created workspace | | Command palette | components/CommandPalette.tsx + lib/paletteSearch.ts | Mod+Shift+K; panes (recent first, fuzzy ranked) + actions with `.kbd` hints; Escape closes | | Settings | components/SettingsDialog.tsx + SettingsControls.tsx + lib/settings.ts | `SettingsProvider` wraps App in main.tsx and writes `data-theme` / `data-density` on ``; the dialog shows one page at a time (`PAGES`; a phone lists them first) and mounts per opening, so a page's requests start when it is opened; a new setting is a `SettingsRow` in a `SettingsGroup` with one of the SettingsControls, never markup of its own; the dialog's accessible name stays Settings on every page, and a browser script reaches a page through `openSettingsPage` (scripts/settings-page.ts); each step in (dialog, a phone's page, key bar editor) is a history entry kept by lib/settingsHistory.ts, so open and close go through App's `settingsOpen` and the dialog's own state only, never a `history` call of their own; PhonePanel.tsx picks the address that works through lib/phone.ts `phonePlan`; QrCode.tsx is always dark-on-white | -| Shortcuts | lib/shortcuts.ts | `useShortcuts(actions, enabled)`: capture-phase window keydown; every binding is Mod+Shift+key so the pty keeps Ctrl+; `SHORTCUTS` drives the hint tables | +| Shortcuts | lib/shortcuts.ts | `useShortcuts(actions, enabled)`: capture-phase window keydown; every global binding is Mod+Shift+key so the pty keeps Ctrl+; `SHORTCUTS` defines defaults and `shortcutDisplayKeys` resolves user-facing hints | | OSC 52 clipboard | lib/osc52.ts + PaneTerminal `parser.registerOscHandler(52)` | client half of a clipboard bridge; DORMANT on herdr 0.9.x (herdr's screen-diff attach stream consumes OSC 52 before clients — live-verified); fires as a transient pill once sequences arrive | | Sidebar badges | components/Sidebar.tsx `StatusBadge` + lib/status.ts | ONE vocabulary for every surface (sidebar, palette, composer status line): idle→READY, working→RUN, blocked→INPUT, done→DONE, unknown→`—` (`STATUS_WORD`, `knownStatus`); the compact sidebar variant draws a glyph for RUN, INPUT and DONE and nothing for READY and unknown, but always renders the element with `data-status`, its label and a box (the browser scripts wait on it); background tasks are `.background-count`, not a `.badge`; the composer alone reads a blank state as READY (a shell is always ready); pushed statuses land through `applyPaneStatus` (lib/snapshot.ts) | | Notifications | lib/notifications.ts | tab alerts: policy re-exported from shared/notify-policy.ts; hidden-tab guard; shown via `registration.showNotification` (Android has no `new Notification()`), constructor only as fallback; skipped when this device has push (`pushOnRef`) | diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 8da7d6f1c..5d4766eca 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -7,7 +7,8 @@ import "./CommandPalette.css"; import type { PaneInfo, SessionSnapshot } from "../../shared/protocol.ts"; import type { AppActions, PaneView } from "../lib/actions.ts"; import { rankPanes } from "../lib/paletteSearch.ts"; -import { SHORTCUTS, formatKeys, type ShortcutId } from "../lib/shortcuts.ts"; +import { shortcutDisplayKeys, formatKeys, type ShortcutId } from "../lib/shortcuts.ts"; +import { useSettings } from "../lib/settings.ts"; import { AgentMark } from "./AgentMark.tsx"; import { displayPaneTitle, StatusBadge } from "./Sidebar.tsx"; import { placeLine } from "../lib/paneName.ts"; @@ -60,10 +61,11 @@ function cwdBasename(path: string | null | undefined): string { } function ShortcutHint({ shortcutId }: { shortcutId?: ShortcutId }) { + const { settings } = useSettings(); if (!shortcutId) return null; - const shortcut = SHORTCUTS.find((item) => item.id === shortcutId); - if (!shortcut) return null; - return {formatKeys(shortcut.keys).map((key) => {key})}; + const keys = formatKeys(shortcutDisplayKeys(shortcutId, settings.shortcutOverrides)); + if (keys.length === 0) return null; + return {keys.map((key) => {key})}; } export function CommandPalette({ open, onClose, snapshot, selectedPaneId, view, actions }: CommandPaletteProps) { diff --git a/src/components/SettingsDialog.css b/src/components/SettingsDialog.css index b7e673cb7..aadc4ed70 100644 --- a/src/components/SettingsDialog.css +++ b/src/components/SettingsDialog.css @@ -132,7 +132,7 @@ } .settings-shortcut-select { - width: 11rem; + width: 14rem; } .settings-link { @@ -310,7 +310,7 @@ } .settings-shortcut-select { - width: 9.5rem; + width: 100%; } /* the key takes the line; Save and Remove share the next one */ diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index 390aa1e0a..539ba126f 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -5,7 +5,8 @@ import "./SettingsDialog.css"; import type { AppActions } from "../lib/actions.ts"; import { useInstallPrompt } from "../lib/install.ts"; -import { SHORTCUTS, formatKeys, shortcutKeys, shortcutConflict } from "../lib/shortcuts.ts"; +import { SHORTCUTS, formatKeys, isMacPlatform, shortcutDisplayKeys, shortcutKeys, shortcutConflict } from "../lib/shortcuts.ts"; +import { isReservedShortcutKey } from "../lib/shortcutBindings.ts"; import { CHAT_FONT_MAX, CHAT_FONT_MIN, CHAT_WIDTHS, chatFontSize, DEFAULT_SETTINGS, QUICK_REPLIES_MAX, QUICK_REPLY_MAX_CHARS, TERMINAL_FONT_MAX, TERMINAL_FONT_MIN, TERMINAL_WHEEL_SPEED_MAX, TERMINAL_WHEEL_SPEED_MIN, VOICE_BUTTONS, useSettings, forgetPaneViews, type VoiceButton } from "../lib/settings.ts"; import { LANGUAGE_NAMES, LANGUAGE_SETTINGS, useT } from "../lib/i18n.ts"; import { useFocusTrap } from "../lib/useFocusTrap.ts"; @@ -459,28 +460,38 @@ const SHORTCUT_KEYS: readonly string[] = [..."abcdefghijklmnopqrstuvwxyz01234567 function ShortcutsPage() { const { settings, update } = useSettings(); const t = useT(); + const platformIsMac = isMacPlatform(); return ( <> - - {SHORTCUTS.map((shortcut) => ( - - {shortcut.id === "voice" ? {formatKeys(shortcut.keys).map((key) => {key})} : ( - - )} - - ))} + {t("Bindings apply to this browser and device; Mod+Shift is fixed.")} {t("Text selection in focused fields stays native; Tab/list keys are UI-local, not global shortcuts.")} {t("The mobile key bar sends terminal keys, not app actions.")}}> + {SHORTCUTS.map((shortcut) => { + const displayedKeys = shortcutDisplayKeys(shortcut.id, settings.shortcutOverrides); + const selectedKey = displayedKeys[displayedKeys.length - 1]; + const selectedReserved = selectedKey !== undefined && isReservedShortcutKey(selectedKey, platformIsMac); + const defaultKeys = shortcutDisplayKeys(shortcut.id, {}); + const defaultKey = defaultKeys[defaultKeys.length - 1]; + const defaultReserved = defaultKey !== undefined && isReservedShortcutKey(defaultKey, platformIsMac); + return ( + + {shortcut.id === "voice" ? {formatKeys(shortcut.keys).map((key) => {key})} : ( + + )} + + ); + })}
diff --git a/src/lib/i18n.ja.ts b/src/lib/i18n.ja.ts index 6a47cb5e8..b8d789011 100644 --- a/src/lib/i18n.ja.ts +++ b/src/lib/i18n.ja.ts @@ -15,7 +15,11 @@ export const JA: Record = { "Automatic": "自動", "Terminal input mode": "端末入力モード", "Reset shortcuts": "ショートカットをリセット", - "Some keys are reserved by the browser. Changes apply to this device.": "一部のキーはブラウザーが使用します。変更はこの端末に適用されます。", + "Bindings apply to this browser and device; Mod+Shift is fixed.": "ショートカットはこのブラウザーとデバイスに適用されます。Mod+Shiftは固定です。", + "Text selection in focused fields stays native; Tab/list keys are UI-local, not global shortcuts.": "フォーカス中の入力欄でのテキスト選択は標準動作のままです。Tabやリストのキーは UI 内でのみ使い、グローバルショートカットではありません。", + "The mobile key bar sends terminal keys, not app actions.": "モバイルのキー列はアプリ操作ではなく、端末にキーを送ります。", + "Your browser or operating system may intercept {keys}.": "ブラウザーや OS が {keys} を先に処理する場合があります。", + "Reserved": "予約済み", "Already assigned": "割り当て済み", "Send keys to terminal": "キーを端末に送る", "Default": "既定", diff --git a/src/lib/i18n.ko.ts b/src/lib/i18n.ko.ts index 4985c6f31..21ebeb286 100644 --- a/src/lib/i18n.ko.ts +++ b/src/lib/i18n.ko.ts @@ -13,7 +13,11 @@ export const KO: Record = { "Automatic": "자동", "Terminal input mode": "터미널 입력 방식", "Reset shortcuts": "단축키 초기화", - "Some keys are reserved by the browser. Changes apply to this device.": "일부 키는 브라우저가 사용합니다. 변경은 이 기기에 적용됩니다.", + "Bindings apply to this browser and device; Mod+Shift is fixed.": "단축키는 이 브라우저와 기기에 적용됩니다. Mod+Shift는 고정입니다.", + "Text selection in focused fields stays native; Tab/list keys are UI-local, not global shortcuts.": "포커스된 입력란의 텍스트 선택은 기본 동작을 따릅니다. Tab/목록 키는 UI 안에서만 작동하며 전역 단축키가 아닙니다.", + "The mobile key bar sends terminal keys, not app actions.": "모바일 키 바는 앱 동작이 아닌 터미널 키를 보냅니다.", + "Your browser or operating system may intercept {keys}.": "브라우저나 운영체제가 {keys} 입력을 가로챌 수 있습니다.", + "Reserved": "예약됨", "Already assigned": "이미 지정됨", "Send keys to terminal": "키를 터미널로 전달", "Default": "기본값", diff --git a/src/lib/i18n.zh.ts b/src/lib/i18n.zh.ts index 752b8189c..aa33f861a 100644 --- a/src/lib/i18n.zh.ts +++ b/src/lib/i18n.zh.ts @@ -17,7 +17,11 @@ export const ZH: Record = { "Automatic": "自动", "Terminal input mode": "终端输入模式", "Reset shortcuts": "重置快捷键", - "Some keys are reserved by the browser. Changes apply to this device.": "部分按键由浏览器保留。更改适用于此设备。", + "Bindings apply to this browser and device; Mod+Shift is fixed.": "快捷键仅应用于此浏览器和设备;Mod+Shift 固定不变。", + "Text selection in focused fields stays native; Tab/list keys are UI-local, not global shortcuts.": "焦点位于文本输入框时,文本选择仍由系统处理;Tab/列表按键仅在界面内生效,不是全局快捷键。", + "The mobile key bar sends terminal keys, not app actions.": "手机上的按键栏发送终端按键,不会触发应用操作。", + "Your browser or operating system may intercept {keys}.": "浏览器或操作系统可能会拦截 {keys}。", + "Reserved": "系统保留", "Already assigned": "已分配", "Send keys to terminal": "将按键发送到终端", "Default": "默认", diff --git a/src/lib/keyBar.test.ts b/src/lib/keyBar.test.ts index b606c0074..529ed104a 100644 --- a/src/lib/keyBar.test.ts +++ b/src/lib/keyBar.test.ts @@ -5,8 +5,8 @@ import { DEFAULT_KEY_BAR_ITEMS, KEY_BAR_ITEMS_MAX, KEY_BAR_KEYS, keyBarInputSequ describe("key bar migration", () => { const caps = (items: KeyBarItem[]) => items.map(keyBarItemLabel); - it("preserves the current default and a legacy device's extra selections in display order", () => { - expect(caps(DEFAULT_KEY_BAR_ITEMS)).toEqual(["Esc", "Tab", "Ctrl", "Alt", "Shift", "Enter", "↑", "↓", "←", "→", "^C"]); + it("preserves legacy migration order and selected extras", () => { + expect(caps(migrateKeyBarItems(["alt"]))).toEqual(["Esc", "Tab", "Ctrl", "Alt", "Shift", "Enter", "↑", "↓", "←", "→", "^C"]); expect(caps(migrateKeyBarItems(["slash", "home-end", "shift-tab", "ctrl-z"]))).toEqual([ "Esc", "Tab", "⇧Tab", "Ctrl", "Shift", "Enter", "↑", "↓", "←", "→", "Home", "End", "^C", "^Z", "/", ]); @@ -16,6 +16,15 @@ describe("key bar migration", () => { }); }); +describe("default key bar", () => { + const caps = (items: KeyBarItem[]) => items.map(keyBarItemLabel); + + it("prioritizes core keys ahead of modifiers and navigation", () => { + expect(caps(DEFAULT_KEY_BAR_ITEMS)).toEqual(["Esc", "Tab", "^C", "Ctrl", "Alt", "Shift", "Enter", "↑", "↓", "←", "→"]); + expect(caps(sanitizeKeyBarItems(undefined))).toEqual(caps(DEFAULT_KEY_BAR_ITEMS)); + }); +}); + describe("saved key bars", () => { it("preserves reordered keys, exact chords, sticky toggles and intentional empty bars", () => { const items: KeyBarItem[] = [ diff --git a/src/lib/keyBar.ts b/src/lib/keyBar.ts index b77d79ab0..560615e1e 100644 --- a/src/lib/keyBar.ts +++ b/src/lib/keyBar.ts @@ -57,7 +57,12 @@ export function migrateKeyBarItems(extras: readonly KeyBarExtra[]): KeyBarItem[] return items; } -export const DEFAULT_KEY_BAR_ITEMS: KeyBarItem[] = migrateKeyBarItems(["alt"]); +export const DEFAULT_KEY_BAR_ITEMS: KeyBarItem[] = [ + { type: "key", key: "Escape" }, { type: "key", key: "Tab" }, { type: "key", key: "ctrl-c" }, + { type: "modifier", modifier: "ctrl" }, { type: "modifier", modifier: "alt" }, { type: "modifier", modifier: "shift" }, + { type: "key", key: "Enter" }, + { type: "key", key: "ArrowUp" }, { type: "key", key: "ArrowDown" }, { type: "key", key: "ArrowLeft" }, { type: "key", key: "ArrowRight" }, +]; /** Stable descriptor identity; an inherited key and an exact bare key have different behavior. */ export function keyBarItemId(item: KeyBarItem): string { diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 6a04f06c9..275f9ad73 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -459,9 +459,11 @@ it("sanitizes input modes and shortcut overrides without accepting arbitrary com }); describe("key bar settings", () => { - it("migrates existing optional keys without restoring keys a new layout removed", () => { + it("uses the new default for fresh partial records and migrates explicit legacy records", () => { expect(DEFAULT_SETTINGS.keyBarItems).toEqual(DEFAULT_KEY_BAR_ITEMS); expect(sanitizeSettings({}).keyBarItems).toEqual(DEFAULT_KEY_BAR_ITEMS); + expect(sanitizeSettings({ theme: "light" }).keyBarItems).toEqual(DEFAULT_KEY_BAR_ITEMS); + expect(sanitizeSettings({ keyBarExtras: ["alt"] }).keyBarItems).toEqual(migrateKeyBarItems(["alt"])); expect(sanitizeSettings({ keyBarExtras: ["home-end", "slash", "unknown"] }).keyBarItems).toEqual(migrateKeyBarItems(["home-end", "slash"])); expect(sanitizeSettings({ keyBarExtras: [], keyBarItems: [] }).keyBarItems).toEqual([]); expect(sanitizeSettings({ keyBarExtras: ["alt"], keyBarItems: [] }).keyBarItems).toEqual([]); diff --git a/src/lib/settings.ts b/src/lib/settings.ts index db167b54a..42a554af7 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -260,10 +260,13 @@ export function sanitizeSettings(raw: unknown): Settings { const font = record["terminalFontSize"]; const chatFont = record["chatFontSize"]; const keyBarExtras = sanitizeKeyBarExtras(record["keyBarExtras"], DEFAULT_SETTINGS.keyBarExtras); + const keyBarFallback = Array.isArray(record["keyBarExtras"]) + ? migrateKeyBarItems(keyBarExtras) + : DEFAULT_KEY_BAR_ITEMS; return { terminalInputMode: record["terminalInputMode"] === "line" || record["terminalInputMode"] === "direct" ? record["terminalInputMode"] : "auto", keyBarExtras, - keyBarItems: sanitizeKeyBarItems(record["keyBarItems"], migrateKeyBarItems(keyBarExtras)), + keyBarItems: sanitizeKeyBarItems(record["keyBarItems"], keyBarFallback), shortcutOverrides: sanitizeShortcutOverrides(record["shortcutOverrides"]), theme: theme === "dark" || theme === "light" || theme === "system" ? theme : DEFAULT_SETTINGS.theme, density: density === "compact" || density === "comfortable" ? density : DEFAULT_SETTINGS.density, diff --git a/src/lib/shortcutBindings.ts b/src/lib/shortcutBindings.ts index c37ad44f3..c37cb7d0c 100644 --- a/src/lib/shortcutBindings.ts +++ b/src/lib/shortcutBindings.ts @@ -1,6 +1,24 @@ /** Overrides keep the app's Mod+Shift convention; null returns a key to the terminal. */ export type ShortcutOverrides = Record; export const CUSTOM_SHORTCUT_IDS = ["palette", "toggle-view", "toggle-sidebar", "new-session", "previous-pane", "next-pane", "settings"]; + +/** + * Narrow warning list for Mod+Shift browser/OS reservations; this is not exhaustive: + * Mod+Shift+N opens Chromium private browsing, Mod+Shift+T reopens a closed browser tab, + * Mod+Shift+B opens or toggles browser bookmarks, and Firefox uses Mod+Shift+P for a private + * window. Mod+Shift+W closes a browser window. On non-Mac platforms Chromium/Firefox also + * reserve Mod+Shift+C/I/J for DevTools and Chromium reserves Mod+Shift+O for bookmarks. + * macOS uses Cmd+Shift+3/4/5 for screenshots and Cmd+Shift+Q for logout. + * This only reports likely conflicts; configured and default bindings remain valid. + */ +export function isReservedShortcutKey(key: string, platformIsMac: boolean): boolean { + const normalized = /^[a-z]$/i.test(key) ? key.toLowerCase() : key; + if (normalized === "n" || normalized === "t" || normalized === "b" || normalized === "p" || normalized === "w") return true; + return platformIsMac + ? normalized === "3" || normalized === "4" || normalized === "5" || normalized === "q" + : normalized === "c" || normalized === "i" || normalized === "j" || normalized === "o"; +} + export function sanitizeShortcutOverrides(raw: unknown): ShortcutOverrides { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; const result: ShortcutOverrides = {}; diff --git a/src/lib/shortcuts.test.ts b/src/lib/shortcuts.test.ts index 5440d5410..133bffa3d 100644 --- a/src/lib/shortcuts.test.ts +++ b/src/lib/shortcuts.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { matchShortcut, type ShortcutEventLike, keepsArrowsForText, shortcutConflict, shortcutKeys } from "./shortcuts.ts"; +import { isAppShortcut, isMacPlatform, matchShortcut, type ShortcutEventLike, keepsArrowsForText, shortcutConflict, shortcutDisplayKeys, shortcutKeys } from "./shortcuts.ts"; +import { isReservedShortcutKey, sanitizeShortcutOverrides } from "./shortcutBindings.ts"; function keyEvent(key: string, patch: Partial = {}): ShortcutEventLike { return { key, ctrlKey: false, metaKey: false, shiftKey: true, altKey: false, ...patch }; @@ -71,3 +72,82 @@ it("matches shifted digits and detects alias and default-restoration conflicts", expect(shortcutConflict("palette", ["n"], {})).toBe(true); expect(shortcutConflict("palette", shortcutKeys("palette", {}), { palette: "p", settings: "k" })).toBe(true); }); + +describe("shortcutDisplayKeys", () => { + it("shows the default chord, custom chord, disabled state, and uncustomizable voice chord", () => { + expect(shortcutDisplayKeys("palette", {})).toEqual(["Mod", "Shift", "K"]); + expect(shortcutDisplayKeys("toggle-view", { "toggle-view": "p" })).toEqual(["Mod", "Shift", "P"]); + expect(shortcutDisplayKeys("settings", { settings: null })).toEqual([]); + expect(shortcutDisplayKeys("voice", { voice: "p" })).toEqual(["Mod", "Shift", "Space"]); + }); +}); + +describe("non-Latin keyboard layouts", () => { + it("uses KeyA-Z for a non-ASCII single character but preserves an ASCII event.key layout", () => { + expect(matchShortcut(keyEvent("한", { ctrlKey: true, code: "KeyK" }), false, { palette: "k" })).toBe("palette"); + expect(matchShortcut(keyEvent("ㅏ", { ctrlKey: true, code: "KeyK" }), false)).toBe("palette"); + expect(matchShortcut(keyEvent("л", { metaKey: true, code: "KeyK" }), true)).toBe("palette"); + expect(matchShortcut(keyEvent("ㅏ", { ctrlKey: true, code: "KeyK" }), false, { palette: null })).toBeNull(); + expect(matchShortcut(keyEvent("q", { ctrlKey: true, code: "KeyK" }), false, { palette: "q" })).toBe("palette"); + expect(matchShortcut(keyEvent("q", { ctrlKey: true, code: "KeyK" }), false)).toBeNull(); + expect(matchShortcut(keyEvent("🙂", { ctrlKey: true, code: "KeyK" }), false, { palette: "k" })).toBeNull(); + }); + + it("leaves an accented Latin letter to the terminal, whatever key position it sits on", () => { + // BÉPO: É is on KeyW; Turkish F: Ç is on KeyB, the default sidebar key's position + expect(matchShortcut(keyEvent("É", { ctrlKey: true, code: "KeyW" }), false, { "toggle-sidebar": "w" })).toBeNull(); + expect(matchShortcut(keyEvent("Ç", { ctrlKey: true, code: "KeyB" }), false)).toBeNull(); + expect(matchShortcut(keyEvent("ı", { ctrlKey: true, code: "KeyI" }), false, { palette: "i" })).toBeNull(); + }); + + it("keeps IME guards and Comma/Digit code normalization ahead of layout fallback", () => { + expect(matchShortcut(keyEvent("한", { ctrlKey: true, code: "KeyK", isComposing: true }), false, { palette: "k" })).toBeNull(); + expect(matchShortcut(keyEvent("한", { ctrlKey: true, code: "KeyK", keyCode: 229 }), false, { palette: "k" })).toBeNull(); + expect(matchShortcut(keyEvent("한", { ctrlKey: true, code: "Comma" }), false, { settings: "," })).toBe("settings"); + expect(matchShortcut(keyEvent("한", { ctrlKey: true, code: "Digit1" }), false, { palette: "1" })).toBe("palette"); + }); + + it("keeps app-level and terminal shortcut detection on the same matcher", () => { + const overrides = { palette: "k", "toggle-sidebar": "x" }; + const events = [ + keyEvent("한", { ctrlKey: true, code: "KeyK" }), + keyEvent("x", { ctrlKey: true }), + keyEvent("k", { ctrlKey: true, isComposing: true }), + keyEvent("z", { ctrlKey: true }), + ]; + for (const event of events) { + expect(isAppShortcut(event, overrides)).toBe(matchShortcut(event, isMacPlatform(), overrides) !== null); + } + }); +}); + +describe("isReservedShortcutKey", () => { + it("reports documented browser reservations and macOS screenshot chords", () => { + expect(isReservedShortcutKey("n", false)).toBe(true); + expect(isReservedShortcutKey("T", false)).toBe(true); + expect(isReservedShortcutKey("p", true)).toBe(true); + expect(isReservedShortcutKey("b", true)).toBe(true); + expect(isReservedShortcutKey("w", false)).toBe(true); + expect(isReservedShortcutKey("w", true)).toBe(true); + expect(isReservedShortcutKey("q", true)).toBe(true); + expect(isReservedShortcutKey("j", false)).toBe(true); + expect(isReservedShortcutKey("o", false)).toBe(true); + expect(isReservedShortcutKey("3", true)).toBe(true); + expect(isReservedShortcutKey("4", true)).toBe(true); + expect(isReservedShortcutKey("5", true)).toBe(true); + }); + + it("does not classify unrelated or platform-inapplicable keys as reserved", () => { + expect(isReservedShortcutKey("k", false)).toBe(false); + expect(isReservedShortcutKey("ArrowUp", true)).toBe(false); + expect(isReservedShortcutKey("j", true)).toBe(false); + expect(isReservedShortcutKey("3", false)).toBe(false); + }); + + it("warns without rejecting a configured key or changing an existing default", () => { + const overrides = sanitizeShortcutOverrides({ "new-session": "t" }); + expect(overrides).toEqual({ "new-session": "t" }); + expect(matchShortcut(keyEvent("t", { ctrlKey: true }), false, overrides)).toBe("new-session"); + expect(matchShortcut(keyEvent("n", { ctrlKey: true }), false)).toBe("new-session"); + }); +}); diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 41f5f0e56..db707b9b0 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { useSettings } from "./settings.ts"; import type { ShortcutOverrides } from "./shortcutBindings.ts"; +import { physicalKey } from "./keys.ts"; import type { AppActions } from "./actions.ts"; @@ -46,6 +47,15 @@ export function shortcutKeys(id: ShortcutId, overrides: ShortcutOverrides): stri if (Object.hasOwn(overrides, id)) return overrides[id] ? [overrides[id]!] : []; return Object.entries(KEY_TO_ID).filter(([, action]) => action === id).map(([key]) => key); } + +export function shortcutDisplayKeys(id: ShortcutId, overrides: ShortcutOverrides): string[] { + const shortcut = SHORTCUTS.find((candidate) => candidate.id === id)!; + if (id === "voice" || !Object.hasOwn(overrides, id)) return [...shortcut.keys]; + const key = overrides[id]; + if (!key) return []; + return ["Mod", "Shift", key.length === 1 ? key.toUpperCase() : key]; +} + export function shortcutConflict(id: ShortcutId, keys: string[], overrides: ShortcutOverrides): boolean { return SHORTCUTS.some((other) => other.id !== id && shortcutKeys(other.id, overrides).some((key) => keys.includes(key))); } @@ -55,7 +65,17 @@ export function matchShortcut(event: ShortcutEventLike, platformIsMac: boolean, const hasMod = platformIsMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey; if (!hasMod || !event.shiftKey || event.altKey) return null; // Shift+Comma produces "<" on common keyboard layouts. - const key = event.code === "Comma" ? "," : /^Digit[0-9]$/.test(event.code ?? "") ? event.code!.slice(-1) : event.key.length === 1 ? event.key.toLowerCase() : event.key; + const code = event.code ?? ""; + // Share the terminal's letter-position convention, but never turn a typed symbol into an app action. + // A Latin layout's own letter stays as typed, accented ones included: BÉPO's É sits on KeyW and + // Turkish F's Ç on KeyB, and neither is that binding. + const nonLatinLetter = /^\p{L}$/u.test(event.key) && !/^\p{Script=Latin}$/u.test(event.key); + const letter = nonLatinLetter ? physicalKey(event.key, code) : event.key; + const key = code === "Comma" + ? "," + : /^Digit[0-9]$/.test(code) + ? code.slice(-1) + : letter.length === 1 ? letter.toLowerCase() : letter; for (const shortcut of SHORTCUTS) { if (shortcut.id === "voice") continue; if (Object.hasOwn(overrides, shortcut.id) && overrides[shortcut.id] === key) return shortcut.id; @@ -122,7 +142,6 @@ export function useShortcuts(actions: AppActions, enabled: boolean): void { if (!enabled) return; const platformIsMac = isMacPlatform(); const onKeyDown = (event: KeyboardEvent): void => { - if ((event.target as HTMLElement | null)?.closest?.("[data-shortcut-recorder]")) return; const shortcut = matchShortcut(event, platformIsMac, settings.shortcutOverrides); if (shortcut === null) return; if (event.key.startsWith("Arrow") && keepsArrowsForText(event.target)) return;