diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index dccbdb00b..f594dbe0e 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -200,6 +200,21 @@ pub(crate) struct AppSettings { pub(crate) remote_backend_token: Option, #[serde(default = "default_access_mode", rename = "defaultAccessMode")] pub(crate) default_access_mode: String, + #[serde( + default = "default_composer_model_shortcut", + rename = "composerModelShortcut" + )] + pub(crate) composer_model_shortcut: Option, + #[serde( + default = "default_composer_access_shortcut", + rename = "composerAccessShortcut" + )] + pub(crate) composer_access_shortcut: Option, + #[serde( + default = "default_composer_reasoning_shortcut", + rename = "composerReasoningShortcut" + )] + pub(crate) composer_reasoning_shortcut: Option, #[serde(default, rename = "lastComposerModelId")] pub(crate) last_composer_model_id: Option, #[serde(default, rename = "lastComposerReasoningEffort")] @@ -269,6 +284,18 @@ fn default_ui_scale() -> f64 { 1.0 } +fn default_composer_model_shortcut() -> Option { + Some("cmd+shift+m".to_string()) +} + +fn default_composer_access_shortcut() -> Option { + Some("cmd+shift+a".to_string()) +} + +fn default_composer_reasoning_shortcut() -> Option { + Some("cmd+shift+r".to_string()) +} + fn default_notification_sounds_enabled() -> bool { true } @@ -309,6 +336,9 @@ impl Default for AppSettings { remote_backend_host: default_remote_backend_host(), remote_backend_token: None, default_access_mode: "current".to_string(), + composer_model_shortcut: default_composer_model_shortcut(), + composer_access_shortcut: default_composer_access_shortcut(), + composer_reasoning_shortcut: default_composer_reasoning_shortcut(), last_composer_model_id: None, last_composer_reasoning_effort: None, ui_scale: 1.0, @@ -337,6 +367,18 @@ mod tests { assert_eq!(settings.remote_backend_host, "127.0.0.1:4732"); assert!(settings.remote_backend_token.is_none()); assert_eq!(settings.default_access_mode, "current"); + assert_eq!( + settings.composer_model_shortcut.as_deref(), + Some("cmd+shift+m") + ); + assert_eq!( + settings.composer_access_shortcut.as_deref(), + Some("cmd+shift+a") + ); + assert_eq!( + settings.composer_reasoning_shortcut.as_deref(), + Some("cmd+shift+r") + ); assert!(settings.last_composer_model_id.is_none()); assert!(settings.last_composer_reasoning_effort.is_none()); assert!((settings.ui_scale - 1.0).abs() < f64::EPSILON); diff --git a/src/App.tsx b/src/App.tsx index d21bb6593..843ad4eb7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -64,6 +64,7 @@ import { import { useAppSettings } from "./features/settings/hooks/useAppSettings"; import { useUpdater } from "./features/update/hooks/useUpdater"; import { useComposerImages } from "./features/composer/hooks/useComposerImages"; +import { useComposerShortcuts } from "./features/composer/hooks/useComposerShortcuts"; import { useDictationModel } from "./features/dictation/hooks/useDictationModel"; import { useDictation } from "./features/dictation/hooks/useDictation"; import { useHoldToDictate } from "./features/dictation/hooks/useHoldToDictate"; @@ -177,7 +178,13 @@ function MainApp() { const [composerInsert, setComposerInsert] = useState( null ); - type SettingsSection = "projects" | "display" | "dictation" | "codex" | "experimental"; + type SettingsSection = + | "projects" + | "display" + | "dictation" + | "shortcuts" + | "codex" + | "experimental"; const [settingsOpen, setSettingsOpen] = useState(false); const [settingsSection, setSettingsSection] = useState( null, @@ -381,6 +388,21 @@ function MainApp() { preferredModelId: appSettings.lastComposerModelId, preferredEffort: appSettings.lastComposerReasoningEffort, }); + + useComposerShortcuts({ + textareaRef: composerInputRef, + modelShortcut: appSettings.composerModelShortcut, + accessShortcut: appSettings.composerAccessShortcut, + reasoningShortcut: appSettings.composerReasoningShortcut, + models, + selectedModelId, + onSelectModel: setSelectedModelId, + accessMode, + onSelectAccessMode: setAccessMode, + reasoningOptions, + selectedEffort, + onSelectEffort: setSelectedEffort, + }); const { collaborationModes, selectedCollaborationMode, diff --git a/src/features/composer/hooks/useComposerShortcuts.ts b/src/features/composer/hooks/useComposerShortcuts.ts new file mode 100644 index 000000000..9c89c061b --- /dev/null +++ b/src/features/composer/hooks/useComposerShortcuts.ts @@ -0,0 +1,100 @@ +import { useEffect } from "react"; +import type { AccessMode } from "../../../types"; +import { matchesShortcut } from "../../../utils/shortcuts"; + +type ModelOption = { id: string; displayName: string; model: string }; + +type UseComposerShortcutsOptions = { + textareaRef: React.RefObject; + modelShortcut: string | null; + accessShortcut: string | null; + reasoningShortcut: string | null; + models: ModelOption[]; + selectedModelId: string | null; + onSelectModel: (id: string) => void; + accessMode: AccessMode; + onSelectAccessMode: (mode: AccessMode) => void; + reasoningOptions: string[]; + selectedEffort: string | null; + onSelectEffort: (effort: string) => void; +}; + +const ACCESS_ORDER: AccessMode[] = ["read-only", "current", "full-access"]; + +export function useComposerShortcuts({ + textareaRef, + modelShortcut, + accessShortcut, + reasoningShortcut, + models, + selectedModelId, + onSelectModel, + accessMode, + onSelectAccessMode, + reasoningOptions, + selectedEffort, + onSelectEffort, +}: UseComposerShortcutsOptions) { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.repeat) { + return; + } + if (document.activeElement !== textareaRef.current) { + return; + } + if (matchesShortcut(event, modelShortcut)) { + event.preventDefault(); + if (models.length === 0) { + return; + } + const currentIndex = models.findIndex((model) => model.id === selectedModelId); + const nextIndex = currentIndex >= 0 ? (currentIndex + 1) % models.length : 0; + const nextModel = models[nextIndex]; + if (nextModel) { + onSelectModel(nextModel.id); + } + return; + } + if (matchesShortcut(event, accessShortcut)) { + event.preventDefault(); + const currentIndex = ACCESS_ORDER.indexOf(accessMode); + const nextIndex = currentIndex >= 0 ? (currentIndex + 1) % ACCESS_ORDER.length : 0; + const nextAccess = ACCESS_ORDER[nextIndex]; + if (nextAccess) { + onSelectAccessMode(nextAccess); + } + return; + } + if (matchesShortcut(event, reasoningShortcut)) { + event.preventDefault(); + if (reasoningOptions.length === 0) { + return; + } + const currentIndex = reasoningOptions.indexOf(selectedEffort ?? ""); + const nextIndex = + currentIndex >= 0 ? (currentIndex + 1) % reasoningOptions.length : 0; + const nextEffort = reasoningOptions[nextIndex]; + if (nextEffort) { + onSelectEffort(nextEffort); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + accessMode, + accessShortcut, + modelShortcut, + models, + onSelectAccessMode, + onSelectEffort, + onSelectModel, + reasoningOptions, + reasoningShortcut, + selectedEffort, + selectedModelId, + textareaRef, + ]); +} diff --git a/src/features/models/hooks/useModels.ts b/src/features/models/hooks/useModels.ts index 0ae3166fe..fe14afddc 100644 --- a/src/features/models/hooks/useModels.ts +++ b/src/features/models/hooks/useModels.ts @@ -9,6 +9,12 @@ type UseModelsOptions = { preferredEffort?: string | null; }; +const pickDefaultModel = (models: ModelOption[]) => + models.find((model) => model.model === "gpt-5.2-codex") ?? + models.find((model) => model.isDefault) ?? + models[0] ?? + null; + export function useModels({ activeWorkspace, onDebug, @@ -16,14 +22,36 @@ export function useModels({ preferredEffort = null, }: UseModelsOptions) { const [models, setModels] = useState([]); - const [selectedModelId, setSelectedModelId] = useState(null); - const [selectedEffort, setSelectedEffort] = useState(null); + const [selectedModelId, setSelectedModelIdState] = useState(null); + const [selectedEffort, setSelectedEffortState] = useState(null); const lastFetchedWorkspaceId = useRef(null); const inFlight = useRef(false); + const hasUserSelectedModel = useRef(false); + const hasUserSelectedEffort = useRef(false); + const lastWorkspaceId = useRef(null); const workspaceId = activeWorkspace?.id ?? null; const isConnected = Boolean(activeWorkspace?.connected); + useEffect(() => { + if (workspaceId === lastWorkspaceId.current) { + return; + } + hasUserSelectedModel.current = false; + hasUserSelectedEffort.current = false; + lastWorkspaceId.current = workspaceId; + }, [workspaceId]); + + const setSelectedModelId = useCallback((next: string | null) => { + hasUserSelectedModel.current = true; + setSelectedModelIdState(next); + }, []); + + const setSelectedEffort = useCallback((next: string | null) => { + hasUserSelectedEffort.current = true; + setSelectedEffortState(next); + }, []); + const selectedModel = useMemo( () => models.find((model) => model.id === selectedModelId) ?? null, [models, selectedModelId], @@ -38,6 +66,26 @@ export function useModels({ ); }, [selectedModel]); + const resolveEffort = useCallback( + (model: ModelOption, preferCurrent: boolean) => { + const supportedEfforts = model.supportedReasoningEfforts.map( + (effort) => effort.reasoningEffort, + ); + if ( + preferCurrent && + selectedEffort && + supportedEfforts.includes(selectedEffort) + ) { + return selectedEffort; + } + if (preferredEffort && supportedEfforts.includes(preferredEffort)) { + return preferredEffort; + } + return model.defaultReasoningEffort ?? null; + }, + [preferredEffort, selectedEffort], + ); + const refreshModels = useCallback(async () => { if (!workspaceId || !isConnected) { return; @@ -85,28 +133,32 @@ export function useModels({ })); setModels(data); lastFetchedWorkspaceId.current = workspaceId; - const preferredModel = - data.find((model) => model.model === "gpt-5.2-codex") ?? null; - const defaultModel = - preferredModel ?? data.find((model) => model.isDefault) ?? data[0] ?? null; + const defaultModel = pickDefaultModel(data); const existingSelection = data.find((model) => model.id === selectedModelId) ?? null; - const preferredSelection = data.find((model) => model.id === preferredModelId) ?? null; - const nextSelection = existingSelection ?? preferredSelection ?? defaultModel; + if (selectedModelId && !existingSelection) { + hasUserSelectedModel.current = false; + } + const preferredSelection = preferredModelId + ? data.find((model) => model.id === preferredModelId) ?? null + : null; + const shouldKeepExisting = + hasUserSelectedModel.current && existingSelection !== null; + const nextSelection = + (shouldKeepExisting ? existingSelection : null) ?? + preferredSelection ?? + defaultModel ?? + existingSelection; if (nextSelection) { - setSelectedModelId(nextSelection.id); - const nextEffort = - selectedEffort && - nextSelection.supportedReasoningEfforts.some( - (effort) => effort.reasoningEffort === selectedEffort, - ) - ? selectedEffort - : preferredEffort && - nextSelection.supportedReasoningEfforts.some( - (effort) => effort.reasoningEffort === preferredEffort, - ) - ? preferredEffort - : nextSelection.defaultReasoningEffort ?? null; - setSelectedEffort(nextEffort); + if (nextSelection.id !== selectedModelId) { + setSelectedModelIdState(nextSelection.id); + } + const nextEffort = resolveEffort( + nextSelection, + hasUserSelectedEffort.current, + ); + if (nextEffort !== selectedEffort) { + setSelectedEffortState(nextEffort); + } } } catch (error) { onDebug?.({ @@ -122,10 +174,10 @@ export function useModels({ }, [ isConnected, onDebug, - preferredEffort, preferredModelId, selectedEffort, selectedModelId, + resolveEffort, workspaceId, ]); @@ -151,7 +203,8 @@ export function useModels({ ) { return; } - setSelectedEffort(selectedModel.defaultReasoningEffort ?? null); + hasUserSelectedEffort.current = false; + setSelectedEffortState(selectedModel.defaultReasoningEffort ?? null); }, [selectedEffort, selectedModel]); useEffect(() => { @@ -161,44 +214,31 @@ export function useModels({ const preferredSelection = preferredModelId ? models.find((model) => model.id === preferredModelId) ?? null : null; - if (!preferredSelection) { - return; + const defaultModel = pickDefaultModel(models); + const existingSelection = selectedModelId + ? models.find((model) => model.id === selectedModelId) ?? null + : null; + if (selectedModelId && !existingSelection) { + hasUserSelectedModel.current = false; } - const preferredCodex = - models.find((model) => model.model === "gpt-5.2-codex") ?? null; - const defaultModel = - preferredCodex ?? models.find((model) => model.isDefault) ?? models[0] ?? null; - const shouldApplyPreferredModel = - !selectedModelId || - (defaultModel && - selectedModelId === defaultModel.id && - selectedModelId !== preferredSelection.id); - if (shouldApplyPreferredModel) { - setSelectedModelId(preferredSelection.id); - const nextEffort = - preferredEffort && - preferredSelection.supportedReasoningEfforts.some( - (effort) => effort.reasoningEffort === preferredEffort, - ) - ? preferredEffort - : preferredSelection.defaultReasoningEffort ?? null; - setSelectedEffort(nextEffort); + const shouldKeepUserSelection = + hasUserSelectedModel.current && existingSelection !== null; + if (shouldKeepUserSelection) { return; } - if (selectedModelId !== preferredSelection.id || !preferredEffort) { + const nextSelection = + preferredSelection ?? defaultModel ?? existingSelection ?? null; + if (!nextSelection) { return; } - const preferredEffortSupported = preferredSelection.supportedReasoningEfforts.some( - (effort) => effort.reasoningEffort === preferredEffort, - ); - if (!preferredEffortSupported) { - return; + if (nextSelection.id !== selectedModelId) { + setSelectedModelIdState(nextSelection.id); } - const defaultEffort = preferredSelection.defaultReasoningEffort ?? null; - if (!selectedEffort || selectedEffort === defaultEffort) { - setSelectedEffort(preferredEffort); + const nextEffort = resolveEffort(nextSelection, hasUserSelectedEffort.current); + if (nextEffort !== selectedEffort) { + setSelectedEffortState(nextEffort); } - }, [models, preferredEffort, preferredModelId, selectedEffort, selectedModelId]); + }, [models, preferredModelId, selectedEffort, selectedModelId, resolveEffort]); return { models, diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index 4b879ffa7..b6b44aa6e 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -6,6 +6,7 @@ import { LayoutGrid, SlidersHorizontal, Mic, + Keyboard, Stethoscope, TerminalSquare, Trash2, @@ -20,6 +21,7 @@ import type { WorkspaceInfo, } from "../../../types"; import { formatDownloadSize } from "../../../utils/formatting"; +import { buildShortcutValue, formatShortcut } from "../../../utils/shortcuts"; import { clampUiScale } from "../../../utils/uiScale"; const DICTATION_MODELS = [ @@ -65,7 +67,7 @@ type SettingsViewProps = { initialSection?: CodexSection; }; -type SettingsSection = "projects" | "display" | "dictation"; +type SettingsSection = "projects" | "display" | "dictation" | "shortcuts"; type CodexSection = SettingsSection | "codex" | "experimental"; export function SettingsView({ @@ -111,6 +113,11 @@ export function SettingsView({ result: CodexDoctorResult | null; }>({ status: "idle", result: null }); const [isSavingSettings, setIsSavingSettings] = useState(false); + const [shortcutDrafts, setShortcutDrafts] = useState({ + model: appSettings.composerModelShortcut ?? "", + access: appSettings.composerAccessShortcut ?? "", + reasoning: appSettings.composerReasoningShortcut ?? "", + }); const dictationReady = dictationModelStatus?.state === "ready"; const dictationProgress = dictationModelStatus?.progress ?? null; const selectedDictationModel = useMemo(() => { @@ -142,6 +149,18 @@ export function SettingsView({ setScaleDraft(`${Math.round(clampUiScale(appSettings.uiScale) * 100)}%`); }, [appSettings.uiScale]); + useEffect(() => { + setShortcutDrafts({ + model: appSettings.composerModelShortcut ?? "", + access: appSettings.composerAccessShortcut ?? "", + reasoning: appSettings.composerReasoningShortcut ?? "", + }); + }, [ + appSettings.composerAccessShortcut, + appSettings.composerModelShortcut, + appSettings.composerReasoningShortcut, + ]); + useEffect(() => { setOverrideDrafts((prev) => { const next: Record = {}; @@ -275,6 +294,43 @@ export function SettingsView({ } }; + const updateShortcut = async ( + key: "composerModelShortcut" | "composerAccessShortcut" | "composerReasoningShortcut", + value: string | null, + ) => { + setShortcutDrafts((prev) => ({ + ...prev, + [key === "composerModelShortcut" + ? "model" + : key === "composerAccessShortcut" + ? "access" + : "reasoning"]: value ?? "", + })); + await onUpdateAppSettings({ + ...appSettings, + [key]: value, + }); + }; + + const handleShortcutKeyDown = ( + event: React.KeyboardEvent, + key: "composerModelShortcut" | "composerAccessShortcut" | "composerReasoningShortcut", + ) => { + if (event.key === "Tab") { + return; + } + event.preventDefault(); + if (event.key === "Backspace" || event.key === "Delete") { + void updateShortcut(key, null); + return; + } + const value = buildShortcutValue(event.nativeEvent); + if (!value) { + return; + } + void updateShortcut(key, value); + }; + const trimmedGroupName = newGroupName.trim(); const canCreateGroup = Boolean(trimmedGroupName); @@ -380,6 +436,14 @@ export function SettingsView({ Dictation + + +
+ Press a new shortcut while focused. Default: {formatShortcut("cmd+shift+m")} +
+ +
+
Cycle access mode
+
+ + handleShortcutKeyDown(event, "composerAccessShortcut") + } + placeholder="Type shortcut" + readOnly + /> + +
+
+ Default: {formatShortcut("cmd+shift+a")} +
+
+
+
Cycle reasoning mode
+
+ + handleShortcutKeyDown(event, "composerReasoningShortcut") + } + placeholder="Type shortcut" + readOnly + /> + +
+
+ Default: {formatShortcut("cmd+shift+r")} +
+
+ + )} {activeSection === "codex" && (
Codex
diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index 86c149588..85a48882c 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -9,6 +9,9 @@ const defaultSettings: AppSettings = { remoteBackendHost: "127.0.0.1:4732", remoteBackendToken: null, defaultAccessMode: "current", + composerModelShortcut: "cmd+shift+m", + composerAccessShortcut: "cmd+shift+a", + composerReasoningShortcut: "cmd+shift+r", lastComposerModelId: null, lastComposerReasoningEffort: null, uiScale: UI_SCALE_DEFAULT, diff --git a/src/styles/settings.css b/src/styles/settings.css index d5af4e7fb..acd90ad8b 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -190,6 +190,11 @@ text-align: right; } +.settings-input--shortcut { + font-family: "SF Mono", "Fira Mono", "Menlo", monospace; + letter-spacing: 0.02em; +} + .settings-scale-controls { display: flex; diff --git a/src/types.ts b/src/types.ts index 26d8c3c1a..77ae1d111 100644 --- a/src/types.ts +++ b/src/types.ts @@ -77,6 +77,9 @@ export type AppSettings = { remoteBackendHost: string; remoteBackendToken: string | null; defaultAccessMode: AccessMode; + composerModelShortcut: string | null; + composerAccessShortcut: string | null; + composerReasoningShortcut: string | null; lastComposerModelId: string | null; lastComposerReasoningEffort: string | null; uiScale: number; diff --git a/src/utils/shortcuts.ts b/src/utils/shortcuts.ts new file mode 100644 index 000000000..58b5bde5d --- /dev/null +++ b/src/utils/shortcuts.ts @@ -0,0 +1,128 @@ +export type ShortcutDefinition = { + key: string; + meta: boolean; + ctrl: boolean; + alt: boolean; + shift: boolean; +}; + +const MODIFIER_ORDER = ["cmd", "ctrl", "alt", "shift"] as const; +const MODIFIER_LABELS: Record = { + cmd: "⌘", + ctrl: "⌃", + alt: "⌥", + shift: "⇧", +}; + +const KEY_LABELS: Record = { + " ": "Space", + escape: "Esc", + arrowup: "↑", + arrowdown: "↓", + arrowleft: "←", + arrowright: "→", +}; + +const MODIFIER_KEYS = new Set(["shift", "control", "alt", "meta"]); + +function normalizeKey(key: string) { + const normalized = key.toLowerCase(); + if (MODIFIER_KEYS.has(normalized)) { + return null; + } + if (normalized === " ") { + return "space"; + } + return normalized; +} + +export function parseShortcut(value: string | null | undefined): ShortcutDefinition | null { + if (!value) { + return null; + } + const parts = value + .split("+") + .map((part) => part.trim().toLowerCase()) + .filter(Boolean); + if (parts.length === 0) { + return null; + } + const key = parts[parts.length - 1] ?? ""; + if (!key || MODIFIER_KEYS.has(key)) { + return null; + } + return { + key, + meta: parts.includes("cmd") || parts.includes("meta"), + ctrl: parts.includes("ctrl") || parts.includes("control"), + alt: parts.includes("alt") || parts.includes("option"), + shift: parts.includes("shift"), + }; +} + +export function formatShortcut(value: string | null | undefined): string { + if (!value) { + return "Not set"; + } + const parsed = parseShortcut(value); + if (!parsed) { + return value; + } + const modifiers = MODIFIER_ORDER.flatMap((modifier) => { + if (modifier === "cmd" && parsed.meta) { + return MODIFIER_LABELS.cmd; + } + if (modifier === "ctrl" && parsed.ctrl) { + return MODIFIER_LABELS.ctrl; + } + if (modifier === "alt" && parsed.alt) { + return MODIFIER_LABELS.alt; + } + if (modifier === "shift" && parsed.shift) { + return MODIFIER_LABELS.shift; + } + return []; + }); + const keyLabel = + KEY_LABELS[parsed.key] ?? + (parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key); + return [...modifiers, keyLabel].join(""); +} + +export function buildShortcutValue(event: KeyboardEvent): string | null { + const key = normalizeKey(event.key); + if (!key) { + return null; + } + const modifiers = []; + if (event.metaKey) { + modifiers.push("cmd"); + } + if (event.ctrlKey) { + modifiers.push("ctrl"); + } + if (event.altKey) { + modifiers.push("alt"); + } + if (event.shiftKey) { + modifiers.push("shift"); + } + return [...modifiers, key].join("+"); +} + +export function matchesShortcut(event: KeyboardEvent, value: string | null | undefined): boolean { + const parsed = parseShortcut(value); + if (!parsed) { + return false; + } + const key = normalizeKey(event.key); + if (!key || key !== parsed.key) { + return false; + } + return ( + parsed.meta === event.metaKey && + parsed.ctrl === event.ctrlKey && + parsed.alt === event.altKey && + parsed.shift === event.shiftKey + ); +}