Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions shared/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KeybindingKeyboardEventLike, 'code' | 'key' | 'location'>,
) {
return (
event.code === 'AltRight' ||
event.key === 'AltGraph' ||
(event.key === 'Alt' && event.location === rightKeyboardLocation)
)
}

export function isRightAltShortcutEvent(
event: Pick<KeybindingKeyboardEventLike, 'code' | 'getModifierState' | 'key' | 'location'>,
rightAltPressed: boolean,
) {
return (
rightAltPressed || event.getModifierState?.('AltGraph') === true || isRightAltKeyEvent(event)
)
}

export function getKeybindingEventKey(event: Pick<KeybindingKeyboardEventLike, 'code' | 'key'>) {
if (keyboardCodeLetterPattern.test(event.code)) return event.code.slice(3)
if (keyboardCodeDigitPattern.test(event.code)) return event.code.slice(5)
Expand Down
26 changes: 26 additions & 0 deletions src/app/app-shell/useAppKeybindings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
eventToAcceleratorCandidates,
getEffectiveAccelerators,
isRightAltKeyEvent,
isRightAltShortcutEvent,
type KeybindingCommandId,
type KeybindingOverrides,
} from '@howcode/shared/keybindings'
Expand Down Expand Up @@ -62,6 +64,18 @@ function handleShortcut(event: KeyboardEvent, runtime: KeybindingRuntime) {
event.stopImmediatePropagation()
}

function shouldSkipShortcutForRightAlt(
event: KeyboardEvent,
rightAltPressedRef: React.MutableRefObject<boolean>,
) {
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
Expand All @@ -88,6 +102,7 @@ export function useAppKeybindings(input: {
return map
}, [keybindings])
const lastEscapeAtRef = useRef(0)
const rightAltPressedRef = useRef(false)
const cycleSelectionRef = useRef<KeybindingRuntime['cycleSelectionRef']['current']>(null)
const latest = useLatest({
acceleratorToCommand,
Expand Down Expand Up @@ -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<HowcodeKeybindingCommandDetail>).detail?.commandId
if (!commandId || rendererCommandIds.has(commandId)) return
Expand All @@ -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])
Expand Down
79 changes: 41 additions & 38 deletions src/app/composer/composer-prompt-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<boolean>,
) {
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[]) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -697,6 +688,7 @@ export function ComposerPromptSurface({
const stopButtonBoundaryRef = useRef<HTMLDivElement>(null)
const composerOverlayStackRef = useRef<HTMLDivElement>(null)
const piExtensionStatusLineRef = useRef<HTMLDivElement>(null)
const rightAltPressedRef = useRef(false)
const showNativeDialog = piExtensionDialogRequest !== null
const showProjectTrust = projectTrustRequest !== null
const visiblePiExtensionWidgets = piExtensionWidgets.filter(
Expand Down Expand Up @@ -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()
Expand All @@ -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,
Expand Down
57 changes: 44 additions & 13 deletions src/app/composer/composer-text-keydown.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
type ComposerSendMode,
eventToAcceleratorCandidates,
isRightAltKeyEvent,
isRightAltShortcutEvent,
type KeybindingOverrides,
normalizeAccelerator,
} from '@howcode/shared/keybindings'
Expand All @@ -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<HTMLTextAreaElement>) {
if (isRightAltKeyEvent(event)) {
rightAltPressed = true
return
}
if (!event.altKey) rightAltPressed = false
}

function shouldSkipComposerTextShortcutForRightAlt(event: KeyboardEvent<HTMLTextAreaElement>) {
updateComposerTextRightAltState(event)
return isRightAltShortcutEvent(event, rightAltPressed)
}

function handleLockedComposerTextKey(
event: KeyboardEvent<HTMLTextAreaElement>,
inputLocked: boolean,
) {
if (!inputLocked) return false
event.preventDefault()
return true
}

function handleComposerEscapeKey(
event: KeyboardEvent<HTMLTextAreaElement>,
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)) {
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -177,19 +218,9 @@ export function handleComposerTextKeyDown(
event: KeyboardEvent<HTMLTextAreaElement>,
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
Expand Down
53 changes: 53 additions & 0 deletions src/app/composer/pi-extension-shortcuts.ts
Original file line number Diff line number Diff line change
@@ -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<KeyboardEvent, 'code' | 'key'>) {
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
}
Loading