From 7ead9d14a4f7b31dd710e4b84682b6c781e9f813 Mon Sep 17 00:00:00 2001 From: Igor Warzocha Date: Thu, 18 Jun 2026 09:48:54 +0100 Subject: [PATCH] Fix right Alt extension shortcuts --- docs/changelog.md | 1 + shared/keybindings.ts | 23 ++++++ src/app/app-shell/useAppKeybindings.ts | 26 ++++++ src/app/composer/composer-prompt-surface.tsx | 79 ++++++++++--------- src/app/composer/composer-text-keydown.ts | 57 ++++++++++--- src/app/composer/pi-extension-shortcuts.ts | 53 +++++++++++++ .../settingsDescriptorKeybindings.tsx | 23 +++++- src/test/keybindings.test.ts | 44 +++++++++++ src/test/pi-extension-shortcuts.test.ts | 55 +++++++++++++ 9 files changed, 306 insertions(+), 55 deletions(-) create mode 100644 src/app/composer/pi-extension-shortcuts.ts create mode 100644 src/test/keybindings.test.ts create mode 100644 src/test/pi-extension-shortcuts.test.ts diff --git a/docs/changelog.md b/docs/changelog.md index c28d584cb..d5377b4ce 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,7 @@ - Added native Smart BTW extension, and subsequently removed it, because... - Howcode now uses new Pi SDK to render dialogs, widgets, statuslines and notifications. - Extensions pass through shortcuts. +- Right Alt no longer triggers app or extension shortcuts. - Fancy react-rendered extensions not supported yet. Only normal-ish widgets. - Implemented /tree functionality with labelling and summarisation. - Split Pi TUI takeover and the terminal drawer properly. diff --git a/shared/keybindings.ts b/shared/keybindings.ts index fbbcb50cd..5b7c1aeca 100644 --- a/shared/keybindings.ts +++ b/shared/keybindings.ts @@ -211,11 +211,34 @@ export type KeybindingKeyboardEventLike = { altKey: boolean code: string ctrlKey: boolean + getModifierState?: ((keyArg: 'AltGraph') => boolean) | undefined key: string + location?: number | undefined metaKey: boolean shiftKey: boolean } +const rightKeyboardLocation = 2 + +export function isRightAltKeyEvent( + event: Pick, +) { + return ( + event.code === 'AltRight' || + event.key === 'AltGraph' || + (event.key === 'Alt' && event.location === rightKeyboardLocation) + ) +} + +export function isRightAltShortcutEvent( + event: Pick, + rightAltPressed: boolean, +) { + return ( + rightAltPressed || event.getModifierState?.('AltGraph') === true || isRightAltKeyEvent(event) + ) +} + export function getKeybindingEventKey(event: Pick) { if (keyboardCodeLetterPattern.test(event.code)) return event.code.slice(3) if (keyboardCodeDigitPattern.test(event.code)) return event.code.slice(5) diff --git a/src/app/app-shell/useAppKeybindings.ts b/src/app/app-shell/useAppKeybindings.ts index b02b4c91a..9b382c987 100644 --- a/src/app/app-shell/useAppKeybindings.ts +++ b/src/app/app-shell/useAppKeybindings.ts @@ -1,6 +1,8 @@ import { eventToAcceleratorCandidates, getEffectiveAccelerators, + isRightAltKeyEvent, + isRightAltShortcutEvent, type KeybindingCommandId, type KeybindingOverrides, } from '@howcode/shared/keybindings' @@ -62,6 +64,18 @@ function handleShortcut(event: KeyboardEvent, runtime: KeybindingRuntime) { event.stopImmediatePropagation() } +function shouldSkipShortcutForRightAlt( + event: KeyboardEvent, + rightAltPressedRef: React.MutableRefObject, +) { + if (isRightAltKeyEvent(event)) { + rightAltPressedRef.current = true + return true + } + if (!event.altKey) rightAltPressedRef.current = false + return isRightAltShortcutEvent(event, rightAltPressedRef.current) +} + export function useAppKeybindings(input: { controller: AppShellController keybindings: KeybindingOverrides @@ -88,6 +102,7 @@ export function useAppKeybindings(input: { return map }, [keybindings]) const lastEscapeAtRef = useRef(0) + const rightAltPressedRef = useRef(false) const cycleSelectionRef = useRef(null) const latest = useLatest({ acceleratorToCommand, @@ -118,9 +133,16 @@ export function useAppKeybindings(input: { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return + if (shouldSkipShortcutForRightAlt(event, rightAltPressedRef)) return if (event.key === 'Escape') handleEscape(event, latest.current, lastEscapeAtRef) else handleShortcut(event, latest.current) } + const handleKeyUp = (event: KeyboardEvent) => { + if (isRightAltKeyEvent(event)) rightAltPressedRef.current = false + } + const resetRightAltPressed = () => { + rightAltPressedRef.current = false + } const handleCommand = (event: Event) => { const commandId = (event as CustomEvent).detail?.commandId if (!commandId || rendererCommandIds.has(commandId)) return @@ -129,9 +151,13 @@ export function useAppKeybindings(input: { } window.addEventListener('keydown', handleKeyDown, { capture: true }) + window.addEventListener('keyup', handleKeyUp, { capture: true }) + window.addEventListener('blur', resetRightAltPressed) window.addEventListener(howcodeKeybindingCommandEvent, handleCommand) return () => { window.removeEventListener('keydown', handleKeyDown, { capture: true }) + window.removeEventListener('keyup', handleKeyUp, { capture: true }) + window.removeEventListener('blur', resetRightAltPressed) window.removeEventListener(howcodeKeybindingCommandEvent, handleCommand) } }, [latest]) diff --git a/src/app/composer/composer-prompt-surface.tsx b/src/app/composer/composer-prompt-surface.tsx index e4bb11a88..4238abb87 100644 --- a/src/app/composer/composer-prompt-surface.tsx +++ b/src/app/composer/composer-prompt-surface.tsx @@ -39,6 +39,13 @@ import { } from './composer-prompt-surface-helpers' import { ComposerAttachmentRail, ComposerStopRail } from './composer-side-controls' import { useComposerController } from './controller/useComposerController' +import { + getPiExtensionShortcutKey, + isEditableEventTarget, + isPlainPiExtensionShortcut, + isRightAltKeyEvent, + isRightAltPiExtensionShortcutEvent, +} from './pi-extension-shortcuts' import { useComposerFileMentions } from './useComposerFileMentions' import { useComposerAutocompleteEffects, @@ -497,30 +504,28 @@ function getPiExtensionBgClass(name: string | undefined) { } } -function getPiExtensionShortcutKey(event: KeyboardEvent) { - if (event.isComposing) return null - const key = getPiExtensionShortcutBaseKey(event) - if (!key) return null - const modifiers = [ - event.ctrlKey ? 'ctrl' : null, - event.altKey ? 'alt' : null, - event.shiftKey ? 'shift' : null, - event.metaKey ? 'meta' : null, - ].filter(Boolean) - return [...modifiers, key].join('+') -} - -function isPlainPiExtensionShortcut(shortcut: string) { - return !shortcut.includes('+') +function isPiExtensionOverlayHovered(overlay: HTMLElement | null) { + return Boolean(overlay?.matches(':hover')) } -function isEditableEventTarget(target: EventTarget | null) { - if (!(target instanceof HTMLElement)) return false - return Boolean(target.closest('input, textarea, select, [contenteditable="true"]')) +function shouldSkipPiExtensionShortcutForRightAlt( + event: KeyboardEvent, + rightAltPressedRef: React.MutableRefObject, +) { + if (isRightAltKeyEvent(event)) { + rightAltPressedRef.current = true + return true + } + return isRightAltPiExtensionShortcutEvent(event, rightAltPressedRef.current) } -function isPiExtensionOverlayHovered(overlay: HTMLElement | null) { - return Boolean(overlay?.matches(':hover')) +function canRunPiExtensionShortcut(input: { + overlayHovered: boolean + shortcut: string + target: EventTarget | null +}) { + if (!isPlainPiExtensionShortcut(input.shortcut)) return true + return input.overlayHovered && !isEditableEventTarget(input.target) } function hasComposerOverlayAbove(...visibleFlags: boolean[]) { @@ -550,20 +555,6 @@ function applyPiExtensionEditorResult(input: { }) } -function getPiExtensionShortcutBaseKey(event: KeyboardEvent) { - if (event.code.startsWith('Key')) return event.code.slice(3).toLowerCase() - if (event.code.startsWith('Digit')) return event.code.slice(5) - if (event.code === 'ArrowLeft') return 'left' - if (event.code === 'ArrowRight') return 'right' - if (event.code === 'ArrowUp') return 'up' - if (event.code === 'ArrowDown') return 'down' - if (event.code === 'Escape') return 'escape' - if (event.code === 'Enter') return 'enter' - if (event.code === 'Space') return 'space' - if (event.key.length === 1) return event.key.toLowerCase() - return event.key.toLowerCase() || null -} - export function ComposerPromptSurface({ activeView, composerPanelRef, @@ -697,6 +688,7 @@ export function ComposerPromptSurface({ const stopButtonBoundaryRef = useRef(null) const composerOverlayStackRef = useRef(null) const piExtensionStatusLineRef = useRef(null) + const rightAltPressedRef = useRef(false) const showNativeDialog = piExtensionDialogRequest !== null const showProjectTrust = projectTrustRequest !== null const visiblePiExtensionWidgets = piExtensionWidgets.filter( @@ -897,12 +889,11 @@ export function ComposerPromptSurface({ piExtensionShortcuts.map((shortcut) => shortcut.shortcut.toLowerCase()), ) const handlePiExtensionShortcut = (event: KeyboardEvent) => { + if (shouldSkipPiExtensionShortcutForRightAlt(event, rightAltPressedRef)) return const shortcut = getPiExtensionShortcutKey(event) if (!(shortcut && registeredShortcuts.has(shortcut))) return const overlayHovered = isPiExtensionOverlayHovered(composerOverlayStackRef.current) - const plainShortcut = isPlainPiExtensionShortcut(shortcut) - if (plainShortcut && !overlayHovered) return - if (plainShortcut && isEditableEventTarget(event.target)) return + if (!canRunPiExtensionShortcut({ overlayHovered, shortcut, target: event.target })) return const textarea = getComposerTextarea(composerPanelRef.current) event.preventDefault() event.stopPropagation() @@ -927,8 +918,20 @@ export function ComposerPromptSurface({ }) }) } + const handlePiExtensionKeyUp = (event: KeyboardEvent) => { + if (isRightAltKeyEvent(event)) rightAltPressedRef.current = false + } + const resetRightAltPressed = () => { + rightAltPressedRef.current = false + } window.addEventListener('keydown', handlePiExtensionShortcut, { capture: true }) - return () => window.removeEventListener('keydown', handlePiExtensionShortcut, { capture: true }) + window.addEventListener('keyup', handlePiExtensionKeyUp, { capture: true }) + window.addEventListener('blur', resetRightAltPressed) + return () => { + window.removeEventListener('keydown', handlePiExtensionShortcut, { capture: true }) + window.removeEventListener('keyup', handlePiExtensionKeyUp, { capture: true }) + window.removeEventListener('blur', resetRightAltPressed) + } }, [ chatGroupId, composerMode, diff --git a/src/app/composer/composer-text-keydown.ts b/src/app/composer/composer-text-keydown.ts index 6bb530312..c047dfb24 100644 --- a/src/app/composer/composer-text-keydown.ts +++ b/src/app/composer/composer-text-keydown.ts @@ -1,6 +1,8 @@ import { type ComposerSendMode, eventToAcceleratorCandidates, + isRightAltKeyEvent, + isRightAltShortcutEvent, type KeybindingOverrides, normalizeAccelerator, } from '@howcode/shared/keybindings' @@ -10,6 +12,44 @@ import type { ComposerSkillMentions } from './useComposerSkillMentions' import type { ComposerSlashCommands } from './useComposerSlashCommands' let graphemeSegmenter: Intl.Segmenter | null = null +let rightAltPressed = false + +function updateComposerTextRightAltState(event: KeyboardEvent) { + if (isRightAltKeyEvent(event)) { + rightAltPressed = true + return + } + if (!event.altKey) rightAltPressed = false +} + +function shouldSkipComposerTextShortcutForRightAlt(event: KeyboardEvent) { + updateComposerTextRightAltState(event) + return isRightAltShortcutEvent(event, rightAltPressed) +} + +function handleLockedComposerTextKey( + event: KeyboardEvent, + inputLocked: boolean, +) { + if (!inputLocked) return false + event.preventDefault() + return true +} + +function handleComposerEscapeKey( + event: KeyboardEvent, + input: ComposerKeyDownInput, +) { + if (event.key !== 'Escape') return false + if (input.dictationActive || input.dictationTranscribing) { + event.preventDefault() + void input.cancelDictation() + return true + } + if (!input.onEscapeOverride?.()) return false + event.preventDefault() + return true +} function getTextSegments(value: string) { if (!('Segmenter' in Intl)) { @@ -131,6 +171,7 @@ function matchesComposerCommandKey( input: ComposerKeyDownInput, commandId: 'composer.newline' | 'composer.submit', ) { + if (isRightAltShortcutEvent(event, rightAltPressed)) return false const override = input.keybindings[commandId] if (override === null) return false const accelerators = @@ -177,19 +218,9 @@ export function handleComposerTextKeyDown( event: KeyboardEvent, input: ComposerKeyDownInput, ) { - if (input.inputLocked) { - event.preventDefault() - return - } - if (event.key === 'Escape' && (input.dictationActive || input.dictationTranscribing)) { - event.preventDefault() - void input.cancelDictation() - return - } - if (event.key === 'Escape' && input.onEscapeOverride?.()) { - event.preventDefault() - return - } + if (handleLockedComposerTextKey(event, input.inputLocked)) return + if (shouldSkipComposerTextShortcutForRightAlt(event)) return + if (handleComposerEscapeKey(event, input)) return if (handleDeleteTextKey(event, input.setDraft, input.clearError)) return if (handleOpenAutocompleteKeyDown(event, input)) return if (handleComposerNewlineCommand(event, input)) return diff --git a/src/app/composer/pi-extension-shortcuts.ts b/src/app/composer/pi-extension-shortcuts.ts new file mode 100644 index 000000000..b06675fd1 --- /dev/null +++ b/src/app/composer/pi-extension-shortcuts.ts @@ -0,0 +1,53 @@ +import { isRightAltShortcutEvent } from '@howcode/shared/keybindings' + +export { isRightAltKeyEvent } from '@howcode/shared/keybindings' + +export type PiExtensionShortcutKeyboardEventLike = Pick< + KeyboardEvent, + 'altKey' | 'code' | 'ctrlKey' | 'isComposing' | 'key' | 'location' | 'metaKey' | 'shiftKey' +> & { + getModifierState?: (keyArg: 'AltGraph') => boolean +} + +export function getPiExtensionShortcutKey(event: PiExtensionShortcutKeyboardEventLike) { + if (event.isComposing) return null + const key = getPiExtensionShortcutBaseKey(event) + if (!key) return null + const modifiers = [ + event.ctrlKey ? 'ctrl' : null, + event.altKey ? 'alt' : null, + event.shiftKey ? 'shift' : null, + event.metaKey ? 'meta' : null, + ].filter(Boolean) + return [...modifiers, key].join('+') +} + +export function isPlainPiExtensionShortcut(shortcut: string) { + return !shortcut.includes('+') +} + +export function isEditableEventTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false + return Boolean(target.closest('input, textarea, select, [contenteditable="true"]')) +} + +export function isRightAltPiExtensionShortcutEvent( + event: PiExtensionShortcutKeyboardEventLike, + rightAltPressed: boolean, +) { + return isRightAltShortcutEvent(event, rightAltPressed) +} + +function getPiExtensionShortcutBaseKey(event: Pick) { + if (event.code.startsWith('Key')) return event.code.slice(3).toLowerCase() + if (event.code.startsWith('Digit')) return event.code.slice(5) + if (event.code === 'ArrowLeft') return 'left' + if (event.code === 'ArrowRight') return 'right' + if (event.code === 'ArrowUp') return 'up' + if (event.code === 'ArrowDown') return 'down' + if (event.code === 'Escape') return 'escape' + if (event.code === 'Enter') return 'enter' + if (event.code === 'Space') return 'space' + if (event.key.length === 1) return event.key.toLowerCase() + return event.key.toLowerCase() || null +} diff --git a/src/app/settings/settings/settingsDescriptorKeybindings.tsx b/src/app/settings/settings/settingsDescriptorKeybindings.tsx index 0118ec374..f55ce6de0 100644 --- a/src/app/settings/settings/settingsDescriptorKeybindings.tsx +++ b/src/app/settings/settings/settingsDescriptorKeybindings.tsx @@ -2,12 +2,14 @@ import { bundledKeybindings, eventToAcceleratorCandidates, getConflictForCommand, + isRightAltKeyEvent, + isRightAltShortcutEvent, isValidAccelerator, normalizeAccelerator, } from '@howcode/shared/keybindings' import { Ban, CheckCircle2, RotateCcw } from 'lucide-react' import type { KeyboardEvent } from 'react' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { AppSettings, DesktopActionInvoker, KeybindingCommandId } from '../../desktop/types' import { appToneTextClass, @@ -108,9 +110,10 @@ function formatAccelerator(value: string, platform: Platform) { .join(' then ') } -function keyEventToAccelerator(event: KeyboardEvent) { +function keyEventToAccelerator(event: KeyboardEvent, rightAltPressed: boolean) { if (event.key === 'Tab') return null if (event.key === 'Escape') return null + if (isRightAltKeyEvent(event) || isRightAltShortcutEvent(event, rightAltPressed)) return null if (['Control', 'Shift', 'Alt', 'Meta'].includes(event.key)) return null if (!(event.metaKey || event.ctrlKey || event.altKey || event.shiftKey)) return null return eventToAcceleratorCandidates(event)[0] ?? null @@ -156,6 +159,7 @@ function ShortcutRecorder({ const persistedOverride = getKeybindingOverride(appSettings, commandId) const [draft, setDraft] = useState(persistedOverride) const [recording, setRecording] = useState(false) + const rightAltPressedRef = useRef(false) const conflict = getConflictForCommand(commandId, appSettings.keybindings) const disabled = appSettings.keybindings[commandId] === null const binding = bundledKeybindings.find((item) => item.id === commandId) @@ -189,7 +193,10 @@ function ShortcutRecorder({ disabled && 'opacity-45', )} onFocus={() => setRecording(true)} - onBlur={() => setRecording(false)} + onBlur={() => { + rightAltPressedRef.current = false + setRecording(false) + }} onClick={() => setRecording(true)} onKeyDown={(event) => { if (event.key === 'Tab') return @@ -198,17 +205,25 @@ function ShortcutRecorder({ return } event.preventDefault() + if (isRightAltKeyEvent(event)) { + rightAltPressedRef.current = true + return + } + if (!event.altKey) rightAltPressedRef.current = false if (event.key === 'Backspace' || event.key === 'Delete') { setDraft('') resetKeybinding({ appSettings, commandId, onAction }) return } - const accelerator = keyEventToAccelerator(event) + const accelerator = keyEventToAccelerator(event, rightAltPressedRef.current) if (!accelerator) return setDraft(accelerator) updateKeybinding({ appSettings, commandId, value: accelerator, onAction }) event.currentTarget.blur() }} + onKeyUp={(event) => { + if (isRightAltKeyEvent(event)) rightAltPressedRef.current = false + }} aria-label={`Record shortcut for ${binding?.label ?? commandId}`} > diff --git a/src/test/keybindings.test.ts b/src/test/keybindings.test.ts new file mode 100644 index 000000000..b8706eae3 --- /dev/null +++ b/src/test/keybindings.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + eventToAcceleratorCandidates, + isRightAltKeyEvent, + isRightAltShortcutEvent, + type KeybindingKeyboardEventLike, +} from '../../shared/keybindings' + +function keyEvent(input: Partial) { + return { + altKey: false, + code: 'KeyE', + ctrlKey: false, + key: 'e', + location: 0, + metaKey: false, + shiftKey: false, + ...input, + } satisfies KeybindingKeyboardEventLike +} + +describe('keybindings', () => { + it('keeps left Alt accelerator candidates available', () => { + const event = keyEvent({ altKey: true }) + + expect(eventToAcceleratorCandidates(event)).toContain('Alt+E') + expect(isRightAltShortcutEvent(event, false)).toBe(false) + }) + + it('detects Right Alt and AltGraph without treating right Shift as Right Alt', () => { + expect(isRightAltKeyEvent(keyEvent({ code: 'AltRight', key: 'Alt', location: 2 }))).toBe(true) + expect(isRightAltKeyEvent(keyEvent({ key: 'AltGraph' }))).toBe(true) + expect(isRightAltShortcutEvent(keyEvent({ altKey: true }), true)).toBe(true) + expect( + isRightAltShortcutEvent( + keyEvent({ altKey: true, getModifierState: (keyArg) => keyArg === 'AltGraph' }), + false, + ), + ).toBe(true) + expect(isRightAltKeyEvent(keyEvent({ code: 'ShiftRight', key: 'Shift', location: 2 }))).toBe( + false, + ) + }) +}) diff --git a/src/test/pi-extension-shortcuts.test.ts b/src/test/pi-extension-shortcuts.test.ts new file mode 100644 index 000000000..0ce0e3d66 --- /dev/null +++ b/src/test/pi-extension-shortcuts.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { + getPiExtensionShortcutKey, + isRightAltKeyEvent, + isRightAltPiExtensionShortcutEvent, + type PiExtensionShortcutKeyboardEventLike, +} from '../app/composer/pi-extension-shortcuts' + +function keyEvent(input: Partial) { + return { + altKey: false, + code: 'KeyE', + ctrlKey: false, + isComposing: false, + key: 'e', + location: 0, + metaKey: false, + shiftKey: false, + ...input, + } satisfies PiExtensionShortcutKeyboardEventLike +} + +describe('Pi extension shortcuts', () => { + it('keeps left Alt shortcuts available', () => { + const event = keyEvent({ altKey: true }) + + expect(getPiExtensionShortcutKey(event)).toBe('alt+e') + expect(isRightAltPiExtensionShortcutEvent(event, false)).toBe(false) + }) + + it('blocks shortcuts while Right Alt is held', () => { + const rightAltDown = keyEvent({ code: 'AltRight', key: 'Alt', location: 2 }) + const textKey = keyEvent({ altKey: true, key: 'ę' }) + + expect(isRightAltKeyEvent(rightAltDown)).toBe(true) + expect(getPiExtensionShortcutKey(textKey)).toBe('alt+e') + expect(isRightAltPiExtensionShortcutEvent(textKey, true)).toBe(true) + }) + + it('does not mistake other right-side modifiers for Right Alt', () => { + const event = keyEvent({ code: 'ShiftRight', key: 'Shift', location: 2, shiftKey: true }) + + expect(isRightAltKeyEvent(event)).toBe(false) + expect(isRightAltPiExtensionShortcutEvent(event, false)).toBe(false) + }) + + it('treats AltGraph as Right Alt input, even without tracked state', () => { + const event = keyEvent({ + altKey: true, + getModifierState: (keyArg) => keyArg === 'AltGraph', + }) + + expect(isRightAltPiExtensionShortcutEvent(event, false)).toBe(true) + }) +})