From aa3121bb340f60fad983d8879ad2a0a160f7a860 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:37:42 -0700 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20Quick=20Entry=20=E2=80=94=20gl?= =?UTF-8?q?obal-hotkey=20mini=20composer=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/electron/main.ts | 257 +++++++++++ apps/desktop/electron/preload.ts | 24 + apps/desktop/electron/quick-entry.test.ts | 240 ++++++++++ apps/desktop/electron/quick-entry.ts | 426 ++++++++++++++++++ apps/desktop/electron/zoom.ts | 9 +- .../contrib/hooks/use-quick-entry-bridge.ts | 38 ++ apps/desktop/src/app/contrib/wiring.tsx | 5 + .../src/app/quick-entry/quick-entry-app.tsx | 126 ++++++ .../src/app/quick-entry/quick-entry-root.tsx | 38 ++ .../src/app/settings/config-settings.tsx | 16 +- .../src/app/settings/quick-entry-settings.tsx | 103 +++++ apps/desktop/src/global.d.ts | 22 + apps/desktop/src/i18n/ar.ts | 9 + apps/desktop/src/i18n/en.ts | 10 + apps/desktop/src/i18n/ja.ts | 10 + apps/desktop/src/i18n/types.ts | 9 + apps/desktop/src/i18n/zh-hant.ts | 9 + apps/desktop/src/i18n/zh.ts | 9 + apps/desktop/src/main.tsx | 6 +- apps/desktop/src/store/quick-entry.test.ts | 114 +++++ apps/desktop/src/store/quick-entry.ts | 202 +++++++++ 21 files changed, 1674 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/electron/quick-entry.test.ts create mode 100644 apps/desktop/electron/quick-entry.ts create mode 100644 apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts create mode 100644 apps/desktop/src/app/quick-entry/quick-entry-app.tsx create mode 100644 apps/desktop/src/app/quick-entry/quick-entry-root.tsx create mode 100644 apps/desktop/src/app/settings/quick-entry-settings.tsx create mode 100644 apps/desktop/src/store/quick-entry.test.ts create mode 100644 apps/desktop/src/store/quick-entry.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 8823ec1ef938..92a487ee890b 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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 { diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 7cb17e5f4ef3..f045f2024e3e 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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), diff --git a/apps/desktop/electron/quick-entry.test.ts b/apps/desktop/electron/quick-entry.test.ts new file mode 100644 index 000000000000..572cae419d28 --- /dev/null +++ b/apps/desktop/electron/quick-entry.test.ts @@ -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 }) + }) +}) diff --git a/apps/desktop/electron/quick-entry.ts b/apps/desktop/electron/quick-entry.ts new file mode 100644 index 000000000000..f436b1e3139f --- /dev/null +++ b/apps/desktop/electron/quick-entry.ts @@ -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() + + 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 = { + 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 = { + 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) : {} + 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 } diff --git a/apps/desktop/electron/zoom.ts b/apps/desktop/electron/zoom.ts index 7d1d80d974ff..e81ffeeb07f7 100644 --- a/apps/desktop/electron/zoom.ts +++ b/apps/desktop/electron/zoom.ts @@ -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) { diff --git a/apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts b/apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts new file mode 100644 index 000000000000..993622984373 --- /dev/null +++ b/apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts @@ -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 +} + +/** + * 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() + } + }, []) +} diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 6c2eff3528ad..37ada98ef82c 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -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 diff --git a/apps/desktop/src/app/quick-entry/quick-entry-app.tsx b/apps/desktop/src/app/quick-entry/quick-entry-app.tsx new file mode 100644 index 000000000000..d45bae84637e --- /dev/null +++ b/apps/desktop/src/app/quick-entry/quick-entry-app.tsx @@ -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(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 ( +
+
+ + › + + 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} + /> +
+
+ ) +} diff --git a/apps/desktop/src/app/quick-entry/quick-entry-root.tsx b/apps/desktop/src/app/quick-entry/quick-entry-root.tsx new file mode 100644 index 000000000000..a45cb926ba64 --- /dev/null +++ b/apps/desktop/src/app/quick-entry/quick-entry-root.tsx @@ -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( + + + + + + + + ) +} diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index c97d5b720090..38747792732d 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -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({ )} - {/* 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' && ( - + <> + + + )} {visibleFields.length === 0 ? ( diff --git a/apps/desktop/src/app/settings/quick-entry-settings.tsx b/apps/desktop/src/app/settings/quick-entry-settings.tsx new file mode 100644 index 000000000000..4e38531d11db --- /dev/null +++ b/apps/desktop/src/app/settings/quick-entry-settings.tsx @@ -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) + + 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 ( + <> + void saveQuickEntrySettings({ enabled })} + /> + setDraft(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault() + commit() + } + }} + placeholder={QUICK_ENTRY_DEFAULT_SHORTCUT} + value={draft ?? state.shortcut} + /> + } + below={ + status && ( +
+ {status} +
+ ) + } + description={q.shortcutDesc} + title={q.shortcutTitle} + /> + + ) +} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 08da0f744b2d..51e8b988a5f7 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 + // 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 + // 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 getConnectionConfig: (profile?: null | string) => Promise saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index b44991014175..fb0c4035a51f 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -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}`, diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 248abf413048..c25d226ea3e6 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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`, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index d37b9ad28186..a1eeea21d1a9 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -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} キーを貼り付け`, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 32459f177631..2ffe5cb9061a 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 32a603b71d8f..dfe9efe69868 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -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} 金鑰`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 26a6ef09067c..d00bb3d11a9d 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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} 密钥`, diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 02cd6dfca36e..969b5c510d93 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -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( diff --git a/apps/desktop/src/store/quick-entry.test.ts b/apps/desktop/src/store/quick-entry.test.ts new file mode 100644 index 000000000000..f8117eb4bd49 --- /dev/null +++ b/apps/desktop/src/store/quick-entry.test.ts @@ -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']) + }) +}) diff --git a/apps/desktop/src/store/quick-entry.ts b/apps/desktop/src/store/quick-entry.ts new file mode 100644 index 000000000000..75bd2fd53d1f --- /dev/null +++ b/apps/desktop/src/store/quick-entry.ts @@ -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({ + 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 { + 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 { + 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 + } +}