mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
feat(desktop): Quick Entry — global-hotkey mini composer window
This commit is contained in:
parent
5557b10fb6
commit
aa3121bb34
21 changed files with 1674 additions and 8 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
240
apps/desktop/electron/quick-entry.test.ts
Normal file
240
apps/desktop/electron/quick-entry.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
426
apps/desktop/electron/quick-entry.ts
Normal file
426
apps/desktop/electron/quick-entry.ts
Normal 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 }
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
38
apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts
Normal file
38
apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts
Normal 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()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
126
apps/desktop/src/app/quick-entry/quick-entry-app.tsx
Normal file
126
apps/desktop/src/app/quick-entry/quick-entry-app.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
apps/desktop/src/app/quick-entry/quick-entry-root.tsx
Normal file
38
apps/desktop/src/app/quick-entry/quick-entry-root.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
103
apps/desktop/src/app/settings/quick-entry-settings.tsx
Normal file
103
apps/desktop/src/app/settings/quick-entry-settings.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
22
apps/desktop/src/global.d.ts
vendored
22
apps/desktop/src/global.d.ts
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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} キーを貼り付け`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} 金鑰`,
|
||||
|
|
|
|||
|
|
@ -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} 密钥`,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
114
apps/desktop/src/store/quick-entry.test.ts
Normal file
114
apps/desktop/src/store/quick-entry.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
202
apps/desktop/src/store/quick-entry.ts
Normal file
202
apps/desktop/src/store/quick-entry.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue