diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index cf93e7b3f..05deb065f 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -312,6 +312,8 @@ pub(crate) struct AppSettings { rename = "composerReasoningShortcut" )] pub(crate) composer_reasoning_shortcut: Option, + #[serde(default = "default_interrupt_shortcut", rename = "interruptShortcut")] + pub(crate) interrupt_shortcut: Option, #[serde( default = "default_composer_collaboration_shortcut", rename = "composerCollaborationShortcut" @@ -505,6 +507,15 @@ fn default_composer_reasoning_shortcut() -> Option { Some("cmd+shift+r".to_string()) } +fn default_interrupt_shortcut() -> Option { + let value = if cfg!(target_os = "macos") { + "ctrl+c" + } else { + "ctrl+shift+c" + }; + Some(value.to_string()) +} + fn default_composer_collaboration_shortcut() -> Option { Some("shift+tab".to_string()) } @@ -694,6 +705,7 @@ impl Default for AppSettings { composer_model_shortcut: default_composer_model_shortcut(), composer_access_shortcut: default_composer_access_shortcut(), composer_reasoning_shortcut: default_composer_reasoning_shortcut(), + interrupt_shortcut: default_interrupt_shortcut(), composer_collaboration_shortcut: default_composer_collaboration_shortcut(), new_agent_shortcut: default_new_agent_shortcut(), new_worktree_agent_shortcut: default_new_worktree_agent_shortcut(), @@ -768,6 +780,12 @@ mod tests { settings.composer_collaboration_shortcut.as_deref(), Some("shift+tab") ); + let expected_interrupt = if cfg!(target_os = "macos") { + "ctrl+c" + } else { + "ctrl+shift+c" + }; + assert_eq!(settings.interrupt_shortcut.as_deref(), Some(expected_interrupt)); assert_eq!( settings.toggle_debug_panel_shortcut.as_deref(), Some("cmd+shift+d") diff --git a/src/App.tsx b/src/App.tsx index 15954c9c6..a1fd03660 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -82,6 +82,7 @@ import { useAppMenuEvents } from "./features/app/hooks/useAppMenuEvents"; import { useWorkspaceActions } from "./features/app/hooks/useWorkspaceActions"; import { useWorkspaceCycling } from "./features/app/hooks/useWorkspaceCycling"; import { useThreadRows } from "./features/app/hooks/useThreadRows"; +import { useInterruptShortcut } from "./features/app/hooks/useInterruptShortcut"; import { useLiquidGlassEffect } from "./features/app/hooks/useLiquidGlassEffect"; import { useCopyThread } from "./features/threads/hooks/useCopyThread"; import { useTerminalController } from "./features/terminal/hooks/useTerminalController"; @@ -1208,6 +1209,14 @@ function MainApp() { onDropPaths: handleDropWorkspacePaths, }); + useInterruptShortcut({ + isEnabled: canInterrupt, + shortcut: appSettings.interruptShortcut, + onTrigger: () => { + void interruptTurn(); + }, + }); + const { handleSelectPullRequest, resetPullRequestSelection, diff --git a/src/features/app/hooks/useInterruptShortcut.ts b/src/features/app/hooks/useInterruptShortcut.ts new file mode 100644 index 000000000..b1073a69b --- /dev/null +++ b/src/features/app/hooks/useInterruptShortcut.ts @@ -0,0 +1,40 @@ +import { useEffect } from "react"; +import { matchesShortcut } from "../../../utils/shortcuts"; + +type UseInterruptShortcutOptions = { + isEnabled: boolean; + shortcut: string | null; + onTrigger: () => void | Promise; +}; + +export function useInterruptShortcut({ + isEnabled, + shortcut, + onTrigger, +}: UseInterruptShortcutOptions) { + useEffect(() => { + if (!isEnabled || !shortcut) { + return; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.repeat || event.defaultPrevented) { + return; + } + const target = event.target; + if ( + target instanceof HTMLElement && + (target.isContentEditable || + target.closest("input, textarea, select, [contenteditable='true']")) + ) { + return; + } + if (!matchesShortcut(event, shortcut)) { + return; + } + event.preventDefault(); + void onTrigger(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isEnabled, onTrigger, shortcut]); +} diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 129a17b15..0be55fa9f 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -28,6 +28,7 @@ const baseSettings: AppSettings = { composerAccessShortcut: null, composerReasoningShortcut: null, composerCollaborationShortcut: null, + interruptShortcut: null, newAgentShortcut: null, newWorktreeAgentShortcut: null, newCloneAgentShortcut: null, diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index b720f4990..b2cfe9cce 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -24,7 +24,11 @@ import type { WorkspaceInfo, } from "../../../types"; import { formatDownloadSize } from "../../../utils/formatting"; -import { buildShortcutValue, formatShortcut } from "../../../utils/shortcuts"; +import { + buildShortcutValue, + formatShortcut, + getDefaultInterruptShortcut, +} from "../../../utils/shortcuts"; import { clampUiScale } from "../../../utils/uiScale"; import { getCodexConfigPath } from "../../../services/tauri"; import { @@ -174,6 +178,7 @@ type ShortcutSettingKey = | "composerAccessShortcut" | "composerReasoningShortcut" | "composerCollaborationShortcut" + | "interruptShortcut" | "newAgentShortcut" | "newWorktreeAgentShortcut" | "newCloneAgentShortcut" @@ -190,6 +195,7 @@ type ShortcutDraftKey = | "access" | "reasoning" | "collaboration" + | "interrupt" | "newAgent" | "newWorktreeAgent" | "newCloneAgent" @@ -209,6 +215,7 @@ const shortcutDraftKeyBySetting: Record = composerAccessShortcut: "access", composerReasoningShortcut: "reasoning", composerCollaborationShortcut: "collaboration", + interruptShortcut: "interrupt", newAgentShortcut: "newAgent", newWorktreeAgentShortcut: "newWorktreeAgent", newCloneAgentShortcut: "newCloneAgent", @@ -304,6 +311,7 @@ export function SettingsView({ access: appSettings.composerAccessShortcut ?? "", reasoning: appSettings.composerReasoningShortcut ?? "", collaboration: appSettings.composerCollaborationShortcut ?? "", + interrupt: appSettings.interruptShortcut ?? "", newAgent: appSettings.newAgentShortcut ?? "", newWorktreeAgent: appSettings.newWorktreeAgentShortcut ?? "", newCloneAgent: appSettings.newCloneAgentShortcut ?? "", @@ -405,6 +413,7 @@ export function SettingsView({ access: appSettings.composerAccessShortcut ?? "", reasoning: appSettings.composerReasoningShortcut ?? "", collaboration: appSettings.composerCollaborationShortcut ?? "", + interrupt: appSettings.interruptShortcut ?? "", newAgent: appSettings.newAgentShortcut ?? "", newWorktreeAgent: appSettings.newWorktreeAgentShortcut ?? "", newCloneAgent: appSettings.newCloneAgentShortcut ?? "", @@ -422,6 +431,7 @@ export function SettingsView({ appSettings.composerModelShortcut, appSettings.composerReasoningShortcut, appSettings.composerCollaborationShortcut, + appSettings.interruptShortcut, appSettings.newAgentShortcut, appSettings.newWorktreeAgentShortcut, appSettings.newCloneAgentShortcut, @@ -2022,6 +2032,30 @@ export function SettingsView({ Default: {formatShortcut("shift+tab")} +
+
Stop active run
+
+ + handleShortcutKeyDown(event, "interruptShortcut") + } + placeholder="Type shortcut" + readOnly + /> + +
+
+ Default: {formatShortcut(getDefaultInterruptShortcut())} +
+
Panels
diff --git a/src/features/settings/hooks/useAppSettings.test.ts b/src/features/settings/hooks/useAppSettings.test.ts index c84284b5e..04e8a684d 100644 --- a/src/features/settings/hooks/useAppSettings.test.ts +++ b/src/features/settings/hooks/useAppSettings.test.ts @@ -68,6 +68,7 @@ describe("useAppSettings", () => { expect(result.current.settings.codeFontFamily).toContain("SF Mono"); expect(result.current.settings.backendMode).toBe("local"); expect(result.current.settings.dictationModelId).toBe("base"); + expect(result.current.settings.interruptShortcut).toBeTruthy(); }); it("persists settings via updateAppSettings and updates local state", async () => { diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index 4d6296426..d41e9e550 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -15,6 +15,7 @@ import { OPEN_APP_STORAGE_KEY, } from "../../app/constants"; import { normalizeOpenAppTargets } from "../../app/utils/openApp"; +import { getDefaultInterruptShortcut } from "../../../utils/shortcuts"; const allowedThemes = new Set(["system", "light", "dark"]); @@ -29,6 +30,7 @@ const defaultSettings: AppSettings = { composerAccessShortcut: "cmd+shift+a", composerReasoningShortcut: "cmd+shift+r", composerCollaborationShortcut: "shift+tab", + interruptShortcut: getDefaultInterruptShortcut(), newAgentShortcut: "cmd+n", newWorktreeAgentShortcut: "cmd+shift+n", newCloneAgentShortcut: "cmd+alt+n", diff --git a/src/types.ts b/src/types.ts index 062de07e3..ddd90638c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -117,6 +117,7 @@ export type AppSettings = { composerAccessShortcut: string | null; composerReasoningShortcut: string | null; composerCollaborationShortcut: string | null; + interruptShortcut: string | null; newAgentShortcut: string | null; newWorktreeAgentShortcut: string | null; newCloneAgentShortcut: string | null; diff --git a/src/utils/shortcuts.ts b/src/utils/shortcuts.ts index cbf420a54..0d6eb1cf6 100644 --- a/src/utils/shortcuts.ts +++ b/src/utils/shortcuts.ts @@ -149,6 +149,17 @@ export function matchesShortcut(event: KeyboardEvent, value: string | null | und ); } +export function isMacPlatform(): boolean { + if (typeof navigator === "undefined") { + return false; + } + return /Mac|iPhone|iPad|iPod/.test(navigator.platform); +} + +export function getDefaultInterruptShortcut(): string { + return isMacPlatform() ? "ctrl+c" : "ctrl+shift+c"; +} + export function toMenuAccelerator(value: string | null | undefined): string | null { const parsed = parseShortcut(value); if (!parsed) {