From 6fe320055dacc916acf2514924b6f3ba2d6b42ec Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 24 Jun 2026 14:41:14 +0700 Subject: [PATCH] feat(app): cycle agent modes with Shift+Tab --- .../composer/agent-controls/mode-control.tsx | 81 ++++++-- .../src/composer/agent-controls/mode.test.ts | 40 +++- .../app/src/composer/agent-controls/mode.ts | 16 ++ packages/app/src/composer/index.tsx | 174 +++++++++--------- packages/app/src/composer/keyboard-scope.tsx | 31 ++++ packages/app/src/i18n/resources.test.ts | 2 + packages/app/src/i18n/resources/ar.ts | 3 +- packages/app/src/i18n/resources/en.ts | 3 +- packages/app/src/i18n/resources/es.ts | 3 +- packages/app/src/i18n/resources/fr.ts | 3 +- packages/app/src/i18n/resources/ru.ts | 3 +- packages/app/src/i18n/resources/zh-CN.ts | 3 +- packages/app/src/keyboard/actions.ts | 3 +- .../keyboard/keyboard-action-dispatcher.ts | 2 + .../src/keyboard/keyboard-shortcuts.test.ts | 20 ++ .../app/src/keyboard/keyboard-shortcuts.ts | 15 ++ .../app/src/keyboard/route-shortcut.test.ts | 1 + packages/app/src/keyboard/route-shortcut.ts | 1 + .../app/src/utils/format-shortcut.test.ts | 6 + packages/app/src/utils/format-shortcut.ts | 8 +- 20 files changed, 310 insertions(+), 108 deletions(-) create mode 100644 packages/app/src/composer/keyboard-scope.tsx diff --git a/packages/app/src/composer/agent-controls/mode-control.tsx b/packages/app/src/composer/agent-controls/mode-control.tsx index 9d32ee748..6329cd46e 100644 --- a/packages/app/src/composer/agent-controls/mode-control.tsx +++ b/packages/app/src/composer/agent-controls/mode-control.tsx @@ -16,6 +16,8 @@ import { Bot, ShieldAlert, ShieldCheck, ShieldOff, ShieldQuestionMark } from "lu import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Shortcut } from "@/components/ui/shortcut"; import { useSessionStore } from "@/stores/session-store"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences"; @@ -24,7 +26,12 @@ import { useToast } from "@/contexts/toast-context"; import { useIsCompactFormFactor } from "@/constants/layout"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; -import { formatAgentModeLabel } from "@/composer/agent-controls/utils"; +import { formatAgentModeLabel, getAgentControlHintKey } from "@/composer/agent-controls/utils"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; +import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; +import { resolveNextAgentModeId } from "@/composer/agent-controls/mode"; +import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import type { AgentMode, AgentProvider } from "@getpaseo/protocol/agent-types"; import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; @@ -106,7 +113,10 @@ function AgentModeControlView({ }: AgentModeControlViewProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); + const { isActiveComposer } = useComposerKeyboardScope(); + const cycleShortcutKeys = useShortcutKeys("cycle-agent-mode"); const anchorRef = useRef(null); + const keyboardHandlerIdRef = useRef(`mode-control:${Math.random().toString(36).slice(2)}`); const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -146,6 +156,26 @@ function AgentModeControlView({ [onSelectMode, handleOpenChange], ); + const handleKeyboardAction = useCallback( + (action: KeyboardActionDefinition): boolean => { + if (action.id !== "message-input.mode-cycle") return false; + if (disabled || !isActiveComposer) return false; + const nextModeId = resolveNextAgentModeId({ modeOptions, selectedMode: selectedModeId }); + if (!nextModeId) return false; + onSelectMode(nextModeId); + return true; + }, + [disabled, isActiveComposer, modeOptions, onSelectMode, selectedModeId], + ); + + useKeyboardActionHandler({ + handlerId: keyboardHandlerIdRef.current, + actions: ["message-input.mode-cycle"], + enabled: isActiveComposer && !disabled && modeOptions.length > 1, + priority: 200, + handle: handleKeyboardAction, + }); + const renderOption = useCallback( (args: { option: ComboboxOption; @@ -194,21 +224,31 @@ function AgentModeControlView({ return ( <> - - {Icon ? : null} - {selectedModeLabel} - + + + + {Icon ? : null} + {selectedModeLabel} + + + + + {t(getAgentControlHintKey("mode"))} + {isActiveComposer && cycleShortcutKeys ? : null} + + + ({ fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, + tooltipRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + tooltipText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.xs, + }, })); diff --git a/packages/app/src/composer/agent-controls/mode.test.ts b/packages/app/src/composer/agent-controls/mode.test.ts index 498753ad5..89c22787b 100644 --- a/packages/app/src/composer/agent-controls/mode.test.ts +++ b/packages/app/src/composer/agent-controls/mode.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from "vitest"; -import { resolveAgentControlsMode } from "./mode"; +import type { AgentMode } from "@getpaseo/protocol/agent-types"; +import { resolveAgentControlsMode, resolveNextAgentModeId } from "./mode"; + +const PLAN_MODE = { id: "plan", label: "Plan" } satisfies AgentMode; + +const MODES = [ + PLAN_MODE, + { id: "build", label: "Build" }, + { id: "full-access", label: "Full Access" }, +] satisfies AgentMode[]; describe("resolveAgentControlsMode", () => { it("uses ready mode when no controlled agent controls are provided", () => { @@ -29,3 +38,32 @@ describe("resolveAgentControlsMode", () => { ).toBe("draft"); }); }); + +describe("resolveNextAgentModeId", () => { + it("cycles from the selected mode to the next mode", () => { + expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "build" })).toBe( + "full-access", + ); + }); + + it("wraps from the last mode to the first mode", () => { + expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "full-access" })).toBe( + "plan", + ); + }); + + it("treats an empty selection as the visible first mode", () => { + expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "" })).toBe("build"); + }); + + it("treats a stale selection as the visible first mode", () => { + expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "deleted-mode" })).toBe( + "build", + ); + }); + + it("returns null when there are fewer than two modes", () => { + expect(resolveNextAgentModeId({ modeOptions: [], selectedMode: "" })).toBeNull(); + expect(resolveNextAgentModeId({ modeOptions: [PLAN_MODE], selectedMode: "plan" })).toBeNull(); + }); +}); diff --git a/packages/app/src/composer/agent-controls/mode.ts b/packages/app/src/composer/agent-controls/mode.ts index 3cc81e5b7..e04b94501 100644 --- a/packages/app/src/composer/agent-controls/mode.ts +++ b/packages/app/src/composer/agent-controls/mode.ts @@ -1,4 +1,20 @@ import type { DraftAgentControlsProps } from "@/composer/agent-controls"; +import type { AgentMode } from "@getpaseo/protocol/agent-types"; + +export function resolveNextAgentModeId({ + modeOptions, + selectedMode, +}: { + modeOptions: readonly AgentMode[]; + selectedMode: string | null | undefined; +}): string | null { + if (modeOptions.length < 2) return null; + + const selectedIndex = modeOptions.findIndex((mode) => mode.id === selectedMode); + const currentIndex = selectedIndex >= 0 ? selectedIndex : 0; + const nextIndex = (currentIndex + 1) % modeOptions.length; + return modeOptions[nextIndex]?.id ?? null; +} export function resolveAgentControlsMode(agentControls?: DraftAgentControlsProps) { return agentControls ? "draft" : "ready"; diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 72d5359bd..0f839c1ad 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -89,6 +89,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { submitAgentInput } from "@/composer/submit"; +import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope"; import { useAppSettings } from "@/hooks/use-settings"; import { isWeb, isNative } from "@/constants/platform"; import type { GitHubSearchItem } from "@getpaseo/protocol/messages"; @@ -1746,8 +1747,15 @@ export function Composer({ ); const leftContent = useMemo( - () => renderLeftContent({ agentControls, agentId, serverId, focusInput, isCompactLayout }), - [agentId, focusInput, serverId, agentControls, isCompactLayout], + () => + renderLeftContent({ + agentControls, + agentId, + serverId, + focusInput, + isCompactLayout, + }), + [agentControls, agentId, focusInput, isCompactLayout, serverId], ); const handleAttachButtonRef = useCallback((node: View | null) => { @@ -1860,90 +1868,92 @@ export function Composer({ const autocompleteVisible = autocomplete.isVisible && isPaneFocused; return ( - - - {/* Input area */} - - - {queueList} - {sendErrorNode} + + + + {/* Input area */} + + + {queueList} + {sendErrorNode} - - + + - {/* MessageInput handles everything: text, dictation, attachments, all buttons */} - - + {/* MessageInput handles everything: text, dictation, attachments, all buttons */} + + + - - {renderComposerFooter(footer, footerInlineContent)} - + {renderComposerFooter(footer, footerInlineContent)} + + ); } diff --git a/packages/app/src/composer/keyboard-scope.tsx b/packages/app/src/composer/keyboard-scope.tsx new file mode 100644 index 000000000..b254c53a5 --- /dev/null +++ b/packages/app/src/composer/keyboard-scope.tsx @@ -0,0 +1,31 @@ +import { + createContext, + useContext, + useMemo, + type PropsWithChildren, + type ReactElement, +} from "react"; + +interface ComposerKeyboardScopeValue { + isActiveComposer: boolean; +} + +const ComposerKeyboardScopeContext = createContext({ + isActiveComposer: false, +}); + +export function ComposerKeyboardScopeProvider({ + isActiveComposer, + children, +}: PropsWithChildren): ReactElement { + const value = useMemo(() => ({ isActiveComposer }), [isActiveComposer]); + return ( + + {children} + + ); +} + +export function useComposerKeyboardScope(): ComposerKeyboardScopeValue { + return useContext(ComposerKeyboardScopeContext); +} diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index aa935da05..21ca0cad6 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -183,6 +183,7 @@ describe("translation resources", () => { expect(en.composer.github.title).toBe("Attach issue or PR"); expect(en.agentControls.provider.fallback).toBe("Provider"); expect(en.agentControls.hints.model).toBe("Change model"); + expect(en.agentControls.hints.mode).toBe("Change mode"); expect(en.agentControls.features.title).toBe("Features"); expect(en.agentControls.mode.title).toBe("Mode"); expect(en.agentStream.permission.required).toBe("Permission Required"); @@ -289,6 +290,7 @@ describe("translation resources", () => { expect(en.settings.shortcuts.sections.tabsPanes).toBe("Tabs & Panes"); expect(en.settings.shortcuts.help.toggleCommandCenter).toBe("Toggle command center"); expect(en.settings.shortcuts.help.newWorkspace).toBe("New workspace"); + expect(en.settings.shortcuts.help.cycleAgentMode).toBe("Cycle agent mode"); expect(en.settings.shortcuts.helpNotes.showKeyboardShortcuts).toBe( "Available when focus is not in a text field or terminal.", ); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 9339ed33e..604f69708 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -165,7 +165,7 @@ export const ar: TranslationResources = { hints: { thinking: "وضع التفكير", model: "تغيير النموذج", - mode: "تغيير وضع الإذن", + mode: "تغيير الوضع", }, }, agentStream: { @@ -1567,6 +1567,7 @@ export const ar: TranslationResources = { toggleFocusMode: "تبديل وضع التركيز", cycleTheme: "موضوع الدورة", focusMessageInput: "التركيز على إدخال الرسالة", + cycleAgentMode: "تبديل وضع الوكيل", toggleVoiceMode: "تبديل الوضع الصوتي", startStopDictation: "بدء إملاء /stop", interruptAgent: "عامل المقاطعة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index efca11b2b..4b1dd6508 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -163,7 +163,7 @@ export const en = { hints: { thinking: "Thinking mode", model: "Change model", - mode: "Change permission mode", + mode: "Change mode", }, }, agentStream: { @@ -1573,6 +1573,7 @@ export const en = { toggleFocusMode: "Toggle focus mode", cycleTheme: "Cycle theme", focusMessageInput: "Focus message input", + cycleAgentMode: "Cycle agent mode", toggleVoiceMode: "Toggle voice mode", startStopDictation: "Start/stop dictation", interruptAgent: "Interrupt agent", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 95d572554..f100813de 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -165,7 +165,7 @@ export const es: TranslationResources = { hints: { thinking: "Modo de pensamiento", model: "Cambiar modelo", - mode: "Cambiar modo de permiso", + mode: "Cambiar modo", }, }, agentStream: { @@ -1605,6 +1605,7 @@ export const es: TranslationResources = { toggleFocusMode: "Alternar modo de enfoque", cycleTheme: "Tema del ciclo", focusMessageInput: "Entrada de mensaje de enfoque", + cycleAgentMode: "Alternar modo del agente", toggleVoiceMode: "Alternar modo de voz", startStopDictation: "Iniciar dictado/stop", interruptAgent: "agente de interrupción", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 520b7c18b..74676b573 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -166,7 +166,7 @@ export const fr: TranslationResources = { hints: { thinking: "Mode réflexion", model: "Changer de modèle", - mode: "Changer le mode d'autorisation", + mode: "Changer de mode", }, }, agentStream: { @@ -1609,6 +1609,7 @@ export const fr: TranslationResources = { toggleFocusMode: "Basculer le mode de mise au point", cycleTheme: "Thème du cycle", focusMessageInput: "Saisie du message de focus", + cycleAgentMode: "Parcourir les modes de l'agent", toggleVoiceMode: "Changer le mode vocal", startStopDictation: "Démarrer la dictée/stop", interruptAgent: "Agent d'interruption", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 872e7de65..5d7943970 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -165,7 +165,7 @@ export const ru: TranslationResources = { hints: { thinking: "Режим мышления", model: "Изменить модель", - mode: "Изменить режим разрешений", + mode: "Изменить режим", }, }, agentStream: { @@ -1597,6 +1597,7 @@ export const ru: TranslationResources = { toggleFocusMode: "Переключить режим фокусировки", cycleTheme: "Циклическая тема", focusMessageInput: "Фокус ввода сообщения", + cycleAgentMode: "Переключить режим агента", toggleVoiceMode: "Переключить голосовой режим", startStopDictation: "Начать диктовку /stop", interruptAgent: "Агент прерываний", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 1958a2515..6f3716a2d 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -165,7 +165,7 @@ export const zhCN: TranslationResources = { hints: { thinking: "Thinking mode", model: "切换 Model", - mode: "切换权限 Mode", + mode: "更改模式", }, }, agentStream: { @@ -1548,6 +1548,7 @@ export const zhCN: TranslationResources = { toggleFocusMode: "切换专注模式", cycleTheme: "循环切换主题", focusMessageInput: "聚焦消息输入框", + cycleAgentMode: "循环切换代理模式", toggleVoiceMode: "切换语音模式", startStopDictation: "开始/停止听写", interruptAgent: "中断 Agent", diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index f17e24797..7b9afd11c 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -12,7 +12,8 @@ export type MessageInputKeyboardActionKind = | "dictation-cancel" | "dictation-confirm" | "voice-toggle" - | "voice-mute-toggle"; + | "voice-mute-toggle" + | "mode-cycle"; export type KeyboardActionId = | "agent.interrupt" diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index 4ae487d99..021285536 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -9,6 +9,7 @@ export type KeyboardActionId = | "message-input.dictation-confirm" | "message-input.voice-toggle" | "message-input.voice-mute-toggle" + | "message-input.mode-cycle" | "workspace.tab.new" | "workspace.tab.close-current" | "workspace.tab.navigate-index" @@ -39,6 +40,7 @@ export type KeyboardActionDefinition = | { id: "message-input.dictation-confirm"; scope: KeyboardActionScope } | { id: "message-input.voice-toggle"; scope: KeyboardActionScope } | { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope } + | { id: "message-input.mode-cycle"; scope: KeyboardActionScope } | { id: "workspace.tab.new"; scope: KeyboardActionScope } | { id: "workspace.tab.close-current"; scope: KeyboardActionScope } | { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number } diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index bec812563..9da92dab5 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -299,6 +299,13 @@ describe("keyboard-shortcuts", () => { action: "message-input.action", payload: { kind: "dictation-toggle" }, }, + { + name: "routes Shift+Tab to cycle agent mode from the message input", + event: { key: "Tab", code: "Tab", shiftKey: true }, + context: { focusScope: "message-input" }, + action: "message-input.action", + payload: { kind: "mode-cycle" }, + }, { name: "routes space to voice mute toggle outside editable scopes", event: { key: " ", code: "Space" }, @@ -427,6 +434,16 @@ describe("keyboard-shortcuts", () => { event: { key: "d", code: "KeyD", metaKey: true }, context: { isMac: true, focusScope: "terminal" }, }, + { + name: "does not cycle agent mode outside the message input", + event: { key: "Tab", code: "Tab", shiftKey: true }, + context: { focusScope: "other" }, + }, + { + name: "does not repeat agent mode cycling while Shift+Tab is held", + event: { key: "Tab", code: "Tab", shiftKey: true, repeat: true }, + context: { focusScope: "message-input" }, + }, { name: "does not bind Cmd+Enter as a rebindable message queue shortcut", event: { key: "Enter", code: "Enter", metaKey: true }, @@ -570,6 +587,7 @@ describe("keyboard-shortcut help sections", () => { "workspace-tab-close-current": ["alt", "shift", "W"], "workspace-pane-split-right": ["mod", "\\"], "workspace-pane-close": ["mod", "shift", "W"], + "cycle-agent-mode": ["shift", "Tab"], }, }, { @@ -617,12 +635,14 @@ describe("keyboard-shortcut help sections", () => { const projects = sections.find((section) => section.id === "projects"); const panels = sections.find((section) => section.id === "panels"); const openProject = findRow(sections, "new-agent"); + const cycleAgentMode = findRow(sections, "cycle-agent-mode"); const showShortcuts = findRow(sections, "show-shortcuts"); expect(projects?.titleKey).toBe("settings.shortcuts.sections.projects"); expect(panels?.titleKey).toBe("settings.shortcuts.sections.panels"); expect(openProject?.labelKey).toBe("settings.shortcuts.help.openProject"); expect(openProject?.label).toBe("Open project"); + expect(cycleAgentMode?.labelKey).toBe("settings.shortcuts.help.cycleAgentMode"); expect(showShortcuts?.noteKey).toBe("settings.shortcuts.helpNotes.showKeyboardShortcuts"); }); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 40358977d..d7b10f7fc 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -152,6 +152,7 @@ const SHORTCUT_HELP_LABEL_KEYS: Record = { "toggle-focus": "settings.shortcuts.help.toggleFocusMode", "cycle-theme": "settings.shortcuts.help.cycleTheme", "focus-message-input": "settings.shortcuts.help.focusMessageInput", + "cycle-agent-mode": "settings.shortcuts.help.cycleAgentMode", "voice-toggle": "settings.shortcuts.help.toggleVoiceMode", "dictation-toggle": "settings.shortcuts.help.startStopDictation", "agent-interrupt": "settings.shortcuts.help.interruptAgent", @@ -910,6 +911,20 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ keys: ["mod", "L"], }, }, + { + id: "message-input-mode-cycle-shift-tab", + action: "message-input.action", + combo: "Shift+Tab", + repeat: false, + when: { commandCenter: false, focusScope: "message-input" }, + payload: { type: "message-input", kind: "mode-cycle" }, + help: { + id: "cycle-agent-mode", + section: "agent-input", + label: "Cycle agent mode", + keys: ["shift", "Tab"], + }, + }, { id: "message-input-voice-toggle-cmd-shift-d-mac", action: "message-input.action", diff --git a/packages/app/src/keyboard/route-shortcut.test.ts b/packages/app/src/keyboard/route-shortcut.test.ts index ea569c264..0d6809477 100644 --- a/packages/app/src/keyboard/route-shortcut.test.ts +++ b/packages/app/src/keyboard/route-shortcut.test.ts @@ -276,6 +276,7 @@ describe("routeKeyboardShortcut — message-input.action", () => { ["dictation-confirm", "message-input.dictation-confirm"], ["voice-toggle", "message-input.voice-toggle"], ["voice-mute-toggle", "message-input.voice-mute-toggle"], + ["mode-cycle", "message-input.mode-cycle"], ] as const)("kind=%s → dispatch %s", (kind, id) => { expect( routeKeyboardShortcut({ action: "message-input.action", payload: { kind } }, makeCtx()), diff --git a/packages/app/src/keyboard/route-shortcut.ts b/packages/app/src/keyboard/route-shortcut.ts index 03873a45b..82923f760 100644 --- a/packages/app/src/keyboard/route-shortcut.ts +++ b/packages/app/src/keyboard/route-shortcut.ts @@ -82,6 +82,7 @@ const MESSAGE_INPUT_DISPATCH: Record< "dictation-confirm": { id: "message-input.dictation-confirm", scope: "message-input" }, "voice-toggle": { id: "message-input.voice-toggle", scope: "message-input" }, "voice-mute-toggle": { id: "message-input.voice-mute-toggle", scope: "message-input" }, + "mode-cycle": { id: "message-input.mode-cycle", scope: "message-input" }, }; function hasPayloadKey( diff --git a/packages/app/src/utils/format-shortcut.test.ts b/packages/app/src/utils/format-shortcut.test.ts index f5b6b2716..29b5a32e4 100644 --- a/packages/app/src/utils/format-shortcut.test.ts +++ b/packages/app/src/utils/format-shortcut.test.ts @@ -8,6 +8,12 @@ describe("formatShortcut", () => { expect(formatShortcut(["mod", "E"], "mac")).toBe("⌘E"); }); + it("spells out Shift in shortcut labels", () => { + expect(formatShortcut(["shift", "Tab"], "mac")).toBe("Shift+Tab"); + expect(formatShortcut(["mod", "shift", "P"], "mac")).toBe("Shift+⌘+P"); + expect(formatShortcut(["shift", "Tab"], "non-mac")).toBe("Shift+Tab"); + }); + it("uses Ctrl+ on non-mac platforms", () => { expect(formatShortcut(["mod", "B"], "non-mac")).toBe("Ctrl+B"); expect(formatShortcut(["mod", "E"], "non-mac")).toBe("Ctrl+E"); diff --git a/packages/app/src/utils/format-shortcut.ts b/packages/app/src/utils/format-shortcut.ts index 00fd7ca40..4d79546e5 100644 --- a/packages/app/src/utils/format-shortcut.ts +++ b/packages/app/src/utils/format-shortcut.ts @@ -27,18 +27,22 @@ export function formatShortcut(keys: ShortcutKey[], os: ShortcutOs): string { const order = ["ctrl", "alt", "shift", "mod", "meta"]; const symbols: Record = { mod: "⌘", - shift: "⇧", alt: "⌥", ctrl: "⌃", meta: "⌘", }; const modifierSet = new Set(normalized); - const mods = order.filter((k) => modifierSet.has(k)).map((k) => symbols[k] ?? ""); + const mods = order + .filter((k) => modifierSet.has(k)) + .map((k) => (k === "shift" ? "Shift" : (symbols[k] ?? ""))); const main = normalized .filter((k) => !order.includes(k)) .map(normalizeKey) .join(""); + if (mods.includes("Shift")) { + return [...mods, main].filter(Boolean).join("+"); + } return `${mods.join("")}${main}`; }