diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e5a492c..df7f566 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -55,7 +55,7 @@ flowchart LR | --- | --- | --- | --- | | Renderer | Screens, interaction, local view composition, theme application | Filesystem, process spawning, raw Electron access, Copilot runtime | Typed preload API and pushed events | | Preload | Narrow bridge between browser context and Electron IPC | Business logic, persistence, orchestration | `ipcRenderer` / `ipcMain` | -| Main process | Workspace mutation, persistence, git inspection/write operations, run change attribution, commit workflow orchestration, session lifecycle, native window state, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes | +| Main process | Workspace mutation, persistence, git inspection/write operations, run change attribution, commit workflow orchestration, session lifecycle, native window state, global hotkey registration, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes | | Sidecar | Capability discovery, workflow validation, run execution, streaming deltas and activity | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio | | External systems | Git data, Copilot account/model access, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar | @@ -350,7 +350,8 @@ That gives the system: The main process owns desktop concerns such as: -- native window creation +- native window creation (main window and quick prompt overlay) +- global hotkey registration - title bar behavior - background process management - filesystem access @@ -358,13 +359,28 @@ The main process owns desktop concerns such as: This keeps those concerns out of the renderer while still letting the UI feel native. +### Multi-window setup and Quick Prompt + +Aryx runs two `BrowserWindow` instances: + +- the **main window** — the full workspace UI +- the **quick prompt window** — a frameless, transparent, always-on-top overlay for one-off AI questions + +The quick prompt window loads a separate, lightweight renderer entry (`quickprompt.html` / `quickprompt.tsx`) with its own preload script (`preload/quickprompt.ts`). This keeps its bundle small and avoids loading the full workspace renderer. It communicates with the main process through dedicated IPC channels prefixed with `quick-prompt:`. + +A **global hotkey service** (`src/main/services/globalHotkey.ts`) registers a system-wide keyboard shortcut (default `Super+Shift+A`, configurable in settings) using Electron's `globalShortcut` API. Pressing the hotkey toggles the quick prompt window. The service re-registers the shortcut when the configured hotkey changes and unregisters on app quit. + +Quick prompt sessions are real `SessionRecord` instances created on the scratchpad project. The main process routes matching session events from the sidecar to the quick prompt window's `webContents`. After a response completes, the user can discard the session, close the window (preserving the session for later access in the main UI), or continue the conversation in the main window. + +The `window-all-closed` handler excludes the quick prompt window so the app does not stay alive solely because the hidden popup exists. + ## Build and release architecture Aryx ships as an Electron application bundled together with a self-contained .NET sidecar. The build pipeline is organized around three layers: -- building the Electron renderer and main process assets +- building the Electron renderer entries (main workspace and quick prompt) and main process assets - publishing the sidecar for the target runtime - packaging platform artifacts with electron-builder diff --git a/README.md b/README.md index 14b14a5..a7c6ef6 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Aryx is a desktop app that turns GitHub Copilot into a full workspace. Connect r | Message actions | Copy, pin, edit-and-resend, and regenerate individual messages | | Bookmarks | Browse all pinned messages across sessions in one panel (`Ctrl+Shift+B`) | | System tray | Minimize to tray, quick-launch scratchpads, and see running session count | +| Quick Prompt | System-wide hotkey (`Win+Shift+A` / `Cmd+Shift+A`) summons a floating popup for instant AI questions from any app | | Desktop notifications | Native OS alerts when runs complete, fail, or need approval | | Onboarding | First-launch walkthrough, interactive tooltips, and a "try it" quickstart | diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 40c59e8..660f59f 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -20,6 +20,12 @@ export default defineConfig({ preload: { build: { outDir: 'dist-electron/preload', + rollupOptions: { + input: { + index: resolve(__dirname, 'src/preload/index.ts'), + quickprompt: resolve(__dirname, 'src/preload/quickprompt.ts'), + }, + }, }, plugins: [externalizeDepsPlugin()], resolve: { @@ -32,6 +38,12 @@ export default defineConfig({ root: 'src/renderer', build: { outDir: 'dist/renderer', + rollupOptions: { + input: { + index: resolve(__dirname, 'src/renderer/index.html'), + quickprompt: resolve(__dirname, 'src/renderer/quickprompt.html'), + }, + }, }, plugins: [react(), tailwindcss()], resolve: { diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index 3f42542..ab85de8 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -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 { return this.persistAndBroadcast(workspace); } + getQuickPromptSettings(): QuickPromptSettings { + return this.workspace?.settings.quickPrompt ?? createDefaultQuickPromptSettings(); + } + + async setQuickPromptSettings(patch: Partial): Promise { + const workspace = await this.loadWorkspace(); + const current = workspace.settings.quickPrompt ?? createDefaultQuickPromptSettings(); + workspace.settings.quickPrompt = { ...current, ...patch }; + return this.persistAndBroadcast(workspace); + } + async describeTerminal(): Promise { return this.ptyManager.getSnapshot(); } diff --git a/src/main/index.ts b/src/main/index.ts index 47a6758..bff94db 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { appService = new AryxAppService(); @@ -21,13 +29,14 @@ async function bootstrap(): Promise { 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 { 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(); diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index 251bd30..dbac5f9 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -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) => 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); + } + }); + } } diff --git a/src/main/services/globalHotkey.ts b/src/main/services/globalHotkey.ts new file mode 100644 index 0000000..1da525e --- /dev/null +++ b/src/main/services/globalHotkey.ts @@ -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; +} diff --git a/src/main/windows/createQuickPromptWindow.ts b/src/main/windows/createQuickPromptWindow.ts new file mode 100644 index 0000000..8556dcc --- /dev/null +++ b/src/main/windows/createQuickPromptWindow.ts @@ -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); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index fb480b8..e40e244 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -36,6 +36,8 @@ const api: ElectronApi = { setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled), setMinimizeToTray: (enabled) => ipcRenderer.invoke(ipcChannels.setMinimizeToTray, enabled), setGitAutoRefreshEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setGitAutoRefreshEnabled, enabled), + getQuickPromptSettings: () => ipcRenderer.invoke(ipcChannels.quickPromptGetSettings), + setQuickPromptSettings: (settings) => ipcRenderer.invoke(ipcChannels.quickPromptSetSettings, settings), checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates), installUpdate: () => ipcRenderer.invoke(ipcChannels.installUpdate), saveMcpServer: (input) => ipcRenderer.invoke(ipcChannels.saveMcpServer, input), diff --git a/src/preload/quickprompt.ts b/src/preload/quickprompt.ts new file mode 100644 index 0000000..1ab2972 --- /dev/null +++ b/src/preload/quickprompt.ts @@ -0,0 +1,35 @@ +import electron from 'electron'; + +import { ipcChannels } from '@shared/contracts/channels'; +import type { QuickPromptElectronApi } from '@shared/contracts/ipc'; + +const { contextBridge, ipcRenderer } = electron; + +const api: QuickPromptElectronApi = { + send: (input) => ipcRenderer.invoke(ipcChannels.quickPromptSend, input), + discard: () => ipcRenderer.invoke(ipcChannels.quickPromptDiscard), + close: () => ipcRenderer.invoke(ipcChannels.quickPromptClose), + continueInAryx: () => ipcRenderer.invoke(ipcChannels.quickPromptContinueInAryx), + cancelTurn: () => ipcRenderer.invoke(ipcChannels.quickPromptCancelTurn), + getCapabilities: () => ipcRenderer.invoke(ipcChannels.quickPromptGetCapabilities), + setSettings: (settings) => ipcRenderer.invoke(ipcChannels.quickPromptSetSettings, settings), + onSessionEvent: (listener) => { + const handler = (_event: Electron.IpcRendererEvent, sessionEvent: Parameters[0]) => + listener(sessionEvent); + + ipcRenderer.on(ipcChannels.quickPromptSessionEvent, handler); + return () => ipcRenderer.off(ipcChannels.quickPromptSessionEvent, handler); + }, + onShow: (listener) => { + const handler = () => listener(); + ipcRenderer.on(ipcChannels.quickPromptShow, handler); + return () => ipcRenderer.off(ipcChannels.quickPromptShow, handler); + }, + onHide: (listener) => { + const handler = () => listener(); + ipcRenderer.on(ipcChannels.quickPromptHide, handler); + return () => ipcRenderer.off(ipcChannels.quickPromptHide, handler); + }, +}; + +contextBridge.exposeInMainWorld('quickPromptApi', api); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a50c90b..70eb30a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -34,6 +34,7 @@ import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/proje import type { ProjectGitFileReference } from '@shared/domain/project'; import { applySessionModelConfig } from '@shared/domain/session'; import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling'; +import { createDefaultQuickPromptSettings, type QuickPromptSettings } from '@shared/domain/tooling'; import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent'; import type { WorkspaceState } from '@shared/domain/workspace'; import type { UpdateStatus } from '@shared/contracts/ipc'; @@ -59,7 +60,7 @@ const WelcomePane = lazy(() => import('@renderer/components/WelcomePane').then(( const WorkflowPicker = lazy(() => import('@renderer/components/workflow/WorkflowPicker').then((m) => ({ default: m.WorkflowPicker }))); // Re-export type-only imports from lazy modules so they're available without pulling in the bundle -type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting'; +type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'quick-prompt' | 'troubleshooting'; type BottomPanelTab = 'terminal' | 'git'; // Constants duplicated from BottomPanel to avoid importing the full module at startup @@ -180,6 +181,9 @@ export default function App() { // Commit composer state const [commitComposerCtx, setCommitComposerCtx] = useState<{ projectId: string; sessionId: string; runId?: string }>(); + // Quick prompt settings + const [quickPromptSettings, setQuickPromptSettings] = useState(createDefaultQuickPromptSettings); + // Bottom panel state (terminal + git) const [bottomPanelOpen, setBottomPanelOpen] = useState(false); const [bottomPanelTab, setBottomPanelTab] = useState('terminal'); @@ -198,6 +202,11 @@ export default function App() { .then((ws) => !disposed && setWorkspace(ws)) .catch((e) => !disposed && setError(e instanceof Error ? e.message : String(e))); + void api + .getQuickPromptSettings() + .then((s) => !disposed && setQuickPromptSettings(s)) + .catch(() => { /* quick prompt settings unavailable, use defaults */ }); + const offWorkspace = api.onWorkspaceUpdated((ws) => { setWorkspace(ws); setError(undefined); @@ -858,6 +867,12 @@ export default function App() { void api.resolveWorkspaceDiscoveredTooling({ serverIds, resolution }); }} onGetQuota={() => api.getQuota()} + quickPromptSettings={quickPromptSettings} + onSetQuickPromptSettings={(patch) => { + const updated = { ...quickPromptSettings, ...patch }; + setQuickPromptSettings(updated); + void api.setQuickPromptSettings(patch); + }} /> ) : null; diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index cab5034..03efde7 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type ReactNode } from 'react'; -import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, TriangleAlert, UserCircle, Wrench } from 'lucide-react'; +import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, Sparkles, TriangleAlert, UserCircle, Wrench } from 'lucide-react'; import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard'; import { WorkflowEditor } from '@renderer/components/WorkflowEditor'; @@ -21,6 +21,7 @@ import { type AppearanceTheme, type LspProfileDefinition, type McpServerDefinition, + type QuickPromptSettings, type WorkspaceToolingSettings, } from '@shared/domain/tooling'; import { normalizeWorkspaceAgentDefinition, findWorkspaceAgentUsages, type WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent'; @@ -62,9 +63,11 @@ interface SettingsPanelProps { onGetQuota?: () => Promise>; workflowTemplates?: WorkflowTemplateDefinition[]; onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise; + quickPromptSettings?: QuickPromptSettings; + onSetQuickPromptSettings?: (patch: Partial) => void; } -export type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting'; +export type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'quick-prompt' | 'troubleshooting'; interface NavItem { id: SettingsSection; @@ -82,6 +85,7 @@ const navGroups: NavGroup[] = [ label: 'General', items: [ { id: 'appearance', label: 'Appearance', icon: }, + { id: 'quick-prompt', label: 'Quick Prompt', icon: }, ], }, { @@ -149,6 +153,8 @@ export function SettingsPanel({ onGetQuota, workflowTemplates, onCreateWorkflowFromTemplate, + quickPromptSettings, + onSetQuickPromptSettings, }: SettingsPanelProps) { const [activeSection, setActiveSection] = useState(initialSection ?? 'appearance'); const [editingWorkflow, setEditingWorkflow] = useState(null); @@ -380,6 +386,13 @@ export function SettingsPanel({ profiles={toolingSettings.lspProfiles} /> )} + {activeSection === 'quick-prompt' && ( + + )} {activeSection === 'troubleshooting' && ( ); } + +function QuickPromptSettingsSection({ + settings, + availableModels, + onUpdate, +}: { + settings?: QuickPromptSettings; + availableModels: ReadonlyArray; + onUpdate?: (patch: Partial) => void; +}) { + const enabled = settings?.enabled ?? true; + const hotkey = settings?.hotkey ?? 'Super+Shift+A'; + const defaultModel = settings?.defaultModel; + const defaultReasoning = settings?.defaultReasoningEffort; + + const hotkeyDisplay = hotkey + .replace('Super', process.platform === 'darwin' ? '⌘' : 'Win') + .replace('Shift', '⇧') + .replace('+', ' + '); + + return ( +
+
+

Quick Prompt

+

+ Press a global hotkey to ask the AI a quick question from anywhere +

+
+ + {/* Enable / Disable */} + + + {/* Hotkey display */} +
+

Keyboard Shortcut

+

+ The key combination that opens the Quick Prompt overlay +

+
+ +
+
+ {hotkeyDisplay.split(' + ').map((key) => ( + + {key.trim()} + + ))} +
+
+ + {/* Default Model */} +
+

Default Model

+

+ The model used by Quick Prompt sessions. Can be overridden per-prompt. +

+
+ +
+ {/* "Use workflow default" option */} + + + {availableModels.map((model) => { + const isSelected = defaultModel === model.id; + return ( + + ); + })} +
+ + {/* Reasoning Effort */} + {availableModels.some((m) => m.supportedReasoningEfforts?.length) && ( + <> +
+

Reasoning Effort

+

+ Default reasoning effort for Quick Prompt. Higher effort produces more thorough answers. +

+
+ +
+ {([undefined, 'low', 'medium', 'high'] as const).map((effort) => { + const isActive = defaultReasoning === effort; + const label = effort ?? 'Default'; + return ( + + ); + })} +
+ + )} +
+ ); +} diff --git a/src/renderer/components/quick-prompt/ModelSelector.tsx b/src/renderer/components/quick-prompt/ModelSelector.tsx new file mode 100644 index 0000000..25d155e --- /dev/null +++ b/src/renderer/components/quick-prompt/ModelSelector.tsx @@ -0,0 +1,136 @@ +import { useEffect, useRef } from 'react'; +import { Check, Crown, Zap } from 'lucide-react'; + +import type { ModelDefinition } from '@shared/domain/models'; +import type { ReasoningEffort } from '@shared/domain/workflow'; + +interface ModelSelectorProps { + models: ReadonlyArray; + selectedModelId?: string; + selectedReasoning?: ReasoningEffort; + onSelect: (model: ModelDefinition) => void; + onReasoningChange: (effort: ReasoningEffort | undefined) => void; + onClose: () => void; +} + +const tierConfig = { + premium: { label: 'Premium', icon: Crown, className: 'text-amber-400 bg-amber-400/10' }, + standard: { label: 'Standard', icon: Zap, className: 'text-[var(--color-text-accent)] bg-[var(--color-accent-muted)]' }, + fast: { label: 'Fast', icon: Zap, className: 'text-emerald-400 bg-emerald-400/10' }, +} as const; + +const reasoningOptions: { value: ReasoningEffort; label: string }[] = [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'Extra High' }, +]; + +export function ModelSelector({ + models, + selectedModelId, + selectedReasoning, + onSelect, + onReasoningChange, + onClose, +}: ModelSelectorProps) { + const containerRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + onClose(); + } + }; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onClose(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape, true); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape, true); + }; + }, [onClose]); + + const selectedModel = models.find((m) => m.id === selectedModelId); + const supportedReasoningEfforts = selectedModel?.supportedReasoningEfforts; + + return ( +
+ {/* Model list */} +
+ {models.map((model) => { + const isSelected = model.id === selectedModelId; + const tier = model.tier ? tierConfig[model.tier] : undefined; + const TierIcon = tier?.icon; + + return ( + + ); + })} +
+ + {/* Reasoning effort selector */} + {supportedReasoningEfforts && supportedReasoningEfforts.length > 0 && ( +
+

+ Reasoning Effort +

+
+ {reasoningOptions + .filter((opt) => supportedReasoningEfforts.includes(opt.value)) + .map((opt) => { + const isActive = selectedReasoning === opt.value; + return ( + + ); + })} +
+
+ )} +
+ ); +} diff --git a/src/renderer/components/quick-prompt/QuickPromptActions.tsx b/src/renderer/components/quick-prompt/QuickPromptActions.tsx new file mode 100644 index 0000000..6a5ed08 --- /dev/null +++ b/src/renderer/components/quick-prompt/QuickPromptActions.tsx @@ -0,0 +1,47 @@ +import { ArrowRight, Trash2, X } from 'lucide-react'; + +interface QuickPromptActionsProps { + onDiscard: () => void; + onClose: () => void; + onContinueInAryx: () => void; +} + +export function QuickPromptActions({ onDiscard, onClose, onContinueInAryx }: QuickPromptActionsProps) { + return ( +
+ {/* Discard — destructive, muted */} + + + {/* Close — neutral, preserves session */} + + +
+ + {/* Continue in Aryx — primary action */} + +
+ ); +} diff --git a/src/renderer/components/quick-prompt/QuickPromptApp.tsx b/src/renderer/components/quick-prompt/QuickPromptApp.tsx new file mode 100644 index 0000000..6b47cbc --- /dev/null +++ b/src/renderer/components/quick-prompt/QuickPromptApp.tsx @@ -0,0 +1,213 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { QuickPromptElectronApi, QuickPromptCapabilities } from '@shared/contracts/ipc'; +import type { SessionEventRecord } from '@shared/domain/event'; +import type { ReasoningEffort } from '@shared/domain/workflow'; +import type { ModelDefinition } from '@shared/domain/models'; + +import { QuickPromptInput } from '@renderer/components/quick-prompt/QuickPromptInput'; +import { QuickPromptResponse } from '@renderer/components/quick-prompt/QuickPromptResponse'; +import { QuickPromptActions } from '@renderer/components/quick-prompt/QuickPromptActions'; + +declare global { + interface Window { + quickPromptApi: QuickPromptElectronApi; + } +} + +type PromptPhase = 'idle' | 'streaming' | 'complete' | 'error'; + +interface StreamedMessage { + content: string; + thinkingContent: string; + authorName: string; +} + +export function QuickPromptApp() { + const [phase, setPhase] = useState('idle'); + const [response, setResponse] = useState({ content: '', thinkingContent: '', authorName: '' }); + const [errorMessage, setErrorMessage] = useState(); + const [capabilities, setCapabilities] = useState(); + const [selectedModel, setSelectedModel] = useState(); + const [selectedReasoning, setSelectedReasoning] = useState(); + const [visible, setVisible] = useState(false); + + const sessionIdRef = useRef(null); + const api = window.quickPromptApi; + + // Load capabilities on mount + useEffect(() => { + api.getCapabilities().then((caps) => { + setCapabilities(caps); + setSelectedModel(caps.defaultModel); + setSelectedReasoning(caps.defaultReasoningEffort); + }); + }, [api]); + + // Subscribe to show/hide events from main process + useEffect(() => { + const offShow = api.onShow(() => { + setVisible(true); + resetState(); + }); + const offHide = api.onHide(() => setVisible(false)); + return () => { + offShow(); + offHide(); + }; + }, [api]); + + // Subscribe to session events (streaming) + useEffect(() => { + const off = api.onSessionEvent((event: SessionEventRecord) => { + if (event.kind === 'message-delta' && event.contentDelta) { + if (event.messageKind === 'thinking') { + setResponse((prev) => ({ ...prev, thinkingContent: prev.thinkingContent + event.contentDelta! })); + } else { + setResponse((prev) => ({ + ...prev, + content: prev.content + event.contentDelta!, + authorName: event.authorName ?? prev.authorName, + })); + } + setPhase('streaming'); + } else if (event.kind === 'status' && event.status === 'idle') { + setPhase((prev) => (prev === 'streaming' ? 'complete' : prev)); + } else if (event.kind === 'error') { + setErrorMessage(event.error ?? 'An unexpected error occurred.'); + setPhase('error'); + } + }); + return off; + }, [api]); + + const resetState = useCallback(() => { + setPhase('idle'); + setResponse({ content: '', thinkingContent: '', authorName: '' }); + setErrorMessage(undefined); + sessionIdRef.current = null; + // Refresh capabilities in case models changed + api.getCapabilities().then((caps) => { + setCapabilities(caps); + if (!selectedModel) setSelectedModel(caps.defaultModel); + if (!selectedReasoning) setSelectedReasoning(caps.defaultReasoningEffort); + }); + }, [api, selectedModel, selectedReasoning]); + + const handleSend = useCallback(async (content: string) => { + if (!content.trim() || phase === 'streaming') return; + + setPhase('streaming'); + setResponse({ content: '', thinkingContent: '', authorName: '' }); + setErrorMessage(undefined); + + try { + const result = await api.send({ + content, + model: selectedModel, + reasoningEffort: selectedReasoning, + }); + sessionIdRef.current = result.sessionId; + } catch (err) { + setErrorMessage(err instanceof Error ? err.message : 'Failed to send message.'); + setPhase('error'); + } + }, [api, phase, selectedModel, selectedReasoning]); + + const handleCancel = useCallback(() => { + api.cancelTurn(); + setPhase('complete'); + }, [api]); + + const handleDiscard = useCallback(() => { + api.discard(); + resetState(); + }, [api, resetState]); + + const handleClose = useCallback(() => { + api.close(); + resetState(); + }, [api, resetState]); + + const handleContinueInAryx = useCallback(() => { + api.continueInAryx(); + resetState(); + }, [api, resetState]); + + const handleModelChange = useCallback((model: ModelDefinition) => { + setSelectedModel(model.id); + }, []); + + const handleReasoningChange = useCallback((effort: ReasoningEffort | undefined) => { + setSelectedReasoning(effort); + }, []); + + // Global keyboard handler + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + if (phase === 'streaming') { + handleCancel(); + } else if (phase === 'complete' || phase === 'error') { + handleClose(); + } else { + api.close(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [phase, handleCancel, handleClose, api]); + + if (!visible) return null; + + const hasResponse = phase !== 'idle'; + const resolvedModel = capabilities?.models.find((m) => m.id === selectedModel); + + return ( +
+
+ {/* Input area */} + + + {/* Response area — grows dynamically */} + {hasResponse && ( + + )} + + {/* Action bar */} + {(phase === 'complete' || phase === 'error') && ( + + )} +
+
+ ); +} diff --git a/src/renderer/components/quick-prompt/QuickPromptInput.tsx b/src/renderer/components/quick-prompt/QuickPromptInput.tsx new file mode 100644 index 0000000..eb465fe --- /dev/null +++ b/src/renderer/components/quick-prompt/QuickPromptInput.tsx @@ -0,0 +1,133 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ChevronDown, Loader2, Zap } from 'lucide-react'; + +import type { ModelDefinition } from '@shared/domain/models'; +import type { ReasoningEffort } from '@shared/domain/workflow'; + +import { ModelSelector } from '@renderer/components/quick-prompt/ModelSelector'; + +type PromptPhase = 'idle' | 'streaming' | 'complete' | 'error'; + +interface QuickPromptInputProps { + onSend: (content: string) => void; + onCancel: () => void; + phase: PromptPhase; + models?: ReadonlyArray; + selectedModel?: ModelDefinition; + selectedReasoning?: ReasoningEffort; + onModelChange: (model: ModelDefinition) => void; + onReasoningChange: (effort: ReasoningEffort | undefined) => void; +} + +export function QuickPromptInput({ + onSend, + onCancel, + phase, + models, + selectedModel, + selectedReasoning, + onModelChange, + onReasoningChange, +}: QuickPromptInputProps) { + const [value, setValue] = useState(''); + const [modelSelectorOpen, setModelSelectorOpen] = useState(false); + const textareaRef = useRef(null); + + // Auto-focus on mount and when phase resets to idle + useEffect(() => { + if (phase === 'idle') { + textareaRef.current?.focus(); + } + }, [phase]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (phase === 'idle' && value.trim()) { + onSend(value); + } + } + }, + [onSend, value, phase], + ); + + const isDisabled = phase === 'streaming'; + + return ( +
+ {/* Text input row */} +
+ {/* Spark icon */} +
+ {phase === 'streaming' ? ( + + ) : ( + + )} +
+ + {/* Textarea */} +