diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index ab5efbef8a..6b2360d54c 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -676,6 +676,7 @@ pub struct SettingsSnapshot { auto_download_updates: bool, install_updates_on_quit: bool, global_shortcut: String, + switcher_shortcuts: std::collections::BTreeMap, codex_custom_sessions_dirs: Vec, agent_sessions_enabled: bool, agent_session_ssh_hosts: Vec, @@ -797,6 +798,9 @@ impl From for SettingsSnapshot { update_channel: update_channel_label(settings.update_channel), auto_download_updates: settings.auto_download_updates, install_updates_on_quit: settings.install_updates_on_quit, + switcher_shortcuts: codexbar::switcher_shortcuts::resolve_or_default( + &settings.switcher_shortcuts, + ), global_shortcut: settings.global_shortcut, codex_custom_sessions_dirs: settings.codex_custom_sessions_dirs, agent_sessions_enabled: settings.agent_sessions_enabled, diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index df0e6621a9..3bf3cee57a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -41,6 +41,8 @@ pub struct SettingsUpdate { pub auto_download_updates: Option, pub install_updates_on_quit: Option, pub global_shortcut: Option, + /// Provider-switcher shortcut overrides; replaces the stored overrides. + pub switcher_shortcuts: Option>, pub codex_custom_sessions_dirs: Option>, pub agent_sessions_enabled: Option, pub agent_session_ssh_hosts: Option>, @@ -417,6 +419,11 @@ impl SettingsUpdate { { return Err(format!("Invalid low power mode preference: {value}")); } + if let Some(overrides) = &self.switcher_shortcuts { + settings.switcher_shortcuts = + codexbar::switcher_shortcuts::normalize_overrides(overrides) + .map_err(|error| error.to_string())?; + } if let Some(value) = self.copilot_seat_credit_entitlement { settings.set_seat_credit_entitlement(codexbar::core::ProviderId::Copilot, value)?; } @@ -806,4 +813,87 @@ mod tests { codexbar::settings::NotificationSoundPaths::default() ); } + + fn switcher_patch(json: &str) -> SettingsUpdate { + serde_json::from_str(&format!(r#"{{"switcherShortcuts":{json}}}"#)).unwrap() + } + + #[test] + fn switcher_shortcuts_patch_stores_normalized_non_default_overrides() { + let mut settings = Settings::default(); + switcher_patch(r#"{"select2":"Alt+Cmd+2","previous":"left","next":"none"}"#) + .apply_to(&mut settings) + .expect("valid overrides are stored"); + + assert_eq!( + settings.switcher_shortcuts, + std::collections::BTreeMap::from([ + ("select2".to_string(), "ctrl+alt+2".to_string()), + ("next".to_string(), "none".to_string()), + ]) + ); + } + + #[test] + fn switcher_shortcuts_empty_patch_restores_defaults() { + let mut settings = Settings::default(); + switcher_patch(r#"{"next":"shift+right"}"#) + .apply_to(&mut settings) + .unwrap(); + switcher_patch("{}").apply_to(&mut settings).unwrap(); + + assert!(settings.switcher_shortcuts.is_empty()); + } + + #[test] + fn switcher_shortcuts_patch_rejects_invalid_maps_and_keeps_stored_value() { + let mut settings = Settings::default(); + switcher_patch(r#"{"next":"shift+right"}"#) + .apply_to(&mut settings) + .unwrap(); + + for (json, message) in [ + ( + r#"{"bogus":"ctrl+1"}"#, + "Unknown switcher shortcut action: bogus", + ), + ( + r#"{"next":"left"}"#, + "Each switcher shortcut can be assigned to only one action", + ), + ( + r#"{"next":"ctrl+r"}"#, + "ctrl+r is reserved and cannot be used as a switcher shortcut", + ), + (r#"{"next":"f1"}"#, "f1 is not a valid switcher shortcut"), + ] { + let error = switcher_patch(json) + .apply_to(&mut settings) + .expect_err(json); + assert_eq!(error, message); + } + assert_eq!( + settings.switcher_shortcuts.get("next").map(String::as_str), + Some("shift+right") + ); + } + + #[test] + fn switcher_shortcuts_snapshot_exposes_the_fully_resolved_map() { + let settings = Settings { + switcher_shortcuts: std::collections::BTreeMap::from([( + "next".to_string(), + "none".to_string(), + )]), + ..Settings::default() + }; + let value = + serde_json::to_value(super::super::bridge::SettingsSnapshot::from(settings)).unwrap(); + let map = &value["switcherShortcuts"]; + + assert_eq!(map["next"], "none"); + assert_eq!(map["previous"], "left"); + assert_eq!(map["select9"], "ctrl+9"); + assert_eq!(map.as_object().unwrap().len(), 11); + } } diff --git a/apps/desktop-tauri/src/App.test.tsx b/apps/desktop-tauri/src/App.test.tsx index 42d55a7c27..132170c1f7 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -100,6 +100,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { autoDownloadUpdates: false, installUpdatesOnQuit: false, globalShortcut: "Ctrl+Shift+U", + switcherShortcuts: {}, codexCustomSessionsDirs: [], uiLanguage: "english", // "dark" (not "auto") so useTheme's effect short-circuits before ever diff --git a/apps/desktop-tauri/src/components/ShortcutCapture.test.tsx b/apps/desktop-tauri/src/components/ShortcutCapture.test.tsx index 4befba0372..28ed6732de 100644 --- a/apps/desktop-tauri/src/components/ShortcutCapture.test.tsx +++ b/apps/desktop-tauri/src/components/ShortcutCapture.test.tsx @@ -77,6 +77,27 @@ describe("ShortcutCapture", () => { expect(onCommit).toHaveBeenCalledWith("Ctrl+Shift+K"); }); + it("uses a custom composer, recording hint and empty label", async () => { + const compose = (event: KeyboardEvent) => + event.key === "ArrowLeft" ? "left" : null; + const { onCommit } = await mount({ + compose, + recordingHint: "custom hint", + emptyLabel: "disabled", + }); + expect(screen.getByText("disabled")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Record" })); + expect(screen.getByText("custom hint")).toBeInTheDocument(); + + // A bare key is not a valid accelerator, but the custom composer accepts it. + await act(async () => { + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowLeft", code: "ArrowLeft", bubbles: true }), + ); + }); + expect(onCommit).toHaveBeenCalledWith("left"); + }); + it("cancels on Escape without committing", async () => { const { onCommit } = await mount(); fireEvent.click(screen.getByRole("button", { name: "Record" })); diff --git a/apps/desktop-tauri/src/components/ShortcutCapture.tsx b/apps/desktop-tauri/src/components/ShortcutCapture.tsx index 0791b9594d..db8cbbd88d 100644 --- a/apps/desktop-tauri/src/components/ShortcutCapture.tsx +++ b/apps/desktop-tauri/src/components/ShortcutCapture.tsx @@ -6,6 +6,18 @@ interface ShortcutCaptureProps { disabled?: boolean; onCommit: (accelerator: string) => void; onClear: () => void; + /** + * Turns a key event into the committed shortcut string, or null to keep + * recording. Defaults to a Tauri accelerator (modifier + key). Must be a + * stable reference. + */ + compose?: (event: KeyboardEvent) => string | null; + /** Overrides the recording hint text. */ + recordingHint?: string; + /** Overrides the text shown when `value` is empty. */ + emptyLabel?: string; + /** Names the capture group and its action buttons for assistive technology. */ + accessibleLabel?: string; } const MODIFIER_KEYS = new Set([ @@ -65,6 +77,10 @@ export function ShortcutCapture({ disabled, onCommit, onClear, + compose = composeAccelerator, + recordingHint, + emptyLabel, + accessibleLabel, }: ShortcutCaptureProps) { const { t } = useLocale(); const [recording, setRecording] = useState(false); @@ -91,7 +107,7 @@ export function ShortcutCapture({ return; } - const accel = composeAccelerator(e); + const accel = compose(e); if (accel) { stopRecording(); onCommit(accel); @@ -102,7 +118,7 @@ export function ShortcutCapture({ return () => { window.removeEventListener("keydown", handleKey, true); }; - }, [recording, stopRecording, onCommit, onClear]); + }, [recording, stopRecording, onCommit, onClear, compose]); useEffect(() => { if (recording) { @@ -111,11 +127,15 @@ export function ShortcutCapture({ }, [recording]); const chipText = recording - ? t("ShortcutRecordingHint") - : value || t("ShortcutEmptyPlaceholder"); + ? (recordingHint ?? t("ShortcutRecordingHint")) + : value || (emptyLabel ?? t("ShortcutEmptyPlaceholder")); return ( -
+
{chipText}
@@ -133,6 +158,15 @@ export function ShortcutCapture({ type="button" className="shortcut-capture__button" disabled={disabled || recording} + aria-label={ + accessibleLabel + ? `${accessibleLabel}: ${ + recording + ? t("ShortcutRecordingLabel") + : t("ShortcutRecordButton") + }` + : undefined + } onClick={() => setRecording(true)} > {recording ? t("ShortcutRecordingLabel") : t("ShortcutRecordButton")} @@ -141,6 +175,11 @@ export function ShortcutCapture({ type="button" className="shortcut-capture__button shortcut-capture__button--ghost" disabled={disabled || recording || !value} + aria-label={ + accessibleLabel + ? `${accessibleLabel}: ${t("ShortcutClearButton")}` + : undefined + } onClick={onClear} > {t("ShortcutClearButton")} diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 88860e087d..08f2f5d726 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -165,6 +165,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { autoDownloadUpdates: false, installUpdatesOnQuit: false, globalShortcut: "Ctrl+Shift+U", + switcherShortcuts: {}, codexCustomSessionsDirs: [], uiLanguage: "english", theme: "dark", diff --git a/apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.test.tsx b/apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.test.tsx index e8141363be..07c57ee8ce 100644 --- a/apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.test.tsx +++ b/apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.test.tsx @@ -82,6 +82,65 @@ describe("useProviderSwitcherKeys", () => { expect(onSelect).not.toHaveBeenCalled(); }); + it("follows custom shortcuts instead of the defaults", () => { + const onSelect = vi.fn(); + renderHook(() => + useProviderSwitcherKeys({ + providerIds: IDS, + selectedProviderId: null, + onSelect, + shortcuts: { next: "shift+right", previous: "none", select2: "alt+2" }, + }), + ); + fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" }); + fireEvent.keyDown(window, { key: "ArrowLeft", code: "ArrowLeft" }); + fireEvent.keyDown(window, { key: "2", code: "Digit2", ctrlKey: true }); + expect(onSelect).not.toHaveBeenCalled(); + + fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight", shiftKey: true }); + expect(onSelect).toHaveBeenLastCalledWith("codex"); + fireEvent.keyDown(window, { key: "2", code: "Digit2", altKey: true }); + expect(onSelect).toHaveBeenLastCalledWith("codex"); + expect(onSelect).toHaveBeenCalledTimes(2); + }); + + it("uses updated shortcuts without reinstalling the key listener", () => { + const onSelect = vi.fn(); + const { rerender } = renderHook( + ({ shortcuts }: { shortcuts: Record }) => + useProviderSwitcherKeys({ + providerIds: IDS, + selectedProviderId: null, + onSelect, + shortcuts, + }), + { initialProps: { shortcuts: { next: "shift+right" } } }, + ); + + fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight", shiftKey: true }); + expect(onSelect).toHaveBeenLastCalledWith("codex"); + + rerender({ shortcuts: { next: "alt+2" } }); + fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight", shiftKey: true }); + fireEvent.keyDown(window, { key: "2", code: "Digit2", altKey: true }); + expect(onSelect).toHaveBeenCalledTimes(2); + expect(onSelect).toHaveBeenLastCalledWith("codex"); + }); + + it("falls back to the defaults for an invalid stored map", () => { + const onSelect = vi.fn(); + renderHook(() => + useProviderSwitcherKeys({ + providerIds: IDS, + selectedProviderId: null, + onSelect, + shortcuts: { next: "left" }, + }), + ); + fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" }); + expect(onSelect).toHaveBeenLastCalledWith("codex"); + }); + it("stops listening after unmount", () => { const { onSelect, unmount } = setup(null); unmount(); diff --git a/apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.ts b/apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.ts index 55fa3902e2..953a0534d0 100644 --- a/apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.ts +++ b/apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.ts @@ -1,5 +1,9 @@ -import { useEffect, useRef } from "react"; -import { matchSwitcherAction, resolveSwitcherTarget } from "../lib/switcherShortcuts"; +import { useEffect, useMemo, useRef } from "react"; +import { + matchSwitcherAction, + resolveSwitcherShortcuts, + resolveSwitcherTarget, +} from "../lib/switcherShortcuts"; const EDITABLE_SELECTOR = 'input, textarea, select, [contenteditable=""], [contenteditable="true"], [role="slider"]'; @@ -15,13 +19,17 @@ export function useProviderSwitcherKeys({ providerIds, selectedProviderId, onSelect, + shortcuts, }: { providerIds: readonly string[]; selectedProviderId: string | null; onSelect: (providerId: string | null) => void; + /** `settings.switcherShortcuts`; defaults apply when omitted or invalid. */ + shortcuts?: Readonly>; }) { - const latest = useRef({ providerIds, selectedProviderId, onSelect }); - latest.current = { providerIds, selectedProviderId, onSelect }; + const mapping = useMemo(() => resolveSwitcherShortcuts(shortcuts), [shortcuts]); + const latest = useRef({ providerIds, selectedProviderId, onSelect, mapping }); + latest.current = { providerIds, selectedProviderId, onSelect, mapping }; useEffect(() => { const handler = (event: KeyboardEvent) => { @@ -30,7 +38,7 @@ export function useProviderSwitcherKeys({ if (target instanceof Element && target.closest(EDITABLE_SELECTOR)) return; if (document.querySelector(DRAGGING_SELECTOR)) return; const current = latest.current; - const action = matchSwitcherAction(event); + const action = matchSwitcherAction(event, current.mapping); if (action === null) return; const next = resolveSwitcherTarget( action, diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts index c259fb0860..3f29b09acb 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts @@ -358,6 +358,7 @@ export function useTrayPanelController(state: BootstrapState) { providerIds: gridProviderIds, selectedProviderId, onSelect: handleGridClick, + shortcuts: settings.switcherShortcuts, }); const handleReorder = useCallback((orderedIds: string[]) => { void reorderProviders(orderedIds).catch(() => {}); diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 195620e3bf..6b25bf9487 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -535,6 +535,18 @@ export const ALL_LOCALE_KEYS = [ "ShortcutRecordingHint", "ShortcutClearButton", "ShortcutEmptyPlaceholder", + "SwitcherShortcutsTitle", + "SwitcherShortcutsHelper", + "SwitcherShortcutPrevious", + "SwitcherShortcutNext", + "SwitcherShortcutSelect", + "SwitcherShortcutNone", + "SwitcherShortcutRecordingHint", + "SwitcherShortcutReset", + "SwitcherShortcutErrorUnknown", + "SwitcherShortcutErrorDuplicate", + "SwitcherShortcutErrorReserved", + "SwitcherShortcutErrorInvalid", "NotificationTestSound", "NotificationTestSoundPlaying", "TrayIconModeLabel", diff --git a/apps/desktop-tauri/src/lib/switcherShortcuts.test.ts b/apps/desktop-tauri/src/lib/switcherShortcuts.test.ts index 30c46fc1dc..67ec2bc950 100644 --- a/apps/desktop-tauri/src/lib/switcherShortcuts.test.ts +++ b/apps/desktop-tauri/src/lib/switcherShortcuts.test.ts @@ -5,8 +5,10 @@ import { SwitcherShortcutError, matchSwitcherAction, normalizeShortcut, + resolveSwitcherShortcuts, resolveSwitcherTarget, shortcutFromEvent, + switcherShortcutOverrides, validateSwitcherShortcuts, type SwitcherKeyEvent, } from "./switcherShortcuts"; @@ -136,8 +138,19 @@ describe("shortcutFromEvent", () => { expect(shortcutFromEvent(keyEvent({ key: "A", code: "KeyA", altKey: true }))).toBe("alt+a"); }); + it("uses the logical letter reported by the active keyboard layout", () => { + const event = keyEvent({ key: "z", code: "KeyY", ctrlKey: true }); + expect(shortcutFromEvent(event)).toBe("ctrl+z"); + expect( + matchSwitcherAction(event, validateSwitcherShortcuts({ select1: "ctrl+z" })), + ).toBe("select1"); + }); + it("maps comma and numpad digits via key", () => { expect(shortcutFromEvent(keyEvent({ key: ",", code: "Comma", ctrlKey: true }))).toBe("ctrl+,"); + expect( + shortcutFromEvent(keyEvent({ key: "<", code: "Comma", ctrlKey: true, shiftKey: true })), + ).toBe("ctrl+shift+,"); expect(shortcutFromEvent(keyEvent({ key: "3", code: "Numpad3", ctrlKey: true }))).toBe("ctrl+3"); }); @@ -169,6 +182,16 @@ describe("matchSwitcherAction", () => { matchSwitcherAction(keyEvent({ key: "ArrowRight", code: "ArrowRight", shiftKey: true }), mapping), ).toBe("next"); }); + + it("matches shifted comma by its physical key code", () => { + const mapping = validateSwitcherShortcuts({ select1: "ctrl+shift+," }); + expect( + matchSwitcherAction( + keyEvent({ key: "<", code: "Comma", ctrlKey: true, shiftKey: true }), + mapping, + ), + ).toBe("select1"); + }); }); describe("resolveSwitcherTarget", () => { @@ -197,3 +220,26 @@ describe("resolveSwitcherTarget", () => { expect(resolveSwitcherTarget("select5", ids, null)).toBeNull(); }); }); + +describe("resolveSwitcherShortcuts", () => { + it("returns the defaults for a missing or invalid map", () => { + expect(resolveSwitcherShortcuts()).toEqual(DEFAULT_SWITCHER_SHORTCUTS); + expect(resolveSwitcherShortcuts({})).toEqual(DEFAULT_SWITCHER_SHORTCUTS); + expect(resolveSwitcherShortcuts({ next: "left" })).toEqual(DEFAULT_SWITCHER_SHORTCUTS); + expect(resolveSwitcherShortcuts({ bogus: "ctrl+1" })).toEqual(DEFAULT_SWITCHER_SHORTCUTS); + }); + + it("accepts the fully resolved map the backend sends", () => { + const stored = { ...DEFAULT_SWITCHER_SHORTCUTS, next: "none", select2: "ctrl+alt+2" }; + expect(resolveSwitcherShortcuts(stored)).toEqual(stored); + }); +}); + +describe("switcherShortcutOverrides", () => { + it("keeps only entries that differ from the defaults", () => { + expect(switcherShortcutOverrides(DEFAULT_SWITCHER_SHORTCUTS)).toEqual({}); + expect( + switcherShortcutOverrides(validateSwitcherShortcuts({ next: "none", select2: "alt+2" })), + ).toEqual({ next: "none", select2: "alt+2" }); + }); +}); diff --git a/apps/desktop-tauri/src/lib/switcherShortcuts.ts b/apps/desktop-tauri/src/lib/switcherShortcuts.ts index 7085355f54..f9ff650aaa 100644 --- a/apps/desktop-tauri/src/lib/switcherShortcuts.ts +++ b/apps/desktop-tauri/src/lib/switcherShortcuts.ts @@ -8,6 +8,9 @@ * stay portable. Windows reserves `ctrl+r`, `ctrl+q`, `ctrl+,` and `ctrl+w` * (the tray/pop-out commands). `alt+f4` and `alt+tab` need no entry: F4 and * Tab are outside the grammar, so they are rejected as invalid. + * + * The backend validates the stored map with the same rules + * (`rust/src/switcher_shortcuts.rs`); keep the two in step. */ export const SWITCHER_ACTIONS = [ @@ -126,6 +129,31 @@ export function validateSwitcherShortcuts( return result; } +/** + * Resolve the map stored in settings; a missing or invalid map (the backend + * only stores valid ones) yields the defaults. + */ +export function resolveSwitcherShortcuts( + stored?: Readonly>, +): SwitcherShortcutMap { + try { + return validateSwitcherShortcuts(stored); + } catch { + return { ...DEFAULT_SWITCHER_SHORTCUTS }; + } +} + +/** The entries of `mapping` that differ from the defaults (the patch payload). */ +export function switcherShortcutOverrides( + mapping: Readonly, +): Record { + return Object.fromEntries( + SWITCHER_ACTIONS.filter( + (action) => mapping[action] !== DEFAULT_SWITCHER_SHORTCUTS[action], + ).map((action) => [action, mapping[action]]), + ); +} + export type SwitcherKeyEvent = Pick< KeyboardEvent, "key" | "code" | "ctrlKey" | "altKey" | "shiftKey" | "metaKey" @@ -133,17 +161,18 @@ export type SwitcherKeyEvent = Pick< /** * Canonical shortcut string for a key event, or null when the event cannot - * be expressed in the grammar (bare modifier, Windows key, punctuation other - * than `,`, ...). Letters and digits come from `code` so Shift+1 stays `1`. + * be expressed in the grammar (bare modifier, Windows key, unsupported + * punctuation, ...). Letters come from `key` so shortcuts follow the active + * keyboard layout; top-row digits and comma come from `code` so Shift+1 stays + * `1` and Shift+, stays a comma on US layouts. */ export function shortcutFromEvent(event: SwitcherKeyEvent): string | null { if (event.metaKey) return null; - let key: string | undefined = /^(?:Digit|Key)([0-9A-Z])$/ - .exec(event.code)?.[1] - ?.toLowerCase(); + let key: string | undefined = /^Digit([0-9])$/.exec(event.code)?.[1]; if (key === undefined) { if (event.key === "ArrowLeft") key = "left"; else if (event.key === "ArrowRight") key = "right"; + else if (event.code === "Comma" || event.code === "NumpadComma") key = ","; else if (/^[a-z0-9,]$/i.test(event.key)) key = event.key.toLowerCase(); } if (key === undefined) return null; diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx index 0e7f3053a9..5b10fb4346 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx @@ -170,6 +170,7 @@ function settings(): SettingsSnapshot { autoDownloadUpdates: false, installUpdatesOnQuit: false, globalShortcut: "Ctrl+Shift+U", + switcherShortcuts: {}, codexCustomSessionsDirs: [], uiLanguage: "english", theme: "dark", diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx index cc8885a167..deafe15f17 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx @@ -106,6 +106,7 @@ export default function PopOutPanel({ providerIds: sortedProviderIds, selectedProviderId, onSelect: handleGridClick, + shortcuts: settings.switcherShortcuts, }); const handleReorder = useCallback((orderedIds: string[]) => { void reorderProviders(orderedIds).catch(() => {}); diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 8e156a34bf..791290f15d 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -157,6 +157,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { autoDownloadUpdates: false, installUpdatesOnQuit: false, globalShortcut: "Ctrl+Shift+U", + switcherShortcuts: {}, codexCustomSessionsDirs: [], uiLanguage: "english", theme: "dark", diff --git a/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsSection.test.tsx new file mode 100644 index 0000000000..57d684519a --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsSection.test.tsx @@ -0,0 +1,136 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../hooks/useLocale", () => ({ + useLocale: () => ({ t: (key: string) => key, language: "english" }), +})); + +import SwitcherShortcutsSection from "./SwitcherShortcutsSection"; +import type { SettingsSnapshot } from "../../types/bridge"; + +function renderSection( + switcherShortcuts: Record = {}, + saving = false, +) { + const set = vi.fn(); + render( + , + ); + return set; +} + +/** Start recording on row `index` (previous = 0, next = 1, select1 = 2, ...). */ +function startRecording(index: number) { + fireEvent.click( + screen.getAllByRole("button", { name: /: ShortcutRecordButton$/ })[index], + ); +} + +async function press(init: KeyboardEventInit) { + await act(async () => { + window.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, ...init })); + }); +} + +describe("SwitcherShortcutsSection", () => { + it("shows one row per action with the resolved shortcuts", () => { + renderSection({ next: "none", select2: "ctrl+alt+2" }); + + expect(screen.getAllByRole("button", { name: /: ShortcutRecordButton$/ })).toHaveLength(11); + expect(screen.getByText("SwitcherShortcutPrevious")).toBeInTheDocument(); + expect(screen.getByText("SwitcherShortcutNext")).toBeInTheDocument(); + expect(screen.getByText("SwitcherShortcutSelect 9")).toBeInTheDocument(); + expect(screen.getByText("left")).toBeInTheDocument(); + expect(screen.getByText("ctrl+alt+2")).toBeInTheDocument(); + expect(screen.getByText("SwitcherShortcutNone")).toBeInTheDocument(); + }); + + it("gives each capture control a row-specific accessible name", () => { + renderSection(); + + expect( + screen.getByRole("button", { + name: "SwitcherShortcutNext: ShortcutRecordButton", + }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "SwitcherShortcutNext: ShortcutClearButton", + }), + ).toBeEnabled(); + expect( + screen.getByRole("status", { name: "SwitcherShortcutNext: right" }), + ).toBeInTheDocument(); + }); + + it("saves a recorded key as an override", async () => { + const set = renderSection(); + startRecording(1); + await press({ key: "ArrowRight", code: "ArrowRight", shiftKey: true }); + + expect(set).toHaveBeenCalledWith({ switcherShortcuts: { next: "shift+right" } }); + }); + + it("keeps existing overrides when adding another", async () => { + const set = renderSection({ next: "shift+right" }); + startRecording(2); + await press({ key: "a", code: "KeyA", altKey: true }); + + expect(set).toHaveBeenCalledWith({ + switcherShortcuts: { next: "shift+right", select1: "alt+a" }, + }); + }); + + it("disables an action with Backspace", async () => { + const set = renderSection(); + startRecording(0); + await press({ key: "Backspace", code: "Backspace" }); + + expect(set).toHaveBeenCalledWith({ switcherShortcuts: { previous: "none" } }); + }); + + it("rejects a duplicate key with an inline error and saves nothing", async () => { + const set = renderSection(); + startRecording(1); + await press({ key: "ArrowLeft", code: "ArrowLeft" }); + + expect(set).not.toHaveBeenCalled(); + expect(screen.getByRole("alert")).toHaveTextContent( + "SwitcherShortcutNext: SwitcherShortcutErrorDuplicate", + ); + }); + + it("rejects reserved keys and clears the error after a valid change", async () => { + const set = renderSection(); + startRecording(2); + await press({ key: "r", code: "KeyR", ctrlKey: true }); + + expect(set).not.toHaveBeenCalled(); + expect(screen.getByRole("alert")).toHaveTextContent("SwitcherShortcutErrorReserved"); + + startRecording(2); + await press({ key: "a", code: "KeyA", altKey: true }); + expect(set).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("resets to the defaults with an empty override map", () => { + const set = renderSection({ next: "none" }); + fireEvent.click(screen.getByRole("button", { name: "SwitcherShortcutReset" })); + + expect(set).toHaveBeenCalledWith({ switcherShortcuts: {} }); + }); + + it("disables editing while a save is in flight", () => { + renderSection({}, true); + + expect(screen.getByRole("button", { name: "SwitcherShortcutReset" })).toBeDisabled(); + for (const button of screen.getAllByRole("button", { name: /: ShortcutRecordButton$/ })) { + expect(button).toBeDisabled(); + } + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsSection.tsx new file mode 100644 index 0000000000..16cf8c8654 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsSection.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { Field } from "../../components/FormControls"; +import { ShortcutCapture } from "../../components/ShortcutCapture"; +import { useLocale } from "../../hooks/useLocale"; +import type { LocaleKey } from "../../i18n/keys"; +import { + SWITCHER_ACTIONS, + SWITCHER_SHORTCUT_NONE, + SwitcherShortcutError, + resolveSwitcherShortcuts, + shortcutFromEvent, + switcherShortcutOverrides, + validateSwitcherShortcuts, + type SwitcherAction, + type SwitcherShortcutErrorCode, +} from "../../lib/switcherShortcuts"; +import type { TabProps } from "./settingsTabs"; + +const ERROR_KEYS: Record = { + unknown: "SwitcherShortcutErrorUnknown", + duplicate: "SwitcherShortcutErrorDuplicate", + reserved: "SwitcherShortcutErrorReserved", + invalid: "SwitcherShortcutErrorInvalid", +}; + +/** Editor for the provider-switcher keys (Settings > Menu). */ +export default function SwitcherShortcutsSection({ + settings, + set, + saving, +}: TabProps) { + const { t } = useLocale(); + const [error, setError] = useState<{ + action: SwitcherAction; + code: SwitcherShortcutErrorCode; + } | null>(null); + const mapping = resolveSwitcherShortcuts(settings.switcherShortcuts); + + const assign = (action: SwitcherAction, shortcut: string) => { + try { + const next = validateSwitcherShortcuts({ ...mapping, [action]: shortcut }); + setError(null); + set({ switcherShortcuts: switcherShortcutOverrides(next) }); + } catch (caught) { + if (!(caught instanceof SwitcherShortcutError)) throw caught; + setError({ action, code: caught.code }); + } + }; + + const reset = () => { + setError(null); + set({ switcherShortcuts: {} }); + }; + + const label = (action: SwitcherAction) => { + if (action === "previous") return t("SwitcherShortcutPrevious"); + if (action === "next") return t("SwitcherShortcutNext"); + return `${t("SwitcherShortcutSelect")} ${action.slice("select".length)}`; + }; + + return ( +
+

{t("SwitcherShortcutsTitle")}

+

{t("SwitcherShortcutsHelper")}

+
+ {SWITCHER_ACTIONS.map((action) => { + const value = mapping[action]; + return ( + + assign(action, shortcut)} + onClear={() => assign(action, SWITCHER_SHORTCUT_NONE)} + /> + + ); + })} +
+ {error && ( +

+ {label(error.action)}: {t(ERROR_KEYS[error.code])} +

+ )} + +
+ ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx index 1a818ed97f..57fa4b317c 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx @@ -75,6 +75,7 @@ const settings: SettingsSnapshot = { autoDownloadUpdates: false, installUpdatesOnQuit: false, globalShortcut: "", + switcherShortcuts: {}, codexCustomSessionsDirs: [], updateChannel: "stable", uiLanguage: "english", diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx index 8d1d7f2148..c6e61f991f 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx @@ -53,6 +53,7 @@ const settings: SettingsSnapshot = { autoDownloadUpdates: false, installUpdatesOnQuit: false, globalShortcut: "", + switcherShortcuts: {}, codexCustomSessionsDirs: [], updateChannel: "stable", uiLanguage: "english", diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx index aed13a723b..a7b9d6cd0e 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx @@ -9,6 +9,7 @@ import type { } from "../../../types/bridge"; import type { TabProps } from "../settingsTabs"; import FloatBarSettingsSection from "../../../floatbar/SettingsSection"; +import SwitcherShortcutsSection from "../SwitcherShortcutsSection"; import { getTrayVisibilityStatus } from "../../../lib/tauri"; function clampWindowScalePercent(value: number): number { @@ -240,6 +241,10 @@ export default function DisplayTab({
} + {mode === "menu" && ( + + )} + {mode === "menu" && ( )} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx index 33a78a00e9..7a593945ba 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx @@ -69,6 +69,7 @@ const settings: SettingsSnapshot = { autoDownloadUpdates: false, installUpdatesOnQuit: false, globalShortcut: "", + switcherShortcuts: {}, codexCustomSessionsDirs: [], updateChannel: "stable", uiLanguage: "english", diff --git a/apps/desktop-tauri/src/types/bridge.test.ts b/apps/desktop-tauri/src/types/bridge.test.ts index 5075276d82..90e06bf812 100644 --- a/apps/desktop-tauri/src/types/bridge.test.ts +++ b/apps/desktop-tauri/src/types/bridge.test.ts @@ -98,6 +98,7 @@ describe("Language type", () => { autoDownloadUpdates: false, installUpdatesOnQuit: false, globalShortcut: "", + switcherShortcuts: {}, codexCustomSessionsDirs: [], updateChannel: "stable", uiLanguage: "spanish", diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 88754baa7d..b416fed7fb 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -205,6 +205,8 @@ export interface SettingsSnapshot { autoDownloadUpdates: boolean; installUpdatesOnQuit: boolean; globalShortcut: string; + /** Fully resolved action -> shortcut map (defaults overlaid by overrides). */ + switcherShortcuts: Record; /** Extra Codex home or sessions directories scanned for local cost estimates. */ codexCustomSessionsDirs: string[]; agentSessionsEnabled?: boolean; @@ -314,6 +316,8 @@ export interface SettingsUpdate { autoDownloadUpdates?: boolean; installUpdatesOnQuit?: boolean; globalShortcut?: string; + /** Overrides only; replaces the stored map. `{}` restores the defaults. */ + switcherShortcuts?: Record; codexCustomSessionsDirs?: string[]; agentSessionsEnabled?: boolean; agentSessionSshHosts?: string[]; diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 01911602ca..8694822a44 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -81,7 +81,25 @@ The tray flyout and the pop-out window switch providers from the keyboard (porte | Previous / next | `Left` / `Right` (wraps through Overview) | | Overview, then providers in display order | `Ctrl+1` ... `Ctrl+9` | -Keys are ignored while a text field, select or slider (the zoom slider) has focus, or while a grid drag is active. Customizing the keys is not yet exposed in Settings; the shortcut grammar (`ctrl`/`alt`/`shift` modifiers, `cmd` accepted as an alias for `ctrl`, `none` to disable) lives in `apps/desktop-tauri/src/lib/switcherShortcuts.ts`. +Keys are ignored while a text field, select or slider (the zoom slider) has focus, or while a grid drag is active. + +### Customizing the keys + +**Settings → Menu → Provider switcher shortcuts** lists all 11 actions. Choose **Record**, then press the key or combination; **Backspace** while recording (or **Clear**) disables the action, **Reset to defaults** restores every default. The editor rejects duplicates and reserved keys with an inline message and saves nothing in that case. + +Only the actions you changed are stored, in `settings.json` under `switcher_shortcuts` (the key is omitted when everything is default): + +```json +{ "switcher_shortcuts": { "previous": "shift+left", "select2": "ctrl+alt+2", "next": "none" } } +``` + +Rules (upstream grammar, validated by `rust/src/switcher_shortcuts.rs` and mirrored in `apps/desktop-tauri/src/lib/switcherShortcuts.ts`): + +- Actions: `previous`, `next`, `select1` ... `select9`. Unknown actions are rejected. +- A shortcut is optional `ctrl`, `alt`, `shift` modifiers plus one key: `left`, `right`, `,`, a letter or a digit. `cmd` is accepted as an alias for `ctrl`, so shortcuts copied from macOS stay valid. +- Letters, digits and `,` need `ctrl` or `alt`. `ctrl+r`, `ctrl+q`, `ctrl+,` and `ctrl+w` are reserved for the tray and pop-out commands. +- Two actions cannot share a shortcut. `none` disables an action and frees its key. +- An invalid stored map is ignored on load (defaults apply) and logged as a warning; it never blocks the rest of the settings. ## Claude Code accounts diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 1dfa17cff5..ada59d0b33 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -26,6 +26,7 @@ pub mod sound; pub mod spend_contract; pub mod status; +pub mod switcher_shortcuts; pub mod tray; pub mod updater; pub mod wsl; diff --git a/rust/src/locale.rs b/rust/src/locale.rs index e6ec918ac4..decaa444f6 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -799,6 +799,18 @@ locale_keys! { ShortcutRecordingHint, ShortcutClearButton, ShortcutEmptyPlaceholder, + SwitcherShortcutsTitle, + SwitcherShortcutsHelper, + SwitcherShortcutPrevious, + SwitcherShortcutNext, + SwitcherShortcutSelect, + SwitcherShortcutNone, + SwitcherShortcutRecordingHint, + SwitcherShortcutReset, + SwitcherShortcutErrorUnknown, + SwitcherShortcutErrorDuplicate, + SwitcherShortcutErrorReserved, + SwitcherShortcutErrorInvalid, NotificationTestSound, NotificationTestSoundPlaying, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index fb96ecd4b8..cecc02dac5 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -472,6 +472,18 @@ ShortcutRecordingLabel = Recording… ShortcutRecordingHint = Press modifiers + a key. Esc cancels, Backspace clears. ShortcutClearButton = Clear ShortcutEmptyPlaceholder = Not set +SwitcherShortcutsTitle = Provider switcher shortcuts +SwitcherShortcutsHelper = Keys that switch providers while the tray panel or pop-out window is focused. Item 1 is Overview; items 2-9 follow the provider order. Left and Right wrap around. +SwitcherShortcutPrevious = Previous provider +SwitcherShortcutNext = Next provider +SwitcherShortcutSelect = Go to item +SwitcherShortcutNone = Disabled +SwitcherShortcutRecordingHint = Press a key or combination. Esc cancels, Backspace disables. +SwitcherShortcutReset = Reset to defaults +SwitcherShortcutErrorUnknown = That action is not a provider switcher shortcut. +SwitcherShortcutErrorDuplicate = That key is already assigned to another action. +SwitcherShortcutErrorReserved = That key is reserved. Letters, digits and comma need Ctrl or Alt; Ctrl+R, Ctrl+Q, Ctrl+, and Ctrl+W are taken. +SwitcherShortcutErrorInvalid = That key cannot be used. Use Left, Right, a letter, a digit or comma, with optional Ctrl, Alt or Shift. NotificationTestSound = Test sound NotificationTestSoundPlaying = Playing… TrayIconModeLabel = Tray icon mode diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 38dc0b05ae..e36429fe21 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -428,6 +428,18 @@ ShortcutRecordingLabel = Grabando… ShortcutRecordingHint = Presione modificadores + una tecla. Esc cancela, Retroceso borra. ShortcutClearButton = Limpiar ShortcutEmptyPlaceholder = No configurado +SwitcherShortcutsTitle = Atajos del selector de proveedores +SwitcherShortcutsHelper = Teclas para cambiar de proveedor cuando el panel de bandeja o la ventana emergente tiene el foco. El elemento 1 es Resumen; los elementos 2-9 siguen el orden de proveedores. Las flechas izquierda y derecha recorren la lista en bucle. +SwitcherShortcutPrevious = Proveedor anterior +SwitcherShortcutNext = Proveedor siguiente +SwitcherShortcutSelect = Ir al elemento +SwitcherShortcutNone = Desactivado +SwitcherShortcutRecordingHint = Presione una tecla o combinación. Esc cancela; Retroceso desactiva. +SwitcherShortcutReset = Restablecer valores predeterminados +SwitcherShortcutErrorUnknown = Esa acción no es un atajo del selector de proveedores. +SwitcherShortcutErrorDuplicate = Esa tecla ya está asignada a otra acción. +SwitcherShortcutErrorReserved = Esa tecla está reservada. Las letras, los dígitos y la coma necesitan Ctrl o Alt; Ctrl+R, Ctrl+Q, Ctrl+, y Ctrl+W ya están en uso. +SwitcherShortcutErrorInvalid = Esa tecla no se puede usar. Use Izquierda, Derecha, una letra, un dígito o una coma, con Ctrl, Alt o Shift opcionales. NotificationTestSound = Probar sonido NotificationTestSoundPlaying = Reproduciendo… TrayIconModeLabel = Modo de ícono de bandeja diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index b6c2dc0fc8..a39ce5e56b 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -410,6 +410,18 @@ ShortcutRecordingLabel = 記録中… ShortcutRecordingHint = 修飾キー + キーを押してください。Esc でキャンセル、Backspace でクリア。 ShortcutClearButton = クリア ShortcutEmptyPlaceholder = 未設定 +SwitcherShortcutsTitle = プロバイダー切り替えショートカット +SwitcherShortcutsHelper = トレイパネルまたはポップアウトウィンドウにフォーカスがあるときにプロバイダーを切り替えるキーです。項目1は概要、項目2〜9はプロバイダーの表示順です。左右キーで循環します。 +SwitcherShortcutPrevious = 前のプロバイダー +SwitcherShortcutNext = 次のプロバイダー +SwitcherShortcutSelect = 項目へ移動 +SwitcherShortcutNone = 無効 +SwitcherShortcutRecordingHint = キーまたはキーの組み合わせを押してください。Esc でキャンセル、Backspace で無効化します。 +SwitcherShortcutReset = 既定値に戻す +SwitcherShortcutErrorUnknown = この操作はプロバイダー切り替えショートカットではありません。 +SwitcherShortcutErrorDuplicate = このキーはすでに別の操作に割り当てられています。 +SwitcherShortcutErrorReserved = このキーは予約されています。文字、数字、カンマには Ctrl または Alt が必要です。Ctrl+R、Ctrl+Q、Ctrl+カンマ、Ctrl+W は使用できません。 +SwitcherShortcutErrorInvalid = このキーは使用できません。Left、Right、文字、数字、カンマに、必要に応じて Ctrl、Alt、Shift を付けてください。 NotificationTestSound = サウンドをテスト NotificationTestSoundPlaying = 再生中… TrayIconModeLabel = トレイアイコンモード diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 299dcf1a54..b0a211ed8d 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -415,6 +415,18 @@ ShortcutRecordingLabel = 입력 중… ShortcutRecordingHint = 조합 키 + 단축키를 누르세요. Esc는 취소, Backspace는 지우기입니다. ShortcutClearButton = 지우기 ShortcutEmptyPlaceholder = 설정되지 않음 +SwitcherShortcutsTitle = 공급자 전환 단축키 +SwitcherShortcutsHelper = 트레이 패널이나 팝아웃 창에 포커스가 있을 때 공급자를 전환하는 키입니다. 항목 1은 개요이며, 항목 2-9는 공급자 표시 순서입니다. 왼쪽 및 오른쪽 키는 순환합니다. +SwitcherShortcutPrevious = 이전 공급자 +SwitcherShortcutNext = 다음 공급자 +SwitcherShortcutSelect = 항목으로 이동 +SwitcherShortcutNone = 사용 안 함 +SwitcherShortcutRecordingHint = 키 또는 키 조합을 누르세요. Esc는 취소하고 Backspace는 사용 안 함으로 설정합니다. +SwitcherShortcutReset = 기본값으로 재설정 +SwitcherShortcutErrorUnknown = 해당 작업은 공급자 전환 단축키가 아닙니다. +SwitcherShortcutErrorDuplicate = 해당 키는 이미 다른 작업에 할당되어 있습니다. +SwitcherShortcutErrorReserved = 해당 키는 예약되어 있습니다. 문자, 숫자, 쉼표에는 Ctrl 또는 Alt가 필요합니다. Ctrl+R, Ctrl+Q, Ctrl+쉼표, Ctrl+W는 사용할 수 없습니다. +SwitcherShortcutErrorInvalid = 해당 키는 사용할 수 없습니다. Left, Right, 문자, 숫자 또는 쉼표에 Ctrl, Alt, Shift를 선택적으로 조합하세요. NotificationTestSound = 소리 테스트 NotificationTestSoundPlaying = 재생 중… TrayIconModeLabel = 트레이 아이콘 모드 diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index f978d540e7..c4a1903502 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -394,6 +394,18 @@ ShortcutRecordingLabel = Запись… ShortcutRecordingHint = Нажмите модификаторы + клавишу. Esc отменяет, Backspace очищает. ShortcutClearButton = Очистить ShortcutEmptyPlaceholder = Не установлено +SwitcherShortcutsTitle = Сочетания клавиш переключателя провайдеров +SwitcherShortcutsHelper = Клавиши для переключения провайдеров, когда открыта панель трея или отдельное окно. Пункт 1 — обзор; пункты 2–9 следуют порядку провайдеров. Стрелки влево и вправо переключают по кругу. +SwitcherShortcutPrevious = Предыдущий провайдер +SwitcherShortcutNext = Следующий провайдер +SwitcherShortcutSelect = Перейти к пункту +SwitcherShortcutNone = Отключено +SwitcherShortcutRecordingHint = Нажмите клавишу или сочетание. Esc отменяет, Backspace отключает. +SwitcherShortcutReset = Восстановить значения по умолчанию +SwitcherShortcutErrorUnknown = Это действие не относится к сочетаниям переключателя провайдеров. +SwitcherShortcutErrorDuplicate = Эта клавиша уже назначена другому действию. +SwitcherShortcutErrorReserved = Эта клавиша зарезервирована. Для букв, цифр и запятой нужен Ctrl или Alt; Ctrl+R, Ctrl+Q, Ctrl+запятая и Ctrl+W заняты. +SwitcherShortcutErrorInvalid = Эту клавишу нельзя использовать. Выберите Left, Right, букву, цифру или запятую, при необходимости добавив Ctrl, Alt или Shift. NotificationTestSound = Тестовый звук NotificationTestSoundPlaying = Игра… TrayIconModeLabel = Режим значков в трее diff --git a/rust/src/locale/tests.rs b/rust/src/locale/tests.rs index 9daf93d809..c9347925c3 100644 --- a/rust/src/locale/tests.rs +++ b/rust/src/locale/tests.rs @@ -322,6 +322,40 @@ fn test_locale_key_russian() { ); } +#[test] +fn provider_switcher_shortcut_strings_are_translated_in_design_locales() { + let keys = [ + LocaleKey::SwitcherShortcutsTitle, + LocaleKey::SwitcherShortcutsHelper, + LocaleKey::SwitcherShortcutPrevious, + LocaleKey::SwitcherShortcutNext, + LocaleKey::SwitcherShortcutSelect, + LocaleKey::SwitcherShortcutNone, + LocaleKey::SwitcherShortcutRecordingHint, + LocaleKey::SwitcherShortcutReset, + LocaleKey::SwitcherShortcutErrorUnknown, + LocaleKey::SwitcherShortcutErrorDuplicate, + LocaleKey::SwitcherShortcutErrorReserved, + LocaleKey::SwitcherShortcutErrorInvalid, + ]; + let languages = [ + (Language::Spanish, "es-MX"), + (Language::Japanese, "ja-JP"), + (Language::Korean, "ko-KR"), + (Language::Russian, "ru-RU"), + ]; + + for (language, locale) in languages { + for key in keys { + assert_ne!( + get_text(language, key), + get_text(Language::English, key), + "{key:?} is falling back to English in {locale}" + ); + } + } +} + #[test] fn test_locale_key_turkish() { assert_eq!(get_text(Language::Turkish, LocaleKey::TabGeneral), "Genel"); diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 9863f194ce..51e15355c8 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -12,7 +12,7 @@ )] use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::PathBuf; use crate::core::ProviderId; @@ -212,6 +212,12 @@ pub struct Settings { #[serde(default = "default_global_shortcut")] pub global_shortcut: String, + /// Provider-switcher shortcut overrides (action -> shortcut, normalized). + /// Empty means every action uses its default; see + /// [`crate::switcher_shortcuts`]. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub switcher_shortcuts: BTreeMap, + /// Additional Codex home or sessions directories to include in local cost scans. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub codex_custom_sessions_dirs: Vec, @@ -560,6 +566,7 @@ impl Default for Settings { provider_metrics: HashMap::new(), // Empty = use Automatic for all provider_order: Vec::new(), // Empty = canonical ProviderId::all() order global_shortcut: default_global_shortcut(), // Ctrl+Shift+U by default + switcher_shortcuts: BTreeMap::new(), codex_custom_sessions_dirs: Vec::new(), agent_sessions_enabled: false, agent_session_ssh_hosts: Vec::new(), diff --git a/rust/src/settings/raw.rs b/rust/src/settings/raw.rs index 850c90e960..5215af22dd 100644 --- a/rust/src/settings/raw.rs +++ b/rust/src/settings/raw.rs @@ -119,6 +119,7 @@ pub(super) struct RawSettings { provider_order: Vec, #[serde(default = "default_global_shortcut")] global_shortcut: String, + switcher_shortcuts: BTreeMap, codex_custom_sessions_dirs: Vec, agent_sessions_enabled: bool, agent_session_ssh_hosts: Vec, @@ -253,6 +254,7 @@ impl Default for RawSettings { provider_metrics: s.provider_metrics, provider_order: s.provider_order, global_shortcut: s.global_shortcut, + switcher_shortcuts: s.switcher_shortcuts, codex_custom_sessions_dirs: s.codex_custom_sessions_dirs, agent_sessions_enabled: s.agent_sessions_enabled, agent_session_ssh_hosts: s.agent_session_ssh_hosts, @@ -556,6 +558,7 @@ impl From for Settings { normalize_provider_order(&raw.provider_order) }, global_shortcut: raw.global_shortcut, + switcher_shortcuts: sanitize_switcher_shortcuts(raw.switcher_shortcuts), codex_custom_sessions_dirs: raw.codex_custom_sessions_dirs, agent_sessions_enabled: raw.agent_sessions_enabled, agent_session_ssh_hosts: raw.agent_session_ssh_hosts, @@ -603,3 +606,12 @@ impl From for Settings { } } } + +/// A hand-edited or stale stored map must not break loading: an invalid map +/// falls back to the defaults (empty overrides). +fn sanitize_switcher_shortcuts(stored: BTreeMap) -> BTreeMap { + crate::switcher_shortcuts::normalize_overrides(&stored).unwrap_or_else(|error| { + tracing::warn!("Ignoring invalid switcher shortcuts in settings: {error}"); + BTreeMap::new() + }) +} diff --git a/rust/src/switcher_shortcuts.rs b/rust/src/switcher_shortcuts.rs new file mode 100644 index 0000000000..d5d9f3922e --- /dev/null +++ b/rust/src/switcher_shortcuts.rs @@ -0,0 +1,145 @@ +//! Provider-switcher keyboard shortcuts (upstream CodexBar 0.67.0, +//! `ProviderSwitcherShortcuts`). +//! +//! Shortcuts are menu-local strings such as `left`, `ctrl+3` or +//! `ctrl+shift+right`; `none` disables an action. The grammar is upstream's +//! with one Windows difference: `cmd` is accepted as an alias for `ctrl` and +//! normalized to `ctrl`, so documents exported from macOS stay portable. +//! Windows reserves `ctrl+r`, `ctrl+q`, `ctrl+,` and `ctrl+w` (tray and +//! pop-out commands). +//! +//! The frontend mirrors this module in `src/lib/switcherShortcuts.ts`; the +//! two must stay in step. + +use std::collections::BTreeMap; + +use thiserror::Error; + +pub const NONE: &str = "none"; + +/// Switcher actions in display order with their default shortcuts. +pub const DEFAULTS: [(&str, &str); 11] = [ + ("previous", "left"), + ("next", "right"), + ("select1", "ctrl+1"), + ("select2", "ctrl+2"), + ("select3", "ctrl+3"), + ("select4", "ctrl+4"), + ("select5", "ctrl+5"), + ("select6", "ctrl+6"), + ("select7", "ctrl+7"), + ("select8", "ctrl+8"), + ("select9", "ctrl+9"), +]; + +const MODIFIER_ORDER: [&str; 3] = ["ctrl", "alt", "shift"]; +const NAMED_KEYS: [&str; 3] = ["left", "right", ","]; +const RESERVED: [&str; 4] = ["ctrl+r", "ctrl+q", "ctrl+,", "ctrl+w"]; + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum SwitcherShortcutError { + #[error("Unknown switcher shortcut action: {0}")] + UnknownAction(String), + #[error("Each switcher shortcut can be assigned to only one action")] + Duplicate, + #[error("{0} is reserved and cannot be used as a switcher shortcut")] + Reserved(String), + #[error("{0} is not a valid switcher shortcut")] + Invalid(String), +} + +/// Canonicalize one shortcut string (lowercase, modifiers ordered +/// `ctrl+alt+shift`, `cmd` folded into `ctrl`). +pub fn normalize(shortcut: &str) -> Result { + let lowered = shortcut.to_lowercase(); + let parts: Vec<&str> = lowered.split('+').map(str::trim).collect(); + if parts == [NONE] { + return Ok(NONE.to_string()); + } + let invalid = || SwitcherShortcutError::Invalid(shortcut.to_string()); + let (key, modifier_parts) = parts.split_last().ok_or_else(invalid)?; + let modifiers: Vec<&str> = modifier_parts + .iter() + .map(|part| if *part == "cmd" { "ctrl" } else { part }) + .collect(); + let mut distinct = modifiers.clone(); + distinct.sort_unstable(); + distinct.dedup(); + let key_is_valid = NAMED_KEYS.contains(key) + || (key.len() == 1 + && key + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())); + if distinct.len() != modifiers.len() + || !modifiers.iter().all(|part| MODIFIER_ORDER.contains(part)) + || !key_is_valid + { + return Err(invalid()); + } + let mut canonical: Vec<&str> = MODIFIER_ORDER + .into_iter() + .filter(|part| modifiers.contains(part)) + .collect(); + canonical.push(key); + let result = canonical.join("+"); + let is_arrow = matches!(*key, "left" | "right"); + if RESERVED.contains(&result.as_str()) || (!is_arrow && modifiers.iter().all(|m| *m == "shift")) + { + return Err(SwitcherShortcutError::Reserved(shortcut.to_string())); + } + Ok(result) +} + +/// Overlay `overrides` on the defaults and validate the result: unknown +/// actions and duplicate assignments are rejected; omitted actions keep their +/// default. Returns the fully resolved, normalized map. +pub fn resolve( + overrides: &BTreeMap, +) -> Result, SwitcherShortcutError> { + let mut resolved = default_map(); + for (action, shortcut) in overrides { + let slot = resolved + .get_mut(action) + .ok_or_else(|| SwitcherShortcutError::UnknownAction(action.clone()))?; + *slot = normalize(shortcut)?; + } + let mut assigned: Vec<&String> = resolved.values().filter(|value| *value != NONE).collect(); + assigned.sort_unstable(); + let count = assigned.len(); + assigned.dedup(); + if assigned.len() != count { + return Err(SwitcherShortcutError::Duplicate); + } + Ok(resolved) +} + +/// [`resolve`], falling back to the defaults when `overrides` is invalid +/// (stored maps are validated on write, so this only guards a corrupt file). +pub fn resolve_or_default(overrides: &BTreeMap) -> BTreeMap { + resolve(overrides).unwrap_or_else(|_| default_map()) +} + +fn default_map() -> BTreeMap { + DEFAULTS + .iter() + .map(|(action, shortcut)| (action.to_string(), shortcut.to_string())) + .collect() +} + +/// Validate `overrides` and reduce them to the entries that differ from the +/// defaults, normalized. This is what gets persisted. +pub fn normalize_overrides( + overrides: &BTreeMap, +) -> Result, SwitcherShortcutError> { + let resolved = resolve(overrides)?; + Ok(DEFAULTS + .iter() + .filter_map(|(action, default)| { + let value = &resolved[*action]; + (value != default).then(|| (action.to_string(), value.clone())) + }) + .collect()) +} + +#[cfg(test)] +mod tests; diff --git a/rust/src/switcher_shortcuts/tests.rs b/rust/src/switcher_shortcuts/tests.rs new file mode 100644 index 0000000000..14474045eb --- /dev/null +++ b/rust/src/switcher_shortcuts/tests.rs @@ -0,0 +1,190 @@ +use super::*; + +fn overrides(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(action, shortcut)| (action.to_string(), shortcut.to_string())) + .collect() +} + +#[test] +fn defaults_resolve_unchanged() { + let resolved = resolve(&BTreeMap::new()).unwrap(); + assert_eq!(resolved.len(), 11); + assert_eq!(resolved["previous"], "left"); + assert_eq!(resolved["next"], "right"); + assert_eq!(resolved["select9"], "ctrl+9"); +} + +#[test] +fn normalize_orders_modifiers_and_folds_cmd() { + assert_eq!(normalize("Shift+Ctrl+Right").unwrap(), "ctrl+shift+right"); + assert_eq!(normalize(" alt + ctrl + 2 ").unwrap(), "ctrl+alt+2"); + assert_eq!(normalize("alt+cmd+2").unwrap(), "ctrl+alt+2"); + assert_eq!(normalize("cmd+3").unwrap(), "ctrl+3"); + assert_eq!(normalize("Alt+,").unwrap(), "alt+,"); + assert_eq!(normalize("Shift+Ctrl+,").unwrap(), "ctrl+shift+,"); + assert_eq!(normalize("NONE").unwrap(), NONE); + assert_eq!(normalize("left").unwrap(), "left"); + assert_eq!(normalize("shift+right").unwrap(), "shift+right"); +} + +#[test] +fn normalize_rejects_malformed_shortcuts() { + for bad in [ + "", + "ctrl+", + "ctrl+ab", + "ctrl+ctrl+1", + "ctrl+cmd+1", + "meta+1", + "ctrl+f1", + "ctrl+up", + "alt+f4", + "alt+tab", + "ctrl+none", + ] { + assert!( + matches!(normalize(bad), Err(SwitcherShortcutError::Invalid(_))), + "{bad}" + ); + } +} + +#[test] +fn normalize_rejects_reserved_shortcuts() { + for reserved in [ + "ctrl+r", "cmd+q", "ctrl+,", "ctrl+w", "a", "1", ",", "shift+a", "shift+1", + ] { + assert!( + matches!(normalize(reserved), Err(SwitcherShortcutError::Reserved(_))), + "{reserved}" + ); + } + assert_eq!(normalize("alt+a").unwrap(), "alt+a"); + assert_eq!(normalize("shift+ctrl+r").unwrap(), "ctrl+shift+r"); +} + +#[test] +fn resolve_overlays_and_normalizes_overrides() { + let resolved = resolve(&overrides(&[ + ("select2", "alt+cmd+2"), + ("next", "shift+right"), + ])) + .unwrap(); + assert_eq!(resolved["select2"], "ctrl+alt+2"); + assert_eq!(resolved["next"], "shift+right"); + assert_eq!(resolved["previous"], "left"); +} + +#[test] +fn resolve_rejects_unknown_actions() { + assert_eq!( + resolve(&overrides(&[("select10", "ctrl+0")])), + Err(SwitcherShortcutError::UnknownAction("select10".to_string())) + ); +} + +#[test] +fn resolve_rejects_duplicates_including_untouched_defaults() { + assert_eq!( + resolve(&overrides(&[("next", "left")])), + Err(SwitcherShortcutError::Duplicate) + ); + assert_eq!( + resolve(&overrides(&[("select1", "cmd+2")])), + Err(SwitcherShortcutError::Duplicate) + ); +} + +#[test] +fn none_disables_an_action_and_frees_its_key() { + let resolved = resolve(&overrides(&[("previous", "none"), ("next", "left")])).unwrap(); + assert_eq!(resolved["previous"], NONE); + assert_eq!(resolved["next"], "left"); + assert!(resolve(&overrides(&[("select1", "none"), ("select2", "none")])).is_ok()); +} + +#[test] +fn normalize_overrides_keeps_only_non_default_entries() { + let stored = normalize_overrides(&overrides(&[ + ("previous", "left"), + ("select2", "alt+cmd+2"), + ("next", "NONE"), + ])) + .unwrap(); + assert_eq!( + stored, + overrides(&[("select2", "ctrl+alt+2"), ("next", "none")]) + ); + assert!(normalize_overrides(&BTreeMap::new()).unwrap().is_empty()); +} + +mod persistence { + use super::*; + use crate::settings::Settings; + + #[test] + fn default_settings_have_no_overrides_and_omit_the_key() { + let settings = Settings::default(); + assert!(settings.switcher_shortcuts.is_empty()); + let json = serde_json::to_string(&settings).unwrap(); + assert!(!json.contains("switcher_shortcuts")); + } + + #[test] + fn overrides_roundtrip_through_settings_json() { + let settings = Settings { + switcher_shortcuts: overrides(&[("select2", "ctrl+alt+2"), ("next", "none")]), + ..Settings::default() + }; + let loaded: Settings = + serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap(); + assert_eq!(loaded.switcher_shortcuts, settings.switcher_shortcuts); + } + + #[test] + fn missing_key_loads_as_defaults() { + let loaded: Settings = serde_json::from_str(r#"{ "enabled_providers": [] }"#).unwrap(); + assert!(loaded.switcher_shortcuts.is_empty()); + } + + #[test] + fn hand_edited_map_is_normalized_on_load() { + let loaded: Settings = serde_json::from_str( + r#"{ "switcher_shortcuts": { "select2": "Alt+Cmd+2", "previous": "left" } }"#, + ) + .unwrap(); + assert_eq!( + loaded.switcher_shortcuts, + overrides(&[("select2", "ctrl+alt+2")]) + ); + } + + #[test] + fn invalid_stored_map_falls_back_to_defaults_without_breaking_load() { + for stored in [ + r#"{ "bogus": "ctrl+1" }"#, + r#"{ "next": "left" }"#, + r#"{ "next": "ctrl+r" }"#, + r#"{ "next": "f1" }"#, + ] { + let loaded: Settings = serde_json::from_str(&format!( + r#"{{ "refresh_interval_secs": 120, "switcher_shortcuts": {stored} }}"# + )) + .unwrap(); + assert!(loaded.switcher_shortcuts.is_empty(), "{stored}"); + assert_eq!(loaded.refresh_interval_secs, 120); + } + } + + #[test] + fn resolve_or_default_survives_invalid_overrides() { + let resolved = resolve_or_default(&overrides(&[("next", "left")])); + assert_eq!(resolved["next"], "right"); + assert_eq!( + resolve_or_default(&overrides(&[("next", "alt+n")]))["next"], + "alt+n" + ); + } +}