feat(desktop): Quick Entry — global-hotkey mini composer window

This commit is contained in:
teknium1 2026-07-26 14:37:42 -07:00 committed by Teknium
parent 5557b10fb6
commit aa3121bb34
21 changed files with 1674 additions and 8 deletions

View file

@ -14,6 +14,7 @@ import {
clipboard,
dialog,
net as electronNet,
globalShortcut,
ipcMain,
Menu,
nativeImage,
@ -141,6 +142,7 @@ import { createKeepAwake } from './power-save'
import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-backend-startup'
import { rehomePrimaryConnection } from './primary-connection-rehome'
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
import { createQuickEntryShortcut, quickEntryWindowBounds, sanitizeQuickEntrySettings } from './quick-entry'
import * as remoteLifecycle from './remote-lifecycle'
import { RemoteLivenessTracker, RemoteRevalidationCoordinator, revalidateRemoteConnection } from './remote-liveness'
import {
@ -8665,6 +8667,198 @@ function closePetOverlay() {
petOverlayWindow = null
}
// ── Quick Entry ─────────────────────────────────────────────────────────────
//
// A global shortcut summons a small frameless always-on-top composer from
// anywhere, so a prompt can be fired without raising the whole app. The window
// carries NO gateway connection: it hands its text to us, we forward it to the
// PRIMARY renderer, and that renderer submits through the same prompt path the
// normal composer uses (see store/quick-entry + hooks/use-quick-entry-bridge).
//
// Main owns the OS registration and the persisted preference (it must restore
// the shortcut on a cold launch without the renderer ever visiting Settings),
// same authority split as keep-awake. Registration failure is surfaced, never
// swallowed: a chord another app already owns comes back as `error: 'taken'`.
const QUICK_ENTRY_CONFIG_PATH = path.join(app.getPath('userData'), 'quick-entry.json')
let quickEntryWindow = null
function readQuickEntrySettings() {
try {
return sanitizeQuickEntrySettings(JSON.parse(fs.readFileSync(QUICK_ENTRY_CONFIG_PATH, 'utf8')))
} catch {
// Missing / unreadable / malformed → shipped defaults (enabled, default chord).
return sanitizeQuickEntrySettings(undefined)
}
}
function writeQuickEntrySettings(settings) {
try {
fs.mkdirSync(path.dirname(QUICK_ENTRY_CONFIG_PATH), { recursive: true })
fs.writeFileSync(QUICK_ENTRY_CONFIG_PATH, JSON.stringify(settings, null, 2), 'utf8')
} catch (error) {
rememberLog(`[quick-entry] write failed: ${error.message}`)
}
}
function quickEntryUrl() {
if (DEV_SERVER) {
return `${DEV_SERVER.endsWith('/') ? DEV_SERVER.slice(0, -1) : DEV_SERVER}/?win=quick#/`
}
return `${pathToFileURL(resolveRendererIndex()).toString()}?win=quick#/`
}
function spawnQuickEntryWindow() {
const cursor = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursor)
const bounds = quickEntryWindowBounds(display?.workArea)
const win = new BrowserWindow({
...bounds,
frame: false,
transparent: true,
resizable: false,
movable: true,
minimizable: false,
maximizable: false,
fullscreenable: false,
// Same rationale as the pet overlay: on Windows/Linux keep the helper out
// of the taskbar/alt-tab list; on macOS use an NSPanel so the frameless
// capture window never becomes the app's cmd-tab anchor.
skipTaskbar: !IS_MAC,
hasShadow: true,
alwaysOnTop: true,
type: IS_MAC ? 'panel' : undefined,
hiddenInMissionControl: IS_MAC,
show: false,
backgroundColor: '#00000000',
webPreferences: {
preload: PRELOAD_PATH,
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
devTools: true
}
})
win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver')
win.setHiddenInMissionControl?.(true)
try {
win.setVisibleOnAllWorkspaces(
true,
IS_MAC ? { visibleOnFullScreen: true, skipTransformProcessType: true } : undefined
)
} catch {
// Not supported everywhere — best effort.
}
// Opts out of global UI zoom for the same reason as the pet overlay: it sizes
// its own OS window and a zoomed composer would overflow it.
wireCommonWindowHandlers(win, zoomWiringForWindowKind('quickEntry'))
// Hide on blur. The window must never hold the user's focus captive — losing
// focus is the cheapest, least surprising dismiss (matches Spotlight).
win.on('blur', () => {
if (!win.isDestroyed()) {
win.hide()
}
})
win.on('closed', () => {
if (quickEntryWindow === win) {
quickEntryWindow = null
}
})
win.loadURL(quickEntryUrl())
return win
}
// Move the (already-open) window to the display the cursor is on, so the chord
// summons it where the user is looking rather than where they last were.
function repositionQuickEntryWindow(win) {
try {
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
win.setBounds(quickEntryWindowBounds(display?.workArea))
} catch (error) {
rememberLog(`[quick-entry] reposition failed: ${error.message}`)
}
}
function showQuickEntryWindow() {
if (!quickEntryWindow || quickEntryWindow.isDestroyed()) {
quickEntryWindow = spawnQuickEntryWindow()
quickEntryWindow.once('ready-to-show', () => {
if (!quickEntryWindow?.isDestroyed()) {
quickEntryWindow.show()
quickEntryWindow.focus()
}
})
return
}
repositionQuickEntryWindow(quickEntryWindow)
quickEntryWindow.show()
quickEntryWindow.focus()
// Re-summoned: tell the renderer to clear any stale draft and refocus.
quickEntryWindow.webContents.send('hermes:quick-entry:shown')
}
function hideQuickEntryWindow() {
if (quickEntryWindow && !quickEntryWindow.isDestroyed()) {
quickEntryWindow.hide()
}
}
// The chord toggles: pressing it while the composer is up puts it away, so one
// gesture does exactly one thing in both directions.
function toggleQuickEntryWindow() {
if (quickEntryWindow && !quickEntryWindow.isDestroyed() && quickEntryWindow.isVisible()) {
hideQuickEntryWindow()
return
}
showQuickEntryWindow()
}
const quickEntryShortcut = createQuickEntryShortcut(globalShortcut, toggleQuickEntryWindow)
function applyQuickEntrySettings(settings) {
const state = quickEntryShortcut.apply(settings)
if (!settings.enabled) {
// Turning the feature off must not leave an orphan always-on-top window.
if (quickEntryWindow && !quickEntryWindow.isDestroyed()) {
quickEntryWindow.close()
}
quickEntryWindow = null
}
if (state.error === 'taken') {
rememberLog(`[quick-entry] shortcut ${state.shortcut} is already taken by another application`)
} else if (state.error === 'invalid') {
rememberLog(`[quick-entry] shortcut ${state.shortcut} is not a valid accelerator`)
}
return { ...state, enabled: settings.enabled }
}
function closeQuickEntryWindow() {
quickEntryShortcut.dispose()
if (quickEntryWindow && !quickEntryWindow.isDestroyed()) {
quickEntryWindow.close()
}
quickEntryWindow = null
}
function createWindow() {
const icon = getAppIconPath()
const savedWindowState = readWindowState()
@ -9954,6 +10148,61 @@ ipcMain.on('hermes:keep-awake', (_event, on) => {
}
})
// Quick Entry: the renderer reads the live registration state on settings mount
// and writes the preference back. Main is authoritative — it owns the OS
// accelerator — so both handlers return the state that ACTUALLY resulted,
// including `registered: false` + `error: 'taken'` when another app owns the
// chord. See electron/quick-entry.ts + store/quick-entry.
ipcMain.handle('hermes:quick-entry:settings:get', async () => {
const settings = readQuickEntrySettings()
const state = quickEntryShortcut.current()
// Ground truth is what the last apply produced; the shortcut we report is the
// live one (a saved-but-rejected chord still shows what the user asked for).
return {
enabled: settings.enabled,
error: state.error,
registered: state.registered,
shortcut: settings.enabled ? state.shortcut : settings.shortcut
}
})
ipcMain.handle('hermes:quick-entry:settings:set', async (_event, patch) => {
const current = readQuickEntrySettings()
const next = sanitizeQuickEntrySettings({
enabled: patch?.enabled === undefined ? current.enabled : patch.enabled === true,
shortcut: typeof patch?.shortcut === 'string' && patch.shortcut.trim() ? patch.shortcut : current.shortcut
})
writeQuickEntrySettings(next)
return applyQuickEntrySettings(next)
})
// Quick window → main → PRIMARY renderer. We never submit here: the renderer
// owns the one prompt-submit path, and forwarding keeps it that way.
ipcMain.on('hermes:quick-entry:submit', (_event, text) => {
hideQuickEntryWindow()
const prompt = typeof text === 'string' ? text.trim() : ''
if (!prompt) {
return
}
if (!mainWindow || mainWindow.isDestroyed()) {
rememberLog('[quick-entry] dropped a submit: no primary window to route it to')
return
}
// Deliberately does NOT raise/focus the main window — the user asked to fire
// a prompt from wherever they were, not to be yanked into the app.
mainWindow.webContents.send('hermes:quick-entry:submit', prompt)
})
ipcMain.on('hermes:quick-entry:dismiss', () => hideQuickEntryWindow())
ipcMain.handle('hermes:openExternal', (_event, url) => {
if (!openExternalUrl(url)) {
throw new Error('Invalid external URL')
@ -11025,6 +11274,10 @@ app.whenReady().then(() => {
configureSpellChecker()
registerPowerResumeListeners()
keepAwake.set(readPersistedKeepAwake())
// Quick Entry's global chord — registered on ready so a cold launch restores
// it without the renderer visiting Settings. A failed registration is logged
// here and surfaced in Settings via the IPC state (never silent).
applyQuickEntrySettings(readQuickEntrySettings())
createWindow()
// Win/Linux cold start: the launching hermes:// URL is in our own argv.
@ -11103,6 +11356,10 @@ app.on('before-quit', event => {
// pet can't keep the process alive or float over a quit app.
closePetOverlay()
// Same for the Quick Entry composer — and release its global accelerator so a
// quitting Hermes never keeps another app's chord hostage.
closeQuickEntryWindow()
// Quitting mid-install should stop the installer, not orphan it.
if (bootstrapAbortController) {
try {

View file

@ -36,6 +36,30 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener)
}
},
// Quick Entry: the global-hotkey mini composer window. Main owns the OS
// shortcut + the persisted preference; the quick window only captures text
// and hands it back, and the primary renderer submits it through the normal
// prompt path.
quickEntry: {
getSettings: () => ipcRenderer.invoke('hermes:quick-entry:settings:get'),
setSettings: patch => ipcRenderer.invoke('hermes:quick-entry:settings:set', patch),
submit: text => ipcRenderer.send('hermes:quick-entry:submit', text),
dismiss: () => ipcRenderer.send('hermes:quick-entry:dismiss'),
// Main → primary renderer: text captured by the quick window.
onSubmit: callback => {
const listener = (_event, text) => callback(text)
ipcRenderer.on('hermes:quick-entry:submit', listener)
return () => ipcRenderer.removeListener('hermes:quick-entry:submit', listener)
},
// Main → quick window: you were just summoned (reset draft + refocus).
onShown: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:quick-entry:shown', listener)
return () => ipcRenderer.removeListener('hermes:quick-entry:shown', listener)
}
},
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),

View file

@ -0,0 +1,240 @@
import { describe, expect, it, vi } from 'vitest'
import {
createQuickEntryShortcut,
DEFAULT_QUICK_ENTRY_SHORTCUT,
type GlobalShortcutLike,
parseQuickEntryShortcut,
quickEntryWindowBounds,
sanitizeQuickEntrySettings
} from './quick-entry'
function fakeGlobalShortcut(options: { register?: boolean; taken?: string[] } = {}) {
const held = new Set(options.taken ?? [])
const globalShortcut: GlobalShortcutLike = {
isRegistered: vi.fn((accelerator: string) => held.has(accelerator)),
register: vi.fn((accelerator: string) => {
if (options.register === false) {
return false
}
held.add(accelerator)
return true
}),
unregister: vi.fn((accelerator: string) => void held.delete(accelerator))
}
return { globalShortcut, held }
}
describe('parseQuickEntryShortcut', () => {
it('normalizes casing, aliases, and modifier order', () => {
expect(parseQuickEntryShortcut('cmdorctrl+shift+space')).toEqual({
accelerator: 'CommandOrControl+Shift+Space',
ok: true
})
expect(parseQuickEntryShortcut(' Shift + CTRL + k ')).toEqual({ accelerator: 'Control+Shift+K', ok: true })
expect(parseQuickEntryShortcut('Alt+f5')).toEqual({ accelerator: 'Alt+F5', ok: true })
expect(parseQuickEntryShortcut('Meta+/')).toEqual({ accelerator: 'Super+/', ok: true })
})
it('collapses duplicate modifiers', () => {
expect(parseQuickEntryShortcut('Ctrl+Control+Shift+J')).toEqual({ accelerator: 'Control+Shift+J', ok: true })
})
it('requires a modifier so a global bind cannot swallow a bare key', () => {
expect(parseQuickEntryShortcut('K')).toEqual({ ok: false, reason: 'no-modifier' })
expect(parseQuickEntryShortcut('Space')).toEqual({ ok: false, reason: 'no-modifier' })
})
it('requires exactly one non-modifier key', () => {
expect(parseQuickEntryShortcut('Shift+Control')).toEqual({ ok: false, reason: 'no-key' })
expect(parseQuickEntryShortcut('Shift+A+B')).toEqual({ ok: false, reason: 'invalid-key' })
expect(parseQuickEntryShortcut('A+Shift')).toEqual({ ok: false, reason: 'invalid-modifier' })
})
it('rejects empty, junk, and the reserved Escape key', () => {
expect(parseQuickEntryShortcut('')).toEqual({ ok: false, reason: 'empty' })
expect(parseQuickEntryShortcut(' ')).toEqual({ ok: false, reason: 'empty' })
expect(parseQuickEntryShortcut(null)).toEqual({ ok: false, reason: 'empty' })
expect(parseQuickEntryShortcut('Ctrl+NotAKey')).toEqual({ ok: false, reason: 'invalid-key' })
// Escape hides the window; binding it globally would make it un-toggleable.
expect(parseQuickEntryShortcut('Ctrl+Escape')).toEqual({ ok: false, reason: 'reserved' })
})
it('accepts the shipped default unchanged', () => {
expect(parseQuickEntryShortcut(DEFAULT_QUICK_ENTRY_SHORTCUT)).toEqual({
accelerator: DEFAULT_QUICK_ENTRY_SHORTCUT,
ok: true
})
})
})
describe('sanitizeQuickEntrySettings', () => {
it('defaults to enabled with the default shortcut', () => {
expect(sanitizeQuickEntrySettings(undefined)).toEqual({ enabled: true, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT })
expect(sanitizeQuickEntrySettings('not an object')).toEqual({
enabled: true,
shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT
})
})
it('keeps an explicit disable and normalizes a stored shortcut', () => {
expect(sanitizeQuickEntrySettings({ enabled: false, shortcut: 'alt+j' })).toEqual({
enabled: false,
shortcut: 'Alt+J'
})
})
it('falls back to the default when the stored shortcut is unusable', () => {
expect(sanitizeQuickEntrySettings({ enabled: true, shortcut: 'Q' })).toEqual({
enabled: true,
shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT
})
})
it('treats a non-boolean enabled as off (only `true` opts in once present)', () => {
expect(sanitizeQuickEntrySettings({ enabled: 'yes' }).enabled).toBe(false)
})
})
describe('createQuickEntryShortcut', () => {
it('registers the normalized accelerator when enabled', () => {
const { globalShortcut } = fakeGlobalShortcut()
const onTrigger = vi.fn()
const controller = createQuickEntryShortcut(globalShortcut, onTrigger)
const state = controller.apply({ enabled: true, shortcut: 'cmdorctrl+shift+space' })
expect(state).toEqual({ error: null, registered: true, shortcut: 'CommandOrControl+Shift+Space' })
expect(globalShortcut.register).toHaveBeenCalledWith('CommandOrControl+Shift+Space', onTrigger)
expect(controller.current()).toEqual(state)
})
it('never registers while the setting is disabled', () => {
const { globalShortcut } = fakeGlobalShortcut()
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
const state = controller.apply({ enabled: false, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT })
expect(globalShortcut.register).not.toHaveBeenCalled()
expect(state).toEqual({ error: null, registered: false, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT })
})
it('releases the old accelerator before registering a new one', () => {
const { globalShortcut, held } = fakeGlobalShortcut()
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
controller.apply({ enabled: true, shortcut: 'Alt+J' })
controller.apply({ enabled: true, shortcut: 'Alt+K' })
expect(globalShortcut.unregister).toHaveBeenCalledWith('Alt+J')
expect(held.has('Alt+J')).toBe(false)
expect(held.has('Alt+K')).toBe(true)
})
it('turning the feature off releases the live accelerator', () => {
const { globalShortcut, held } = fakeGlobalShortcut()
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
controller.apply({ enabled: true, shortcut: 'Alt+J' })
const off = controller.apply({ enabled: false, shortcut: 'Alt+J' })
expect(globalShortcut.unregister).toHaveBeenCalledWith('Alt+J')
expect(held.size).toBe(0)
expect(off.registered).toBe(false)
expect(off.error).toBeNull()
})
it("surfaces 'taken' when another app already owns the chord", () => {
const { globalShortcut } = fakeGlobalShortcut({ taken: ['Alt+J'] })
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
const state = controller.apply({ enabled: true, shortcut: 'alt+j' })
expect(globalShortcut.register).not.toHaveBeenCalled()
expect(state).toEqual({ error: 'taken', registered: false, shortcut: 'Alt+J' })
})
it("surfaces 'taken' when the OS refuses the registration", () => {
const { globalShortcut } = fakeGlobalShortcut({ register: false })
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
expect(controller.apply({ enabled: true, shortcut: 'Alt+J' })).toEqual({
error: 'taken',
registered: false,
shortcut: 'Alt+J'
})
})
it("surfaces 'invalid' for an unusable shortcut without asking the OS", () => {
const { globalShortcut } = fakeGlobalShortcut()
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
expect(controller.apply({ enabled: true, shortcut: 'J' })).toEqual({
error: 'invalid',
registered: false,
shortcut: 'J'
})
expect(globalShortcut.register).not.toHaveBeenCalled()
})
it('survives a throwing globalShortcut', () => {
const globalShortcut: GlobalShortcutLike = {
isRegistered: () => false,
register: () => {
throw new Error('x11 grab failed')
},
unregister: () => {}
}
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
expect(controller.apply({ enabled: true, shortcut: 'Alt+J' }).error).toBe('taken')
})
it('dispose releases the accelerator and is idempotent', () => {
const { globalShortcut, held } = fakeGlobalShortcut()
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
controller.apply({ enabled: true, shortcut: 'Alt+J' })
controller.dispose()
controller.dispose()
expect(globalShortcut.unregister).toHaveBeenCalledTimes(1)
expect(held.size).toBe(0)
expect(controller.current().registered).toBe(false)
})
})
describe('quickEntryWindowBounds', () => {
it('centers horizontally and sits below the top edge of the work area', () => {
const bounds = quickEntryWindowBounds({ height: 1000, width: 1600, x: 0, y: 0 })
expect(bounds.width).toBe(640)
expect(bounds.x).toBe((1600 - 640) / 2)
expect(bounds.y).toBeGreaterThan(0)
expect(bounds.y + bounds.height).toBeLessThanOrEqual(1000)
})
it('respects a display origin offset (second monitor)', () => {
const bounds = quickEntryWindowBounds({ height: 900, width: 1440, x: 1600, y: -200 })
expect(bounds.x).toBe(1600 + (1440 - 640) / 2)
expect(bounds.y).toBeGreaterThanOrEqual(-200)
})
it('stays inside a tiny work area', () => {
const bounds = quickEntryWindowBounds({ height: 120, width: 320, x: 0, y: 0 })
expect(bounds.width).toBeLessThanOrEqual(320)
expect(bounds.height).toBeLessThanOrEqual(120)
expect(bounds.y + bounds.height).toBeLessThanOrEqual(120)
})
it('falls back to the origin without a work area', () => {
expect(quickEntryWindowBounds()).toEqual({ height: 132, width: 640, x: 0, y: 0 })
})
})

View file

@ -0,0 +1,426 @@
/**
* 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 is the composer's collapsed
// height; the renderer never grows the OS window in v1.
const QUICK_ENTRY_WINDOW_WIDTH = 640
const QUICK_ENTRY_WINDOW_HEIGHT = 132
// 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 }

View file

@ -90,15 +90,16 @@ export function installZoomReassertOnWindowEvents(win, reassert, platform = proc
/**
* Zoom-wiring decision per window kind. Chat windows (main + session) keep
* global UI zoom; the pet overlay opts out because it sizes its own OS window
* to the sprite and inheriting zoom would crop it.
* global UI zoom; the pet overlay and the Quick Entry composer opt out because
* they size their own OS window and inheriting zoom would crop/overflow them.
*
* Extracted so the "pet opts out, everything else opts in" contract is
* Extracted so the "helper windows opt out, everything else opts in" contract is
* unit-testable without booting a BrowserWindow or reading source.
*/
export const ZOOM_WINDOW_CONFIG = {
chat: { zoom: true },
petOverlay: { zoom: false }
petOverlay: { zoom: false },
quickEntry: { zoom: false }
} as const
export function zoomWiringForWindowKind(kind) {

View file

@ -0,0 +1,38 @@
import { useEffect, useRef } from 'react'
import { initQuickEntryBridge, setQuickEntrySubmitHandler } from '@/store/quick-entry'
import { isSecondaryWindow } from '@/store/windows'
interface QuickEntryBridgeParams {
submitText: (text: string) => Promise<unknown> | unknown
}
/**
* Wires the global-hotkey Quick Entry window back into the app: text captured
* there is submitted through THIS window's normal prompt path (`submitText`), so
* there is exactly one submit pipeline and no bespoke gateway RPC.
*
* The handler registers ONCE through a ref tracking the latest callback
* re-registering on identity churn leaves a nulled-handler window that can drop
* a submit (the same bug shape use-pet-bridge guards). Primary window only: a
* secondary session window must not also claim the global capture channel, or
* one keystroke would send N prompts.
*/
export function useQuickEntryBridge({ submitText }: QuickEntryBridgeParams): void {
const submitTextRef = useRef(submitText)
submitTextRef.current = submitText
useEffect(() => {
if (isSecondaryWindow()) {
return
}
setQuickEntrySubmitHandler(text => void submitTextRef.current(text))
const dispose = initQuickEntryBridge()
return () => {
setQuickEntrySubmitHandler(null)
dispose()
}
}, [])
}

View file

@ -108,6 +108,7 @@ import { ContribWiringContext } from './context'
import { useBackgroundSync } from './hooks/use-background-sync'
import { useDesktopIntegrations } from './hooks/use-desktop-integrations'
import { usePetBridge } from './hooks/use-pet-bridge'
import { useQuickEntryBridge } from './hooks/use-quick-entry-bridge'
import { useSessionTileDelegate } from './hooks/use-session-tile-delegate'
import { $restartPreviewServer, useTitlebarToolContributions } from './panes'
import { ChatRoutesSurface, SidebarSurface, StatusbarSurface, TerminalSurface } from './surfaces'
@ -608,6 +609,10 @@ export function ContribWiring({ children }: { children: ReactNode }) {
// The popped-out pet overlay's bridge back into the app.
usePetBridge({ requestGateway, resumeSession, submitText })
// The global-hotkey Quick Entry window's bridge: its captured text rides the
// SAME submitText the normal composer uses.
useQuickEntryBridge({ submitText })
// Clear a failed turn's red error banner. Errors are renderer-local (never
// persisted): a bare error placeholder is dropped entirely; a partial-output
// failure keeps its content and sheds the error. Both the runtime cache AND

View file

@ -0,0 +1,126 @@
import { useEffect, useReducer, useRef } from 'react'
import {
initialQuickComposerState,
type QuickComposerEvent,
quickComposerReducer,
type QuickComposerState
} from '@/store/quick-entry'
/**
* The Quick Entry composer the whole renderer surface of the global-hotkey
* mini window. Deliberately one input and nothing else: this is a capture
* surface, not a second chat.
*
* All behavior rides `quickComposerReducer` (pure, unit-tested): submit sends
* the trimmed text through the shell and asks to hide; an empty submit does
* neither so a stray Enter can't make the window vanish; Escape and losing
* focus dismiss without sending.
*
* The window itself has no gateway connection. Text goes to the main process,
* which forwards it to the primary renderer's normal prompt-submit path.
*/
export function QuickEntryApp() {
const inputRef = useRef<HTMLInputElement>(null)
// The reducer returns { send, state }; this wrapper performs the side effect
// (hand the text to the shell, ask to hide) and stores the next state, so the
// decision stays pure and testable while the effects stay in one place.
const [state, dispatch] = useReducer((current: QuickComposerState, event: QuickComposerEvent) => {
const { send, state: next } = quickComposerReducer(current, event)
const api = window.hermesDesktop?.quickEntry
if (send) {
api?.submit(send)
} else if (!next.visible && current.visible) {
api?.dismiss()
}
return next
}, initialQuickComposerState)
// Re-summoned by the chord: the shell reuses the window, so reset the draft
// and take the keyboard back for a fresh capture.
useEffect(() => {
const off = window.hermesDesktop?.quickEntry?.onShown(() => {
dispatch({ type: 'shown' })
requestAnimationFrame(() => inputRef.current?.focus())
})
inputRef.current?.focus()
return off
}, [])
return (
<div
style={{
alignItems: 'center',
background: 'transparent',
display: 'flex',
height: '100vh',
justifyContent: 'center',
padding: 12,
width: '100vw'
}}
>
<div
style={{
alignItems: 'center',
background: 'var(--ui-bg-elevated, var(--background))',
border: '1px solid var(--ui-stroke-secondary, rgba(127,127,127,0.35))',
borderRadius: 12,
boxShadow: '0 18px 48px rgba(0,0,0,0.38)',
display: 'flex',
gap: 10,
padding: '10px 14px',
width: '100%'
}}
>
<span
aria-hidden
style={{
color: 'var(--muted-foreground, #8a8a8a)',
flexShrink: 0,
fontSize: 15,
lineHeight: 1,
userSelect: 'none'
}}
>
</span>
<input
aria-label="Quick Entry"
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
onBlur={() => dispatch({ type: 'blur' })}
onChange={event => dispatch({ draft: event.target.value, type: 'edit' })}
onKeyDown={event => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
dispatch({ type: 'submit' })
} else if (event.key === 'Escape') {
event.preventDefault()
dispatch({ type: 'dismiss' })
}
}}
placeholder="Ask Hermes…"
ref={inputRef}
spellCheck={false}
style={{
background: 'transparent',
border: 'none',
color: 'var(--foreground, #eee)',
flex: 1,
fontFamily: 'inherit',
fontSize: 15,
minWidth: 0,
outline: 'none'
}}
value={state.draft}
/>
</div>
</div>
)
}

View file

@ -0,0 +1,38 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { ErrorBoundary } from '@/components/error-boundary'
import { ThemeProvider } from '@/themes/context'
import { QuickEntryApp } from './quick-entry-app'
/**
* Boot the Quick Entry window. Loaded by the same bundle as the main app but via
* `?win=quick`, so it shares CSS/theme tokens while mounting a minimal capture
* surface (no app shell, no gateway, no router).
*
* The index.html boot script paints an OPAQUE themed background to avoid a flash
* in normal windows; this window is a floating card on a transparent backdrop,
* so force the host layers see-through (same trick as the pet overlay).
*/
export function mountQuickEntry(): void {
const style = document.createElement('style')
style.textContent = 'html,body,#root{background:transparent !important;}'
document.head.appendChild(style)
const root = document.getElementById('root')
if (!root) {
return
}
createRoot(root).render(
<StrictMode>
<ErrorBoundary label="quick-entry">
<ThemeProvider>
<QuickEntryApp />
</ThemeProvider>
</ErrorBoundary>
</StrictMode>
)
}

View file

@ -22,6 +22,7 @@ import { MemoryConnect } from './memory/connect'
import { ProviderConfigPanel } from './memory/provider-config-panel'
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
import { EmptyState, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
import { QuickEntrySettings } from './quick-entry-settings'
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
// provider — otherwise every provider's options render at once (the "totally
@ -290,10 +291,19 @@ export function ConfigSettings({
<ModelSettings onMainModelChanged={onMainModelChanged} />
</div>
)}
{/* Device-local desktop pref (not config.yaml) lives here since keeping
the machine awake is a power-user knob. */}
{/* Device-local desktop prefs (not config.yaml) they live here since
keeping the machine awake and the global Quick Entry chord are both
power-user, this-computer-only knobs. */}
{activeSectionId === 'advanced' && (
<ToggleRow checked={keepAwake} description={c.keepAwakeDesc} label={c.keepAwakeTitle} onChange={setKeepAwake} />
<>
<ToggleRow
checked={keepAwake}
description={c.keepAwakeDesc}
label={c.keepAwakeTitle}
onChange={setKeepAwake}
/>
<QuickEntrySettings />
</>
)}
{visibleFields.length === 0 ? (
<EmptyState description={c.emptyDesc} title={c.emptyTitle} />

View file

@ -0,0 +1,103 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import {
$quickEntry,
canUseQuickEntry,
loadQuickEntrySettings,
QUICK_ENTRY_DEFAULT_SHORTCUT,
saveQuickEntrySettings
} from '@/store/quick-entry'
import { ListRow, ToggleRow } from './primitives'
/**
* Quick Entry the global-hotkey mini composer's settings rows.
*
* The MAIN process is authoritative (it owns the OS accelerator), so this reads
* the live registration state on mount and surfaces the failure the feature must
* never swallow: a chord another app already owns comes back `registered: false`
* with `error: 'taken'` and says so, right under the field.
*/
export function QuickEntrySettings() {
const { t } = useI18n()
const q = t.settings.quickEntry
const state = useStore($quickEntry)
// The field is a local draft: the accelerator is only committed on blur/Enter,
// so a half-typed chord ("Alt+") never tears down the live registration.
const [draft, setDraft] = useState<null | string>(null)
useEffect(() => {
void loadQuickEntrySettings()
}, [])
if (!canUseQuickEntry()) {
return null
}
const commit = () => {
const next = (draft ?? '').trim()
setDraft(null)
if (next && next !== state.shortcut) {
void saveQuickEntrySettings({ shortcut: next })
}
}
const status =
state.registered === null
? null
: state.error === 'taken'
? q.takenBy
: state.error === 'invalid'
? q.invalidShortcut
: state.enabled && state.registered
? q.active
: null
return (
<>
<ToggleRow
checked={state.enabled}
description={q.enabledDesc}
label={q.enabledTitle}
onChange={enabled => void saveQuickEntrySettings({ enabled })}
/>
<ListRow
action={
<Input
aria-label={q.shortcutTitle}
disabled={!state.enabled}
onBlur={commit}
onChange={event => setDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
commit()
}
}}
placeholder={QUICK_ENTRY_DEFAULT_SHORTCUT}
value={draft ?? state.shortcut}
/>
}
below={
status && (
<div
className={
state.error
? 'mt-1 text-[length:var(--conversation-caption-font-size)] text-amber-500/90'
: 'mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
}
>
{status}
</div>
)
}
description={q.shortcutDesc}
title={q.shortcutTitle}
/>
</>
)
}

View file

@ -6,6 +6,7 @@ import type {
PetOverlayOpenRequest,
PetOverlayStatePayload
} from './store/pet-overlay'
import type { QuickEntryStatus } from './store/quick-entry'
export {}
@ -55,6 +56,27 @@ declare global {
onState: (callback: (payload: PetOverlayStatePayload) => void) => () => void
onControl: (callback: (payload: PetOverlayControl) => void) => () => void
}
// Quick Entry: a global-hotkey mini composer window. Main owns the OS
// shortcut registration + the persisted preference (it must restore the
// shortcut on a cold launch without the renderer visiting Settings), so
// the renderer reads/writes it here and adopts the authoritative reply.
quickEntry: {
getSettings: () => Promise<QuickEntryStatus>
// Returns the resulting state — including `registered: false` +
// `error: 'taken'` when another app already owns the chord, so a failed
// registration surfaces in Settings instead of failing silently.
setSettings: (patch: { enabled?: boolean; shortcut?: string }) => Promise<QuickEntryStatus>
// Quick window → main: send this text (main forwards it to the primary
// renderer, which submits it through the normal prompt path) and hide.
submit: (text: string) => void
// Quick window → main: hide without sending (Escape / blur).
dismiss: () => void
// Primary renderer subscribes to text captured by the quick window.
onSubmit: (callback: (text: string) => void) => () => void
// Quick window subscribes to "you were just summoned" so it can reset
// its draft and re-focus the input on every open.
onShown: (callback: () => void) => () => void
}
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>

View file

@ -619,6 +619,15 @@ export const ar = defineLocale({
imported: 'تم استيراد الإعدادات',
invalidJson: 'JSON غير صالح'
},
quickEntry: {
enabledTitle: 'الإدخال السريع',
enabledDesc: 'استدعِ محرّرا صغيرا من أي مكان باختصار عام وأرسل طلبا دون فتح Hermes.',
shortcutTitle: 'اختصار الإدخال السريع',
shortcutDesc: 'يحتاج إلى مفتاح تعديل واحد على الأقل، مثل CommandOrControl+Shift+Space.',
active: 'الاختصار مفعّل.',
takenBy: 'يستخدم تطبيق آخر هذا الاختصار — اختر اختصارا مختلفا.',
invalidShortcut: 'ليس اختصارا صالحا. أضف مفتاح تعديل واحدا على الأقل.'
},
credentials: {
pasteKey: 'لصق المفتاح',
pasteLabelKey: label => `لصق مفتاح ${label}`,

View file

@ -551,6 +551,16 @@ export const en: Translations = {
keepAwakeTitle: 'Keep computer awake',
keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.'
},
quickEntry: {
enabledTitle: 'Quick Entry',
enabledDesc:
'Summon a small composer from anywhere with a global shortcut and fire a prompt without opening Hermes.',
shortcutTitle: 'Quick Entry shortcut',
shortcutDesc: 'Needs at least one modifier, e.g. CommandOrControl+Shift+Space.',
active: 'Shortcut is active.',
takenBy: 'Another app already uses this shortcut — pick a different one.',
invalidShortcut: 'Not a valid shortcut. Include at least one modifier key.'
},
credentials: {
pasteKey: 'Paste key',
pasteLabelKey: label => `Paste ${label} key`,

View file

@ -650,6 +650,16 @@ export const ja = defineLocale({
keepAwakeTitle: 'コンピューターをスリープさせない',
keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。'
},
quickEntry: {
enabledTitle: 'クイック入力',
enabledDesc:
'グローバルショートカットで小さな入力欄をどこからでも呼び出し、Hermes を開かずにプロンプトを送信します。',
shortcutTitle: 'クイック入力のショートカット',
shortcutDesc: '修飾キーが 1 つ以上必要です(例: CommandOrControl+Shift+Space。',
active: 'ショートカットは有効です。',
takenBy: 'このショートカットは他のアプリが使用しています。別のものを選んでください。',
invalidShortcut: '有効なショートカットではありません。修飾キーを 1 つ以上含めてください。'
},
credentials: {
pasteKey: 'キーを貼り付け',
pasteLabelKey: label => `${label} キーを貼り付け`,

View file

@ -456,6 +456,15 @@ export interface Translations {
keepAwakeTitle: string
keepAwakeDesc: string
}
quickEntry: {
enabledTitle: string
enabledDesc: string
shortcutTitle: string
shortcutDesc: string
active: string
takenBy: string
invalidShortcut: string
}
credentials: {
pasteKey: string
pasteLabelKey: (label: string) => string

View file

@ -638,6 +638,15 @@ export const zhHant = defineLocale({
keepAwakeTitle: '保持電腦喚醒',
keepAwakeDesc: '阻止本機睡眠,讓長時間或整夜執行持續進行。螢幕仍可變暗。'
},
quickEntry: {
enabledTitle: '快速輸入',
enabledDesc: '用全域快速鍵在任何地方喚出一個小輸入框,無需開啟 Hermes 即可送出提示。',
shortcutTitle: '快速輸入快速鍵',
shortcutDesc: '至少需要一個修飾鍵,例如 CommandOrControl+Shift+Space。',
active: '快速鍵已生效。',
takenBy: '此快速鍵已被其他應用程式占用,請換一個。',
invalidShortcut: '不是有效的快速鍵。請至少包含一個修飾鍵。'
},
credentials: {
pasteKey: '貼上金鑰',
pasteLabelKey: label => `貼上 ${label} 金鑰`,

View file

@ -758,6 +758,15 @@ export const zh: Translations = {
keepAwakeTitle: '保持电脑唤醒',
keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。'
},
quickEntry: {
enabledTitle: '快速输入',
enabledDesc: '用全局快捷键在任何地方唤出一个小输入框,无需打开 Hermes 即可发送提示。',
shortcutTitle: '快速输入快捷键',
shortcutDesc: '至少需要一个修饰键,例如 CommandOrControl+Shift+Space。',
active: '快捷键已生效。',
takenBy: '此快捷键已被其他应用占用,请换一个。',
invalidShortcut: '不是有效的快捷键。请至少包含一个修饰键。'
},
credentials: {
pasteKey: '粘贴密钥',
pasteLabelKey: label => `粘贴 ${label} 密钥`,

View file

@ -32,8 +32,12 @@ if (import.meta.env.MODE !== 'production' || import.meta.env.VITE_PERF_PROBE ===
import('./app/chat/perf-probe')
}
if (new URLSearchParams(window.location.search).get('win') === 'overlay') {
const winParam = new URLSearchParams(window.location.search).get('win')
if (winParam === 'overlay') {
void import('./app/pet-overlay/overlay-root').then(({ mountPetOverlay }) => mountPetOverlay())
} else if (winParam === 'quick') {
void import('./app/quick-entry/quick-entry-root').then(({ mountQuickEntry }) => mountQuickEntry())
} else {
createRoot(document.getElementById('root')!).render(
<StrictMode>

View file

@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import {
initialQuickComposerState,
type QuickComposerEvent,
quickComposerReducer,
type QuickComposerState
} from './quick-entry'
// Drive the reducer like the window does, collecting every send it asked for.
function run(events: QuickComposerEvent[], from: QuickComposerState = initialQuickComposerState) {
let state = from
const sent: string[] = []
for (const event of events) {
const transition = quickComposerReducer(state, event)
state = transition.state
if (transition.send !== null) {
sent.push(transition.send)
}
}
return { sent, state }
}
describe('quickComposerReducer', () => {
it('starts visible with an empty draft', () => {
expect(initialQuickComposerState).toEqual({ draft: '', submitting: false, visible: true })
})
it('submit sends the trimmed draft, clears it, and hides', () => {
const { sent, state } = run([{ draft: ' ship it ', type: 'edit' }, { type: 'submit' }])
expect(sent).toEqual(['ship it'])
expect(state).toEqual({ draft: '', submitting: true, visible: false })
})
it('an empty or whitespace-only submit sends nothing and stays open', () => {
const blank = run([{ type: 'submit' }])
expect(blank.sent).toEqual([])
expect(blank.state.visible).toBe(true)
const spaces = run([{ draft: ' ', type: 'edit' }, { type: 'submit' }])
expect(spaces.sent).toEqual([])
// A stray Enter must not make the window vanish out from under the user.
expect(spaces.state.visible).toBe(true)
expect(spaces.state.draft).toBe(' ')
})
it('a second submit while already submitting cannot double-send', () => {
const { sent, state } = run([{ draft: 'hello', type: 'edit' }, { type: 'submit' }, { type: 'submit' }])
expect(sent).toEqual(['hello'])
expect(state.submitting).toBe(true)
})
it('Escape dismisses without sending and discards the draft', () => {
const { sent, state } = run([{ draft: 'never mind', type: 'edit' }, { type: 'dismiss' }])
expect(sent).toEqual([])
expect(state).toEqual({ draft: '', submitting: false, visible: false })
})
it('blur dismisses without sending', () => {
const { sent, state } = run([{ draft: 'clicked away', type: 'edit' }, { type: 'blur' }])
expect(sent).toEqual([])
expect(state.visible).toBe(false)
expect(state.draft).toBe('')
})
it('the blur that follows a submit does not re-send or resurrect the draft', () => {
const { sent, state } = run([{ draft: 'go', type: 'edit' }, { type: 'submit' }, { type: 'blur' }])
expect(sent).toEqual(['go'])
expect(state).toEqual({ draft: '', submitting: false, visible: false })
})
it('being re-summoned resets to a fresh capture surface', () => {
const afterSubmit = run([{ draft: 'first', type: 'edit' }, { type: 'submit' }]).state
const { sent, state } = run([{ type: 'shown' }], afterSubmit)
expect(sent).toEqual([])
expect(state).toEqual(initialQuickComposerState)
})
it('re-summoning after a dismiss never carries the old draft back', () => {
const dismissed = run([{ draft: 'stale text', type: 'edit' }, { type: 'dismiss' }]).state
const reopened = quickComposerReducer(dismissed, { type: 'shown' }).state
expect(reopened.draft).toBe('')
expect(reopened.visible).toBe(true)
})
it('editing keeps the window open and never sends', () => {
const { sent, state } = run([
{ draft: 'a', type: 'edit' },
{ draft: 'ab', type: 'edit' },
{ draft: 'abc', type: 'edit' }
])
expect(sent).toEqual([])
expect(state).toEqual({ draft: 'abc', submitting: false, visible: true })
})
it('a full summon → type → submit → summon cycle sends exactly once per round', () => {
const first = run([{ draft: 'one', type: 'edit' }, { type: 'submit' }])
const second = run([{ type: 'shown' }, { draft: 'two', type: 'edit' }, { type: 'submit' }], first.state)
expect(first.sent).toEqual(['one'])
expect(second.sent).toEqual(['two'])
})
})

View file

@ -0,0 +1,202 @@
/**
* Quick Entry (renderer side) the mini composer's own state, and the
* primary window's bridge back into the real prompt-submit path.
*
* The quick window carries NO gateway connection: it hands its text to the main
* process, which forwards it to the primary renderer, which sends it through the
* SAME `submitText` the normal composer uses (see
* app/contrib/hooks/use-quick-entry-bridge). There is no second submit path and
* no new gateway RPC.
*
* The device-local preference (enabled + shortcut) is authoritative in the MAIN
* process it owns the OS registration and must restore it on a cold launch
* without the renderer ever visiting Settings. This module treats what the
* bridge returns as the truth and caches it for the settings UI, same authority
* split as keep-awake.
*/
import { atom } from 'nanostores'
export interface QuickEntryState {
enabled: boolean
/** null before the first read; the settings row shows a skeleton until then. */
registered: boolean | null
/** Why the OS shortcut isn't live: taken by another app, or unusable. */
error: null | QuickEntryRegistrationError
shortcut: string
}
export type QuickEntryRegistrationError = 'invalid' | 'taken'
export interface QuickEntryStatus {
enabled: boolean
error: null | QuickEntryRegistrationError
registered: boolean
shortcut: string
}
export const QUICK_ENTRY_DEFAULT_SHORTCUT = 'CommandOrControl+Shift+Space'
export const $quickEntry = atom<QuickEntryState>({
enabled: true,
error: null,
registered: null,
shortcut: QUICK_ENTRY_DEFAULT_SHORTCUT
})
function applyStatus(status: QuickEntryStatus | undefined): void {
if (!status) {
return
}
$quickEntry.set({
enabled: status.enabled === true,
error: status.error ?? null,
registered: status.registered === true,
shortcut: typeof status.shortcut === 'string' && status.shortcut ? status.shortcut : QUICK_ENTRY_DEFAULT_SHORTCUT
})
}
/** True when the shell exposes the Quick Entry capability (desktop only). */
export function canUseQuickEntry(): boolean {
return typeof window !== 'undefined' && typeof window.hermesDesktop?.quickEntry?.getSettings === 'function'
}
/** Read the live registration state into the store (Settings mount). */
export async function loadQuickEntrySettings(): Promise<void> {
if (!canUseQuickEntry()) {
return
}
try {
applyStatus(await window.hermesDesktop.quickEntry.getSettings())
} catch {
// A failed read leaves the store as-is; the row keeps its last known copy.
}
}
/**
* Write a preference and adopt whatever the main process reports back a
* rejected shortcut or an already-taken chord comes back as an error state
* instead of a silently-lost setting.
*/
export async function saveQuickEntrySettings(patch: { enabled?: boolean; shortcut?: string }): Promise<void> {
if (!canUseQuickEntry()) {
return
}
// Optimistic: paint the intent immediately, then let the authoritative reply
// (which knows whether the OS accepted it) get the last word.
const previous = $quickEntry.get()
$quickEntry.set({ ...previous, ...patch, registered: previous.registered })
try {
applyStatus(await window.hermesDesktop.quickEntry.setSettings(patch))
} catch {
$quickEntry.set(previous)
}
}
// ── Quick window submit state machine ───────────────────────────────────────
/**
* The quick window's own composer state. Deliberately a tiny pure reducer: the
* behavior that would actually break a user an empty submit must not send but
* must still not hide the window, a real submit clears the draft AND hides, and
* a double-fire while already submitting must not send twice is the part worth
* proving, and none of it needs React or Electron.
*/
export interface QuickComposerState {
draft: string
/** True between a send and the window actually hiding. Blocks a double-send. */
submitting: boolean
/** Whether the window should be visible. False asks the shell to hide. */
visible: boolean
}
export type QuickComposerEvent =
| { type: 'blur' }
| { type: 'dismiss' }
| { type: 'edit'; draft: string }
| { type: 'shown' }
| { type: 'submit' }
export interface QuickComposerTransition {
/** Text to send through the real prompt-submit path, or null for none. */
send: null | string
state: QuickComposerState
}
export const initialQuickComposerState: QuickComposerState = { draft: '', submitting: false, visible: true }
export function quickComposerReducer(state: QuickComposerState, event: QuickComposerEvent): QuickComposerTransition {
switch (event.type) {
case 'blur':
case 'dismiss': {
// Escape / focus loss discards without sending. A dismiss mid-submit still
// hides — the send already left for the main process.
return { send: null, state: { draft: '', submitting: false, visible: false } }
}
case 'edit': {
return { send: null, state: { ...state, draft: event.draft } }
}
case 'shown': {
// Re-summoned: a fresh capture surface every time, never a stale draft.
return { send: null, state: { ...initialQuickComposerState } }
}
case 'submit': {
const text = state.draft.trim()
// Nothing to send: stay open so the user can type instead of the window
// vanishing on a stray Enter.
if (!text || state.submitting) {
return { send: null, state }
}
return { send: text, state: { draft: '', submitting: true, visible: false } }
}
default: {
return { send: null, state }
}
}
}
// ── Primary-renderer bridge ────────────────────────────────────────────────
let submitHandler: ((text: string) => void) | null = null
let unsubscribeSubmit: (() => void) | null = null
/**
* Register the handler that turns a quick-window submit into a real send. The
* primary window points this at `usePromptActions().submitText`.
*/
export function setQuickEntrySubmitHandler(fn: ((text: string) => void) | null): void {
submitHandler = fn
}
/**
* Wire the quick-window primary-renderer submit channel once. Returns a
* disposer. Idempotent a second call while wired is a no-op.
*/
export function initQuickEntryBridge(): () => void {
const api = typeof window === 'undefined' ? undefined : window.hermesDesktop?.quickEntry
if (!api?.onSubmit || unsubscribeSubmit) {
return () => {}
}
unsubscribeSubmit = api.onSubmit(text => {
if (typeof text === 'string' && text.trim()) {
submitHandler?.(text)
}
})
return () => {
unsubscribeSubmit?.()
unsubscribeSubmit = null
}
}