mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-07 20:28:46 +02:00
feat: add Quick Prompt global hotkey popup for one-off AI questions
Add a system-wide global hotkey (Win+Shift+A / Cmd+Shift+A) that summons a floating, frameless popup window for quick AI interactions from any app. Main process: - GlobalHotkeyService for registering/unregistering system-wide shortcuts - Frameless, transparent, always-on-top BrowserWindow factory - IPC handlers for send, discard, close, continue-in-aryx, cancel - Session event routing from sidecar to quick prompt window - Settings persistence for default model, hotkey, and reasoning effort Renderer (separate lightweight entry): - QuickPromptApp with session state, streaming, keyboard shortcuts - QuickPromptInput with model selector trigger and cancel support - QuickPromptResponse with streamed markdown and thinking blocks - QuickPromptActions (Discard / Close / Continue in Aryx) - ModelSelector dropdown with tier badges and reasoning effort - Glass Command Bar aesthetic with animated gradient border Settings: - Quick Prompt section in SettingsPanel with enable toggle, hotkey display, default model selector, and reasoning effort picker Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -132,6 +132,7 @@ import {
|
||||
} from '@shared/domain/runTimeline';
|
||||
import {
|
||||
createSessionToolingSelection,
|
||||
createDefaultQuickPromptSettings,
|
||||
listApprovalToolNames,
|
||||
normalizeTerminalHeight,
|
||||
normalizeTheme,
|
||||
@@ -140,6 +141,7 @@ import {
|
||||
type AppearanceTheme,
|
||||
type LspProfileDefinition,
|
||||
type McpServerDefinition,
|
||||
type QuickPromptSettings,
|
||||
type SessionToolingSelection,
|
||||
type WorkspaceToolingSettings,
|
||||
normalizeLspProfileDefinition,
|
||||
@@ -830,6 +832,17 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
getQuickPromptSettings(): QuickPromptSettings {
|
||||
return this.workspace?.settings.quickPrompt ?? createDefaultQuickPromptSettings();
|
||||
}
|
||||
|
||||
async setQuickPromptSettings(patch: Partial<QuickPromptSettings>): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const current = workspace.settings.quickPrompt ?? createDefaultQuickPromptSettings();
|
||||
workspace.settings.quickPrompt = { ...current, ...patch };
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async describeTerminal(): Promise<TerminalSnapshot | undefined> {
|
||||
return this.ptyManager.getSnapshot();
|
||||
}
|
||||
|
||||
+28
-3
@@ -4,16 +4,24 @@ import type { BrowserWindow as BrowserWindowType } from 'electron';
|
||||
import { registerIpcHandlers } from '@main/ipc/registerIpcHandlers';
|
||||
import { AryxAppService } from '@main/AryxAppService';
|
||||
import { AutoUpdateService } from '@main/services/autoUpdater';
|
||||
import { GlobalHotkeyService } from '@main/services/globalHotkey';
|
||||
import { createMainWindow } from '@main/windows/createMainWindow';
|
||||
import {
|
||||
createQuickPromptWindow,
|
||||
toggleQuickPromptWindow,
|
||||
} from '@main/windows/createQuickPromptWindow';
|
||||
import { applyTitleBarTheme } from '@main/windows/titleBarTheme';
|
||||
import { SystemTray, setupCloseToTray, showAndFocusWindow } from '@main/services/systemTray';
|
||||
import { createDefaultQuickPromptSettings } from '@shared/domain/tooling';
|
||||
|
||||
const { app, BrowserWindow } = electron;
|
||||
|
||||
let mainWindow: BrowserWindowType | undefined;
|
||||
let quickPromptWindow: BrowserWindowType | undefined;
|
||||
let appService: AryxAppService | undefined;
|
||||
let systemTray: SystemTray | undefined;
|
||||
let autoUpdateService: AutoUpdateService | undefined;
|
||||
let globalHotkeyService: GlobalHotkeyService | undefined;
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
appService = new AryxAppService();
|
||||
@@ -21,13 +29,14 @@ async function bootstrap(): Promise<void> {
|
||||
autoUpdateService = new AutoUpdateService({ isPackaged: app.isPackaged });
|
||||
|
||||
mainWindow = createMainWindow();
|
||||
registerIpcHandlers(mainWindow, appService, autoUpdateService);
|
||||
quickPromptWindow = createQuickPromptWindow();
|
||||
registerIpcHandlers(mainWindow, appService, autoUpdateService, quickPromptWindow);
|
||||
|
||||
// Start workspace loading in parallel — don't block window from showing.
|
||||
// The renderer fetches the workspace via its own IPC call after mount.
|
||||
const workspaceReady = appService.loadWorkspace();
|
||||
|
||||
// Apply theme and set up tray once workspace is available
|
||||
// Apply theme, set up tray, and register global hotkey once workspace is available
|
||||
workspaceReady
|
||||
.then((workspace) => {
|
||||
if (!mainWindow) return;
|
||||
@@ -47,6 +56,19 @@ async function bootstrap(): Promise<void> {
|
||||
appService!.on('workspace-updated', (updatedWorkspace) => {
|
||||
systemTray?.updateRunningCount(updatedWorkspace);
|
||||
});
|
||||
|
||||
// Register global hotkey for Quick Prompt
|
||||
globalHotkeyService = new GlobalHotkeyService();
|
||||
const hotkeySettings = workspace.settings.quickPrompt ?? createDefaultQuickPromptSettings();
|
||||
globalHotkeyService.register(hotkeySettings, () => {
|
||||
if (quickPromptWindow) toggleQuickPromptWindow(quickPromptWindow);
|
||||
});
|
||||
|
||||
// Re-register hotkey when settings change
|
||||
appService!.on('workspace-updated', (updatedWorkspace) => {
|
||||
const updatedSettings = updatedWorkspace.settings.quickPrompt ?? createDefaultQuickPromptSettings();
|
||||
globalHotkeyService?.update(updatedSettings);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[aryx bootstrap] workspace load failed', error);
|
||||
@@ -72,7 +94,9 @@ app.on('window-all-closed', () => {
|
||||
if (process.platform === 'darwin') return;
|
||||
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
const allHidden = windows.length > 0 && windows.every((w) => !w.isVisible());
|
||||
// Ignore the quick prompt window (it's always hidden, never truly closed)
|
||||
const visibleWindows = windows.filter((w) => w !== quickPromptWindow);
|
||||
const allHidden = visibleWindows.length > 0 && visibleWindows.every((w) => !w.isVisible());
|
||||
if (allHidden) return;
|
||||
|
||||
app.quit();
|
||||
@@ -87,6 +111,7 @@ app.on('activate', async () => {
|
||||
});
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
globalHotkeyService?.dispose();
|
||||
autoUpdateService?.dispose();
|
||||
autoUpdateService = undefined;
|
||||
systemTray?.dispose();
|
||||
|
||||
@@ -51,14 +51,18 @@ import type {
|
||||
UpdateSessionModelConfigInput,
|
||||
UpdateSessionApprovalSettingsInput,
|
||||
UpdateSessionToolingInput,
|
||||
QuickPromptSendInput,
|
||||
} from '@shared/contracts/ipc';
|
||||
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
|
||||
import type { AppearanceTheme } from '@shared/domain/tooling';
|
||||
import type { AppearanceTheme, QuickPromptSettings } from '@shared/domain/tooling';
|
||||
|
||||
import { AryxAppService } from '@main/AryxAppService';
|
||||
import { AutoUpdateService } from '@main/services/autoUpdater';
|
||||
import { createDesktopNotificationHandler } from '@main/services/desktopNotifications';
|
||||
import { applyTitleBarTheme } from '@main/windows/titleBarTheme';
|
||||
import { hideQuickPromptWindow } from '@main/windows/createQuickPromptWindow';
|
||||
import { buildAvailableModelCatalog } from '@shared/domain/models';
|
||||
import { SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
|
||||
import type { UpdateStatus } from '@shared/contracts/ipc';
|
||||
|
||||
const { ipcMain } = electron;
|
||||
@@ -67,6 +71,7 @@ export function registerIpcHandlers(
|
||||
window: BrowserWindow,
|
||||
service: AryxAppService,
|
||||
autoUpdateService: AutoUpdateService,
|
||||
quickPromptWindow?: BrowserWindow,
|
||||
): void {
|
||||
window.on('focus', () => {
|
||||
if (service.isGitAutoRefreshEnabled()) {
|
||||
@@ -348,4 +353,94 @@ export function registerIpcHandlers(
|
||||
service.on('terminal-exit', (info) => {
|
||||
window.webContents.send(ipcChannels.terminalExit, info);
|
||||
});
|
||||
|
||||
// --- Quick Prompt IPC ---
|
||||
|
||||
// Track the active quick prompt session so events can be routed
|
||||
let quickPromptSessionId: string | undefined;
|
||||
|
||||
ipcMain.handle(ipcChannels.quickPromptSend, async (_event, input: QuickPromptSendInput) => {
|
||||
const workspace = await service.loadWorkspace();
|
||||
const workflowId = workspace.selectedWorkflowId ?? workspace.workflows[0]?.id;
|
||||
if (!workflowId) throw new Error('No workflow available');
|
||||
|
||||
const created = await service.createSession(SCRATCHPAD_PROJECT_ID, workflowId);
|
||||
const session = created.sessions[0];
|
||||
if (!session) throw new Error('Failed to create quick prompt session');
|
||||
|
||||
quickPromptSessionId = session.id;
|
||||
|
||||
// Apply model override if provided
|
||||
if (input.model) {
|
||||
await service.updateSessionModelConfig(session.id, input.model, input.reasoningEffort);
|
||||
}
|
||||
|
||||
// Send the message (fire-and-forget — results arrive via session events)
|
||||
void service.sendSessionMessage(session.id, input.content);
|
||||
|
||||
return { sessionId: session.id };
|
||||
});
|
||||
|
||||
ipcMain.handle(ipcChannels.quickPromptCancelTurn, async () => {
|
||||
if (quickPromptSessionId) {
|
||||
await service.cancelSessionTurn(quickPromptSessionId);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(ipcChannels.quickPromptDiscard, async () => {
|
||||
if (quickPromptSessionId) {
|
||||
await service.deleteSession(quickPromptSessionId);
|
||||
quickPromptSessionId = undefined;
|
||||
}
|
||||
if (quickPromptWindow) hideQuickPromptWindow(quickPromptWindow);
|
||||
});
|
||||
|
||||
ipcMain.handle(ipcChannels.quickPromptClose, async () => {
|
||||
quickPromptSessionId = undefined;
|
||||
if (quickPromptWindow) hideQuickPromptWindow(quickPromptWindow);
|
||||
});
|
||||
|
||||
ipcMain.handle(ipcChannels.quickPromptContinueInAryx, async () => {
|
||||
if (quickPromptSessionId) {
|
||||
await service.selectSession(quickPromptSessionId);
|
||||
quickPromptSessionId = undefined;
|
||||
}
|
||||
if (quickPromptWindow) hideQuickPromptWindow(quickPromptWindow);
|
||||
// Show and focus the main window
|
||||
if (!window.isDestroyed()) {
|
||||
if (window.isMinimized()) window.restore();
|
||||
window.show();
|
||||
window.focus();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(ipcChannels.quickPromptGetCapabilities, async () => {
|
||||
const capabilities = await service.describeSidecarCapabilities();
|
||||
const settings = service.getQuickPromptSettings();
|
||||
const models = buildAvailableModelCatalog(capabilities.models);
|
||||
return {
|
||||
models,
|
||||
defaultModel: settings.defaultModel,
|
||||
defaultReasoningEffort: settings.defaultReasoningEffort,
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
ipcChannels.quickPromptSetSettings,
|
||||
(_event, settings: Partial<QuickPromptSettings>) => service.setQuickPromptSettings(settings),
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
ipcChannels.quickPromptGetSettings,
|
||||
() => service.getQuickPromptSettings(),
|
||||
);
|
||||
|
||||
// Route session events to the quick prompt window
|
||||
if (quickPromptWindow) {
|
||||
service.on('session-event', (event) => {
|
||||
if (event.sessionId === quickPromptSessionId && !quickPromptWindow.isDestroyed()) {
|
||||
quickPromptWindow.webContents.send(ipcChannels.quickPromptSessionEvent, event);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import electron from 'electron';
|
||||
|
||||
import type { QuickPromptSettings } from '@shared/domain/tooling';
|
||||
|
||||
const { globalShortcut } = electron;
|
||||
|
||||
export class GlobalHotkeyService {
|
||||
private currentAccelerator: string | undefined;
|
||||
private callback: (() => void) | undefined;
|
||||
|
||||
register(settings: QuickPromptSettings, callback: () => void): void {
|
||||
this.callback = callback;
|
||||
|
||||
if (!settings.enabled) {
|
||||
this.unregister();
|
||||
return;
|
||||
}
|
||||
|
||||
const accelerator = toElectronAccelerator(settings.hotkey);
|
||||
if (accelerator === this.currentAccelerator) return;
|
||||
|
||||
this.unregister();
|
||||
const registered = globalShortcut.register(accelerator, callback);
|
||||
|
||||
if (registered) {
|
||||
this.currentAccelerator = accelerator;
|
||||
} else {
|
||||
console.warn(`[globalHotkey] Failed to register accelerator: ${accelerator}`);
|
||||
this.currentAccelerator = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-registers with updated settings (e.g. hotkey string changed). */
|
||||
update(settings: QuickPromptSettings): void {
|
||||
if (!this.callback) return;
|
||||
this.register(settings, this.callback);
|
||||
}
|
||||
|
||||
unregister(): void {
|
||||
if (this.currentAccelerator) {
|
||||
globalShortcut.unregister(this.currentAccelerator);
|
||||
this.currentAccelerator = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.unregister();
|
||||
this.callback = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert our portable hotkey string (e.g. "Super+Shift+A") to an Electron
|
||||
* accelerator string. Electron uses "Super" on all platforms, which maps to
|
||||
* Cmd on macOS and Win on Windows/Linux.
|
||||
*/
|
||||
function toElectronAccelerator(hotkey: string): string {
|
||||
return hotkey;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import electron from 'electron';
|
||||
import type { BrowserWindow as BrowserWindowType } from 'electron';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { resolveWindowIconPath } from '@main/windows/appIcon';
|
||||
|
||||
const { app, BrowserWindow, screen } = electron;
|
||||
|
||||
export function createQuickPromptWindow(): BrowserWindowType {
|
||||
const window = new BrowserWindow({
|
||||
width: 680,
|
||||
height: 72,
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
skipTaskbar: true,
|
||||
alwaysOnTop: true,
|
||||
maximizable: false,
|
||||
minimizable: false,
|
||||
fullscreenable: false,
|
||||
title: 'Aryx Quick Prompt',
|
||||
icon: resolveWindowIconPath({
|
||||
appPath: app.getAppPath(),
|
||||
platform: process.platform,
|
||||
}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/quickprompt.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
const rendererUrl = process.env.ELECTRON_RENDERER_URL;
|
||||
|
||||
if (rendererUrl) {
|
||||
void window.loadURL(`${rendererUrl}/quickprompt.html`);
|
||||
} else {
|
||||
void window.loadFile(join(__dirname, '../../dist/renderer/quickprompt.html'));
|
||||
}
|
||||
|
||||
window.on('blur', () => {
|
||||
if (window.isVisible()) {
|
||||
window.webContents.send('quick-prompt:hide');
|
||||
window.hide();
|
||||
}
|
||||
});
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
export function toggleQuickPromptWindow(window: BrowserWindowType): void {
|
||||
if (window.isVisible()) {
|
||||
window.webContents.send('quick-prompt:hide');
|
||||
window.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
centerOnActiveDisplay(window);
|
||||
window.webContents.send('quick-prompt:show');
|
||||
window.show();
|
||||
window.focus();
|
||||
}
|
||||
|
||||
export function showQuickPromptWindow(window: BrowserWindowType): void {
|
||||
centerOnActiveDisplay(window);
|
||||
window.webContents.send('quick-prompt:show');
|
||||
window.show();
|
||||
window.focus();
|
||||
}
|
||||
|
||||
export function hideQuickPromptWindow(window: BrowserWindowType): void {
|
||||
if (!window.isVisible()) return;
|
||||
window.webContents.send('quick-prompt:hide');
|
||||
window.hide();
|
||||
}
|
||||
|
||||
function centerOnActiveDisplay(window: BrowserWindowType): void {
|
||||
const cursorPoint = screen.getCursorScreenPoint();
|
||||
const activeDisplay = screen.getDisplayNearestPoint(cursorPoint);
|
||||
const { x, y, width, height } = activeDisplay.workArea;
|
||||
|
||||
const [windowWidth] = window.getSize();
|
||||
const windowX = Math.round(x + (width - windowWidth) / 2);
|
||||
// Position in the upper-third of the screen for command-bar feel
|
||||
const windowY = Math.round(y + height * 0.25);
|
||||
|
||||
window.setPosition(windowX, windowY);
|
||||
}
|
||||
Reference in New Issue
Block a user