Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,7 @@ pub struct SettingsSnapshot {
auto_download_updates: bool,
install_updates_on_quit: bool,
global_shortcut: String,
switcher_shortcuts: std::collections::BTreeMap<String, String>,
codex_custom_sessions_dirs: Vec<String>,
agent_sessions_enabled: bool,
agent_session_ssh_hosts: Vec<String>,
Expand Down Expand Up @@ -797,6 +798,9 @@ impl From<Settings> 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,
Expand Down
90 changes: 90 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ pub struct SettingsUpdate {
pub auto_download_updates: Option<bool>,
pub install_updates_on_quit: Option<bool>,
pub global_shortcut: Option<String>,
/// Provider-switcher shortcut overrides; replaces the stored overrides.
pub switcher_shortcuts: Option<std::collections::BTreeMap<String, String>>,
pub codex_custom_sessions_dirs: Option<Vec<String>>,
pub agent_sessions_enabled: Option<bool>,
pub agent_session_ssh_hosts: Option<Vec<String>>,
Expand Down Expand Up @@ -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)?;
}
Expand Down Expand Up @@ -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);
}
}
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ function settings(overrides: Partial<SettingsSnapshot> = {}): SettingsSnapshot {
autoDownloadUpdates: false,
installUpdatesOnQuit: false,
globalShortcut: "Ctrl+Shift+U",
switcherShortcuts: {},
codexCustomSessionsDirs: [],
uiLanguage: "english",
// "dark" (not "auto") so useTheme's effect short-circuits before ever
Expand Down
21 changes: 21 additions & 0 deletions apps/desktop-tauri/src/components/ShortcutCapture.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" }));
Expand Down
49 changes: 44 additions & 5 deletions apps/desktop-tauri/src/components/ShortcutCapture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -65,6 +77,10 @@ export function ShortcutCapture({
disabled,
onCommit,
onClear,
compose = composeAccelerator,
recordingHint,
emptyLabel,
accessibleLabel,
}: ShortcutCaptureProps) {
const { t } = useLocale();
const [recording, setRecording] = useState(false);
Expand All @@ -91,7 +107,7 @@ export function ShortcutCapture({
return;
}

const accel = composeAccelerator(e);
const accel = compose(e);
if (accel) {
stopRecording();
onCommit(accel);
Expand All @@ -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) {
Expand All @@ -111,11 +127,15 @@ export function ShortcutCapture({
}, [recording]);

const chipText = recording
? t("ShortcutRecordingHint")
: value || t("ShortcutEmptyPlaceholder");
? (recordingHint ?? t("ShortcutRecordingHint"))
: value || (emptyLabel ?? t("ShortcutEmptyPlaceholder"));

return (
<div className="shortcut-capture">
<div
className="shortcut-capture"
role={accessibleLabel ? "group" : undefined}
aria-label={accessibleLabel}
>
<div
ref={chipRef}
tabIndex={-1}
Expand All @@ -124,7 +144,12 @@ export function ShortcutCapture({
(recording ? " shortcut-capture__chip--recording" : "") +
(!value && !recording ? " shortcut-capture__chip--empty" : "")
}
role="status"
aria-live="polite"
aria-atomic="true"
aria-label={
accessibleLabel ? `${accessibleLabel}: ${chipText}` : undefined
}
>
{chipText}
</div>
Expand All @@ -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")}
Expand All @@ -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")}
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/floatbar/FloatBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ function settings(overrides: Partial<SettingsSnapshot> = {}): SettingsSnapshot {
autoDownloadUpdates: false,
installUpdatesOnQuit: false,
globalShortcut: "Ctrl+Shift+U",
switcherShortcuts: {},
codexCustomSessionsDirs: [],
uiLanguage: "english",
theme: "dark",
Expand Down
59 changes: 59 additions & 0 deletions apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> }) =>
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();
Expand Down
18 changes: 13 additions & 5 deletions apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.ts
Original file line number Diff line number Diff line change
@@ -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"]';
Expand All @@ -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<Record<string, string>>;
}) {
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) => {
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/hooks/useTrayPanelController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});
Expand Down
Loading