mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
Merge pull request #68918 from NousResearch/bb/composer-focus-keys
feat(desktop): type-to-focus the composer from empty chat chrome
This commit is contained in:
commit
81f4095bc1
7 changed files with 285 additions and 12 deletions
|
|
@ -18,8 +18,10 @@ import { RICH_INPUT_SLOT } from './rich-editor'
|
|||
export type ComposerTarget = 'edit' | 'main' | (string & {})
|
||||
export type ComposerInsertMode = 'block' | 'inline'
|
||||
|
||||
interface FocusDetail {
|
||||
export interface FocusDetail {
|
||||
target: ComposerTarget
|
||||
/** Append after focus (type-to-focus / soft `/`). */
|
||||
typeChar?: string
|
||||
}
|
||||
|
||||
interface InsertDetail {
|
||||
|
|
@ -82,8 +84,10 @@ export const markActiveComposer = (target: ComposerTarget) => {
|
|||
* Used by broadcast listeners (voice, Esc-to-stop) to act on exactly one. */
|
||||
export const getActiveComposer = (): ComposerTarget => activeTarget
|
||||
|
||||
export const requestComposerFocus = (target: ComposerTarget | 'active' = 'active') =>
|
||||
dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target) })
|
||||
export const requestComposerFocus = (
|
||||
target: ComposerTarget | 'active' = 'active',
|
||||
{ typeChar }: { typeChar?: string } = {}
|
||||
) => dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target), typeChar })
|
||||
|
||||
export const requestComposerInsert = (
|
||||
text: string,
|
||||
|
|
@ -98,8 +102,8 @@ export const requestComposerInsert = (
|
|||
dispatch<InsertDetail>(INSERT_EVENT, { mode, target: resolve(target), text: trimmed })
|
||||
}
|
||||
|
||||
export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void) =>
|
||||
subscribe<FocusDetail>(FOCUS_EVENT, ({ target }) => handler(target))
|
||||
export const onComposerFocusRequest = (handler: (detail: FocusDetail) => void) =>
|
||||
subscribe<FocusDetail>(FOCUS_EVENT, handler)
|
||||
|
||||
export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) =>
|
||||
subscribe<InsertDetail>(INSERT_EVENT, handler)
|
||||
|
|
|
|||
|
|
@ -158,10 +158,19 @@ export function useComposerDraft({
|
|||
return undefined
|
||||
}
|
||||
|
||||
const offFocus = onComposerFocusRequest(requested => {
|
||||
if (requested === target) {
|
||||
setFocusRequestId(id => id + 1)
|
||||
const offFocus = onComposerFocusRequest(({ target: requested, typeChar }) => {
|
||||
if (requested !== target) {
|
||||
return
|
||||
}
|
||||
|
||||
// Type-to-focus appends at end; bare Enter just focuses.
|
||||
if (typeChar) {
|
||||
paintDraft(`${draftRef.current}${typeChar}`, true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setFocusRequestId(id => id + 1)
|
||||
})
|
||||
|
||||
const offInsert = onComposerInsertRequest(({ mode, target: requested, text }) => {
|
||||
|
|
@ -174,7 +183,7 @@ export function useComposerDraft({
|
|||
offFocus()
|
||||
offInsert()
|
||||
}
|
||||
}, [appendExternalText, inputDisabled, target])
|
||||
}, [appendExternalText, inputDisabled, paintDraft, target])
|
||||
|
||||
const stashAt = (scope: string | null, text = draftRef.current, attachments = attachmentScope.$attachments.get()) =>
|
||||
stashSessionDraft(scope, text, attachments)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right-
|
|||
import { activateTreeTabSlot, cycleTreeTabInFocusedZone, layoutHasRootSide } from '@/components/pane-shell/tree/store'
|
||||
import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
|
||||
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
|
||||
import {
|
||||
composerFocusKeysAllowed,
|
||||
isComposerFocusSoftCombo,
|
||||
typeToFocusChar
|
||||
} from '@/lib/keybinds/composer-focus-keys'
|
||||
import { $repoStatus } from '@/store/coding-status'
|
||||
import { toggleCommandPalette } from '@/store/command-palette'
|
||||
import { $capture, $comboIndex, endCapture, setBinding } from '@/store/keybinds'
|
||||
|
|
@ -122,7 +127,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
handlersRef.current = {
|
||||
'keybinds.openPanel': () => navigate(`${SETTINGS_ROUTE}?tab=keybinds`),
|
||||
|
||||
'composer.focus': () => requestComposerFocus('main'),
|
||||
'composer.focus': () => requestComposerFocus('active'),
|
||||
'composer.modelPicker': () => setModelPickerOpen(true),
|
||||
'composer.voice': requestVoiceToggle,
|
||||
|
||||
|
|
@ -242,7 +247,15 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
|
||||
const actionId = $comboIndex.get().get(combo)
|
||||
|
||||
// Unbound printable → type-to-focus. Bound chords (shift+n, …) win above.
|
||||
if (!actionId) {
|
||||
const typeChar = typeToFocusChar(event)
|
||||
|
||||
if (typeChar && composerFocusKeysAllowed(event, 'type')) {
|
||||
event.preventDefault()
|
||||
requestComposerFocus('active', { typeChar })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -250,6 +263,19 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
return
|
||||
}
|
||||
|
||||
// Soft `/` / Enter: gated so dialogs/buttons/terminal keep those keys.
|
||||
// Rebound chords fall through to the normal handler.
|
||||
if (actionId === 'composer.focus' && isComposerFocusSoftCombo(combo)) {
|
||||
if (!composerFocusKeysAllowed(event, combo)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
requestComposerFocus('active', { typeChar: combo === '/' ? '/' : undefined })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Built-in handlers first (they carry React context); contributed
|
||||
// actions bring their own `run` through the registry.
|
||||
const handler = handlersRef.current[actionId] ?? contributedKeybindHandler(actionId)
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
}, [focusEditor, focusRequestId])
|
||||
|
||||
useEffect(() => {
|
||||
const offFocus = onComposerFocusRequest(target => {
|
||||
const offFocus = onComposerFocusRequest(({ target }) => {
|
||||
if (target === 'edit') {
|
||||
setFocusRequestId(id => id + 1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ const SESSION_SLOT_ACTIONS: KeybindActionMeta[] = Array.from({ length: SESSION_S
|
|||
|
||||
export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
|
||||
// ── Composer ─────────────────────────────────────────────────────────────
|
||||
{ id: 'composer.focus', category: 'composer', defaults: [] },
|
||||
// Soft `/` / Enter focus (gated); other printables type-to-focus unbound.
|
||||
{ id: 'composer.focus', category: 'composer', defaults: ['/', 'enter'] },
|
||||
{ id: 'composer.modelPicker', category: 'composer', defaults: [] },
|
||||
// Voice conversation toggle. Matches the documented `voice.record_key`
|
||||
// (Ctrl+B). On macOS that's literally ⌃B — distinct from the ⌘B sidebar
|
||||
|
|
|
|||
149
apps/desktop/src/lib/keybinds/composer-focus-keys.test.ts
Normal file
149
apps/desktop/src/lib/keybinds/composer-focus-keys.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $workspaceIsPage } from '@/app/routes'
|
||||
import { closeSwitcher, $switcherOpen } from '@/store/session-switcher'
|
||||
|
||||
import {
|
||||
composerFocusBlockedBySurface,
|
||||
composerFocusKeysAllowed,
|
||||
isActivateOnEnterTarget,
|
||||
typeToFocusChar
|
||||
} from './composer-focus-keys'
|
||||
|
||||
function keydown(init: KeyboardEventInit & { target?: EventTarget }): KeyboardEvent {
|
||||
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })
|
||||
|
||||
if (init.target) {
|
||||
Object.defineProperty(event, 'target', { value: init.target })
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
describe('isActivateOnEnterTarget', () => {
|
||||
it('ignores body / null', () => {
|
||||
expect(isActivateOnEnterTarget(document.body)).toBe(false)
|
||||
expect(isActivateOnEnterTarget(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects buttons and walks to a wrapping activator', () => {
|
||||
const button = document.createElement('button')
|
||||
const wrap = document.createElement('div')
|
||||
wrap.setAttribute('role', 'button')
|
||||
const child = document.createElement('span')
|
||||
wrap.append(child)
|
||||
document.body.append(button, wrap)
|
||||
|
||||
expect(isActivateOnEnterTarget(button)).toBe(true)
|
||||
expect(isActivateOnEnterTarget(child)).toBe(true)
|
||||
expect(isActivateOnEnterTarget(document.createElement('div'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('composerFocusBlockedBySurface', () => {
|
||||
beforeEach(() => {
|
||||
$workspaceIsPage.set(false)
|
||||
closeSwitcher()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$workspaceIsPage.set(false)
|
||||
closeSwitcher()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('is clear on empty chat chrome', () => {
|
||||
expect(composerFocusBlockedBySurface()).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks dialogs, the session switcher, and full pages', () => {
|
||||
const dialog = document.createElement('div')
|
||||
dialog.setAttribute('role', 'dialog')
|
||||
document.body.append(dialog)
|
||||
expect(composerFocusBlockedBySurface()).toBe(true)
|
||||
|
||||
document.body.replaceChildren()
|
||||
$switcherOpen.set(true)
|
||||
expect(composerFocusBlockedBySurface()).toBe(true)
|
||||
|
||||
closeSwitcher()
|
||||
$workspaceIsPage.set(true)
|
||||
expect(composerFocusBlockedBySurface()).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks when focus is inside a terminal', () => {
|
||||
const term = document.createElement('div')
|
||||
term.setAttribute('data-terminal', '')
|
||||
const inner = document.createElement('div')
|
||||
term.append(inner)
|
||||
document.body.append(term)
|
||||
Object.defineProperty(document, 'activeElement', { configurable: true, get: () => inner })
|
||||
|
||||
expect(composerFocusBlockedBySurface()).toBe(true)
|
||||
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
configurable: true,
|
||||
get: () => document.body
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('typeToFocusChar', () => {
|
||||
it('returns printables (case/symbols via event.key)', () => {
|
||||
expect(typeToFocusChar(keydown({ key: 'a', code: 'KeyA' }))).toBe('a')
|
||||
expect(typeToFocusChar(keydown({ key: 'A', code: 'KeyA', shiftKey: true }))).toBe('A')
|
||||
expect(typeToFocusChar(keydown({ key: '?', code: 'Slash', shiftKey: true }))).toBe('?')
|
||||
expect(typeToFocusChar(keydown({ key: ' ', code: 'Space' }))).toBe(' ')
|
||||
})
|
||||
|
||||
it('rejects non-printables and modified chords', () => {
|
||||
expect(typeToFocusChar(keydown({ key: 'Enter', code: 'Enter' }))).toBeNull()
|
||||
expect(typeToFocusChar(keydown({ key: 'a', code: 'KeyA', metaKey: true }))).toBeNull()
|
||||
expect(typeToFocusChar(keydown({ key: 'a', code: 'KeyA', isComposing: true }))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('composerFocusKeysAllowed', () => {
|
||||
beforeEach(() => {
|
||||
$workspaceIsPage.set(false)
|
||||
closeSwitcher()
|
||||
document.body.replaceChildren()
|
||||
vi.spyOn(document, 'activeElement', 'get').mockReturnValue(document.body)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$workspaceIsPage.set(false)
|
||||
closeSwitcher()
|
||||
document.body.replaceChildren()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('passes rebound chords; allows soft keys on the transcript', () => {
|
||||
expect(composerFocusKeysAllowed(keydown({ key: 'i', code: 'KeyI', metaKey: true }), 'mod+i')).toBe(true)
|
||||
expect(composerFocusKeysAllowed(keydown({ key: '/', code: 'Slash', target: document.body }), '/')).toBe(true)
|
||||
expect(composerFocusKeysAllowed(keydown({ key: 'Enter', code: 'Enter', target: document.body }), 'enter')).toBe(
|
||||
true
|
||||
)
|
||||
expect(composerFocusKeysAllowed(keydown({ key: 'h', code: 'KeyH', target: document.body }), 'type')).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses editables; refuses Enter on buttons but allows / and typing', () => {
|
||||
const input = document.createElement('input')
|
||||
const button = document.createElement('button')
|
||||
document.body.append(input, button)
|
||||
|
||||
expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: input }), 'type')).toBe(false)
|
||||
expect(composerFocusKeysAllowed(keydown({ key: 'Enter', code: 'Enter', target: button }), 'enter')).toBe(false)
|
||||
expect(composerFocusKeysAllowed(keydown({ key: '/', code: 'Slash', target: button }), '/')).toBe(true)
|
||||
expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: button }), 'type')).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses when a dialog is open', () => {
|
||||
const dialog = document.createElement('div')
|
||||
dialog.setAttribute('role', 'dialog')
|
||||
document.body.append(dialog)
|
||||
|
||||
expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: document.body }), 'type')).toBe(false)
|
||||
})
|
||||
})
|
||||
84
apps/desktop/src/lib/keybinds/composer-focus-keys.ts
Normal file
84
apps/desktop/src/lib/keybinds/composer-focus-keys.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Soft focus / type-to-focus for the chat composer.
|
||||
*
|
||||
* On empty chat chrome, Enter focuses the composer; printable keys focus and
|
||||
* type. Bound shortcuts still win via the keybind index. Surfaces that own
|
||||
* keys (dialogs, menus, terminal, …) are left alone.
|
||||
*/
|
||||
|
||||
import { $workspaceIsPage } from '@/app/routes'
|
||||
import { switcherActive } from '@/store/session-switcher'
|
||||
|
||||
import { isEditableTarget, isFocusWithin } from './combo'
|
||||
|
||||
/** `composer.focus` defaults that need the surface/target gate. */
|
||||
export const isComposerFocusSoftCombo = (combo: string) => combo === '/' || combo === 'enter'
|
||||
|
||||
const ENTER_ACTIVATES = [
|
||||
'a[href]',
|
||||
'button',
|
||||
'summary',
|
||||
'input',
|
||||
'textarea',
|
||||
'select',
|
||||
'[contenteditable=""]',
|
||||
'[contenteditable="true"]',
|
||||
'[role="button"]',
|
||||
'[role="checkbox"]',
|
||||
'[role="combobox"]',
|
||||
'[role="link"]',
|
||||
'[role="menuitem"]',
|
||||
'[role="menuitemcheckbox"]',
|
||||
'[role="menuitemradio"]',
|
||||
'[role="option"]',
|
||||
'[role="radio"]',
|
||||
'[role="switch"]',
|
||||
'[role="tab"]',
|
||||
'[role="treeitem"]'
|
||||
].join(',')
|
||||
|
||||
const BLOCKING_SURFACE =
|
||||
'[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"],[data-radix-popper-content-wrapper]'
|
||||
|
||||
/** True when the focused control would normally handle Enter itself. */
|
||||
export function isActivateOnEnterTarget(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null
|
||||
|
||||
return Boolean(el && el !== document.body && el !== document.documentElement && el.closest(ENTER_ACTIVATES))
|
||||
}
|
||||
|
||||
/** Dialogs, menus, terminal, full pages, session switcher — they keep their keys. */
|
||||
export function composerFocusBlockedBySurface(): boolean {
|
||||
return (
|
||||
switcherActive() ||
|
||||
$workspaceIsPage.get() ||
|
||||
isFocusWithin('[data-terminal]') ||
|
||||
Boolean(document.querySelector(BLOCKING_SURFACE))
|
||||
)
|
||||
}
|
||||
|
||||
/** Printable `event.key` for type-to-focus, or null (modifiers / non-printables / IME). */
|
||||
export function typeToFocusChar(event: KeyboardEvent): string | null {
|
||||
if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Length 1 ⇒ letter/digit/punct/space; Enter/Tab/Arrows/Dead/F-keys are longer.
|
||||
return event.key.length === 1 ? event.key : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether soft focus / type-to-focus may run.
|
||||
* `combo` is `/` | `enter` | `'type'` (unbound printable); other chords pass.
|
||||
*/
|
||||
export function composerFocusKeysAllowed(event: KeyboardEvent, combo: string): boolean {
|
||||
if (combo !== 'type' && !isComposerFocusSoftCombo(combo)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.defaultPrevented || event.isComposing || isEditableTarget(event.target) || composerFocusBlockedBySurface()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !(combo === 'enter' && isActivateOnEnterTarget(event.target))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue