mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
426 lines
11 KiB
TypeScript
426 lines
11 KiB
TypeScript
/**
|
|
* Quick Entry — the global-hotkey mini composer.
|
|
*
|
|
* A small frameless always-on-top window that a global shortcut summons from
|
|
* anywhere so the user can fire a prompt at Hermes without raising the whole
|
|
* app. The window carries NO gateway connection of its own: it forwards the
|
|
* text to the primary renderer, which sends it through the SAME prompt-submit
|
|
* path the normal composer uses (see app/contrib/hooks/use-quick-entry-bridge).
|
|
*
|
|
* Everything Electron-free lives here so the parts that actually break a user —
|
|
* accelerator validation, "disabled means never register", and surfacing a
|
|
* shortcut another app already owns — are unit-testable without booting
|
|
* Electron. main.ts owns the BrowserWindow, the file I/O, and the real
|
|
* `globalShortcut`.
|
|
*/
|
|
|
|
// Default matches the muscle memory of the apps this ports from (Claude
|
|
// Desktop's quick entry / ChatGPT's Quick Chat sit on a Cmd+Shift chord).
|
|
const DEFAULT_QUICK_ENTRY_SHORTCUT = 'CommandOrControl+Shift+Space'
|
|
|
|
// Compact capture surface: wide enough for a sentence, short enough to read as
|
|
// a HUD rather than a second app window. Height covers the composer row plus
|
|
// the session-target picker row; the renderer never grows the OS window in v1.
|
|
const QUICK_ENTRY_WINDOW_WIDTH = 640
|
|
const QUICK_ENTRY_WINDOW_HEIGHT = 168
|
|
|
|
// Spotlight-ish placement: horizontally centered on the active display, a
|
|
// comfortable fraction down from the top rather than dead center.
|
|
const QUICK_ENTRY_TOP_FRACTION = 0.22
|
|
|
|
// Electron accelerator vocabulary (electronjs.org/docs/latest/api/accelerator).
|
|
// Kept as data so validation and the settings UI agree on one list.
|
|
const ACCELERATOR_MODIFIERS = new Set([
|
|
'alt',
|
|
'altgr',
|
|
'cmd',
|
|
'cmdorctrl',
|
|
'command',
|
|
'commandorcontrol',
|
|
'control',
|
|
'ctrl',
|
|
'meta',
|
|
'option',
|
|
'shift',
|
|
'super'
|
|
])
|
|
|
|
const ACCELERATOR_KEYS = new Set([
|
|
'backspace',
|
|
'delete',
|
|
'down',
|
|
'end',
|
|
'enter',
|
|
'escape',
|
|
'home',
|
|
'insert',
|
|
'left',
|
|
'medianexttrack',
|
|
'mediaplaypause',
|
|
'mediaprevioustrack',
|
|
'mediastop',
|
|
'pagedown',
|
|
'pageup',
|
|
'plus',
|
|
'printscreen',
|
|
'return',
|
|
'right',
|
|
'space',
|
|
'tab',
|
|
'up',
|
|
'volumedown',
|
|
'volumemute',
|
|
'volumeup'
|
|
])
|
|
|
|
// Single printable characters Electron accepts verbatim, plus 0-9 / A-Z below.
|
|
const ACCELERATOR_PUNCTUATION = new Set([
|
|
'!',
|
|
'"',
|
|
'#',
|
|
'$',
|
|
'%',
|
|
'&',
|
|
"'",
|
|
'(',
|
|
')',
|
|
'*',
|
|
'+',
|
|
',',
|
|
'-',
|
|
'.',
|
|
'/',
|
|
':',
|
|
';',
|
|
'<',
|
|
'=',
|
|
'>',
|
|
'?',
|
|
'@',
|
|
'[',
|
|
'\\',
|
|
']',
|
|
'^',
|
|
'_',
|
|
'`',
|
|
'{',
|
|
'|',
|
|
'}',
|
|
'~'
|
|
])
|
|
|
|
/** Why a shortcut string was rejected. The renderer maps these to copy. */
|
|
export type QuickEntryShortcutError =
|
|
| 'empty'
|
|
| 'invalid-key'
|
|
| 'invalid-modifier'
|
|
| 'no-key'
|
|
| 'no-modifier'
|
|
| 'reserved'
|
|
|
|
export type QuickEntryShortcutParse = { ok: false; reason: QuickEntryShortcutError } | { accelerator: string; ok: true }
|
|
|
|
function isAcceleratorKey(token: string): boolean {
|
|
if (ACCELERATOR_KEYS.has(token)) {
|
|
return true
|
|
}
|
|
|
|
if (/^f([1-9]|1[0-9]|2[0-4])$/.test(token)) {
|
|
return true
|
|
}
|
|
|
|
if (/^num(?:[0-9]|lock|dec|add|sub|mult|div)$/.test(token)) {
|
|
return true
|
|
}
|
|
|
|
return token.length === 1 && (/^[a-z0-9]$/.test(token) || ACCELERATOR_PUNCTUATION.has(token))
|
|
}
|
|
|
|
/**
|
|
* Validate + normalize a user-typed accelerator.
|
|
*
|
|
* Rules beyond Electron's own grammar, both deliberate:
|
|
* - At least one modifier. A bare global key steals that key from EVERY app.
|
|
* - `Escape` can't be the key: inside the window Escape means "hide", so
|
|
* binding it globally would make the shortcut un-toggleable.
|
|
*/
|
|
export function parseQuickEntryShortcut(raw: unknown): QuickEntryShortcutParse {
|
|
if (typeof raw !== 'string' || !raw.trim()) {
|
|
return { ok: false, reason: 'empty' }
|
|
}
|
|
|
|
const parts = raw
|
|
.split('+')
|
|
.map(part => part.trim())
|
|
.filter(Boolean)
|
|
|
|
if (parts.length === 0) {
|
|
return { ok: false, reason: 'empty' }
|
|
}
|
|
|
|
const modifiers: string[] = []
|
|
let key: null | string = null
|
|
|
|
for (const part of parts) {
|
|
const lower = part.toLowerCase()
|
|
|
|
if (ACCELERATOR_MODIFIERS.has(lower)) {
|
|
if (key) {
|
|
// A modifier after the key ("A+Shift") is not a valid accelerator.
|
|
return { ok: false, reason: 'invalid-modifier' }
|
|
}
|
|
|
|
modifiers.push(lower)
|
|
|
|
continue
|
|
}
|
|
|
|
if (key) {
|
|
// Two non-modifier keys ("Shift+A+B").
|
|
return { ok: false, reason: 'invalid-key' }
|
|
}
|
|
|
|
if (!isAcceleratorKey(lower)) {
|
|
return { ok: false, reason: 'invalid-key' }
|
|
}
|
|
|
|
key = lower
|
|
}
|
|
|
|
if (!key) {
|
|
return { ok: false, reason: 'no-key' }
|
|
}
|
|
|
|
if (modifiers.length === 0) {
|
|
return { ok: false, reason: 'no-modifier' }
|
|
}
|
|
|
|
if (key === 'escape') {
|
|
return { ok: false, reason: 'reserved' }
|
|
}
|
|
|
|
// Canonical casing so a saved shortcut round-trips identically no matter how
|
|
// the user typed it, and duplicate modifiers collapse.
|
|
const seen = new Set<string>()
|
|
|
|
const normalizedModifiers = modifiers
|
|
.map(modifier => CANONICAL_MODIFIER[modifier] ?? modifier)
|
|
.filter(modifier => (seen.has(modifier) ? false : (seen.add(modifier), true)))
|
|
// Stable display order (Electron itself is order-insensitive).
|
|
.sort((left, right) => MODIFIER_ORDER.indexOf(left) - MODIFIER_ORDER.indexOf(right))
|
|
|
|
return { accelerator: [...normalizedModifiers, canonicalKey(key)].join('+'), ok: true }
|
|
}
|
|
|
|
const CANONICAL_MODIFIER: Record<string, string> = {
|
|
alt: 'Alt',
|
|
altgr: 'AltGr',
|
|
cmd: 'Command',
|
|
cmdorctrl: 'CommandOrControl',
|
|
command: 'Command',
|
|
commandorcontrol: 'CommandOrControl',
|
|
control: 'Control',
|
|
ctrl: 'Control',
|
|
meta: 'Super',
|
|
option: 'Option',
|
|
shift: 'Shift',
|
|
super: 'Super'
|
|
}
|
|
|
|
const MODIFIER_ORDER = ['CommandOrControl', 'Command', 'Control', 'Super', 'Alt', 'Option', 'AltGr', 'Shift']
|
|
|
|
const CANONICAL_KEY: Record<string, string> = {
|
|
backspace: 'Backspace',
|
|
delete: 'Delete',
|
|
down: 'Down',
|
|
end: 'End',
|
|
enter: 'Enter',
|
|
escape: 'Escape',
|
|
home: 'Home',
|
|
insert: 'Insert',
|
|
medianexttrack: 'MediaNextTrack',
|
|
mediaplaypause: 'MediaPlayPause',
|
|
mediaprevioustrack: 'MediaPreviousTrack',
|
|
mediastop: 'MediaStop',
|
|
pagedown: 'PageDown',
|
|
pageup: 'PageUp',
|
|
plus: 'Plus',
|
|
printscreen: 'PrintScreen',
|
|
return: 'Return',
|
|
right: 'Right',
|
|
space: 'Space',
|
|
tab: 'Tab',
|
|
up: 'Up',
|
|
volumedown: 'VolumeDown',
|
|
volumemute: 'VolumeMute',
|
|
volumeup: 'VolumeUp',
|
|
left: 'Left'
|
|
}
|
|
|
|
function canonicalKey(key: string): string {
|
|
if (CANONICAL_KEY[key]) {
|
|
return CANONICAL_KEY[key]
|
|
}
|
|
|
|
if (/^f([1-9]|1[0-9]|2[0-4])$/.test(key)) {
|
|
return key.toUpperCase()
|
|
}
|
|
|
|
if (key.length === 1 && /^[a-z]$/.test(key)) {
|
|
return key.toUpperCase()
|
|
}
|
|
|
|
return key
|
|
}
|
|
|
|
/** The persisted shape of `quick-entry.json` (main-process owned). */
|
|
export interface QuickEntrySettings {
|
|
enabled: boolean
|
|
shortcut: string
|
|
}
|
|
|
|
/**
|
|
* Raw persisted JSON → usable settings. A malformed/absent file, or a shortcut
|
|
* that no longer validates (hand-edited, or from a future build), falls back to
|
|
* the default shortcut rather than leaving the feature un-summonable.
|
|
*/
|
|
export function sanitizeQuickEntrySettings(raw: unknown): QuickEntrySettings {
|
|
const record = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
|
|
const parsed = parseQuickEntryShortcut(record.shortcut)
|
|
|
|
return {
|
|
// Default ON: the feature is inert until the shortcut is pressed.
|
|
enabled: record.enabled === undefined ? true : record.enabled === true,
|
|
shortcut: parsed.ok ? parsed.accelerator : DEFAULT_QUICK_ENTRY_SHORTCUT
|
|
}
|
|
}
|
|
|
|
/** The slice of Electron's `globalShortcut` we use (injected for testing). */
|
|
export interface GlobalShortcutLike {
|
|
isRegistered(accelerator: string): boolean
|
|
register(accelerator: string, callback: () => void): boolean
|
|
unregister(accelerator: string): void
|
|
}
|
|
|
|
/**
|
|
* What Settings shows. `registered` is the ground truth (we asked the OS);
|
|
* `error` distinguishes "you turned it off" from "another app owns that chord",
|
|
* which is the failure this feature must never swallow.
|
|
*/
|
|
export interface QuickEntryRegistration {
|
|
error: null | QuickEntryRegistrationError
|
|
registered: boolean
|
|
shortcut: string
|
|
}
|
|
|
|
export type QuickEntryRegistrationError = 'invalid' | 'taken'
|
|
|
|
export interface QuickEntryShortcutController {
|
|
/** Registration state as of the last apply. */
|
|
current(): QuickEntryRegistration
|
|
/** Release the shortcut (quit / feature off). Idempotent. */
|
|
dispose(): void
|
|
/** Re-register to match `settings`. Returns the resulting state. */
|
|
apply(settings: QuickEntrySettings): QuickEntryRegistration
|
|
}
|
|
|
|
/**
|
|
* Owns the one live global accelerator. Single resolver so every caller — boot,
|
|
* the settings write, quit — gets the same answer and we can never leak two
|
|
* registrations for one feature.
|
|
*
|
|
* Disabled settings never touch `register()` at all: a user who turned Quick
|
|
* Entry off must not have their chord silently held hostage.
|
|
*/
|
|
export function createQuickEntryShortcut(
|
|
globalShortcut: GlobalShortcutLike,
|
|
onTrigger: () => void
|
|
): QuickEntryShortcutController {
|
|
let active: null | string = null
|
|
let state: QuickEntryRegistration = { error: null, registered: false, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT }
|
|
|
|
const release = () => {
|
|
if (active) {
|
|
try {
|
|
globalShortcut.unregister(active)
|
|
} catch {
|
|
// Best effort — a dead accelerator must not block a re-register.
|
|
}
|
|
|
|
active = null
|
|
}
|
|
}
|
|
|
|
return {
|
|
apply(settings) {
|
|
const parsed = parseQuickEntryShortcut(settings.shortcut)
|
|
const shortcut = parsed.ok ? parsed.accelerator : settings.shortcut
|
|
|
|
release()
|
|
|
|
if (!settings.enabled) {
|
|
state = { error: null, registered: false, shortcut }
|
|
|
|
return state
|
|
}
|
|
|
|
if (!parsed.ok) {
|
|
state = { error: 'invalid', registered: false, shortcut }
|
|
|
|
return state
|
|
}
|
|
|
|
// `isRegistered` catches the common conflict before we ask, and
|
|
// `register()` returning false catches the rest (another process owns it
|
|
// OS-wide). Both land in the same surfaced 'taken' state.
|
|
let ok = false
|
|
|
|
try {
|
|
ok = globalShortcut.isRegistered(parsed.accelerator)
|
|
? false
|
|
: globalShortcut.register(parsed.accelerator, onTrigger)
|
|
} catch {
|
|
ok = false
|
|
}
|
|
|
|
active = ok ? parsed.accelerator : null
|
|
state = { error: ok ? null : 'taken', registered: ok, shortcut: parsed.accelerator }
|
|
|
|
return state
|
|
},
|
|
current() {
|
|
return state
|
|
},
|
|
dispose() {
|
|
release()
|
|
state = { ...state, error: null, registered: false }
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Where the quick window opens on a given display work area. Centered
|
|
* horizontally, a fraction down from the top, and clamped so it stays fully
|
|
* inside the work area on small/odd displays.
|
|
*/
|
|
export function quickEntryWindowBounds(workArea?: { height: number; width: number; x: number; y: number }): {
|
|
height: number
|
|
width: number
|
|
x: number
|
|
y: number
|
|
} {
|
|
const width = Math.min(QUICK_ENTRY_WINDOW_WIDTH, workArea?.width ?? QUICK_ENTRY_WINDOW_WIDTH)
|
|
const height = Math.min(QUICK_ENTRY_WINDOW_HEIGHT, workArea?.height ?? QUICK_ENTRY_WINDOW_HEIGHT)
|
|
|
|
if (!workArea) {
|
|
return { height, width, x: 0, y: 0 }
|
|
}
|
|
|
|
const x = Math.round(workArea.x + (workArea.width - width) / 2)
|
|
const maxY = workArea.y + workArea.height - height
|
|
const y = Math.round(Math.min(Math.max(workArea.y, workArea.y + workArea.height * QUICK_ENTRY_TOP_FRACTION), maxY))
|
|
|
|
return { height, width, x, y }
|
|
}
|
|
|
|
export { DEFAULT_QUICK_ENTRY_SHORTCUT, QUICK_ENTRY_TOP_FRACTION, QUICK_ENTRY_WINDOW_HEIGHT, QUICK_ENTRY_WINDOW_WIDTH }
|