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