mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-27 05:13:58 +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:
+19
-3
@@ -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 |
|
| 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` |
|
| 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 |
|
| 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 |
|
| 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:
|
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
|
- title bar behavior
|
||||||
- background process management
|
- background process management
|
||||||
- filesystem access
|
- 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.
|
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
|
## Build and release architecture
|
||||||
|
|
||||||
Aryx ships as an Electron application bundled together with a self-contained .NET sidecar.
|
Aryx ships as an Electron application bundled together with a self-contained .NET sidecar.
|
||||||
|
|
||||||
The build pipeline is organized around three layers:
|
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
|
- publishing the sidecar for the target runtime
|
||||||
- packaging platform artifacts with electron-builder
|
- packaging platform artifacts with electron-builder
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| Message actions | Copy, pin, edit-and-resend, and regenerate individual messages |
|
||||||
| Bookmarks | Browse all pinned messages across sessions in one panel (`Ctrl+Shift+B`) |
|
| 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 |
|
| 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 |
|
| Desktop notifications | Native OS alerts when runs complete, fail, or need approval |
|
||||||
| Onboarding | First-launch walkthrough, interactive tooltips, and a "try it" quickstart |
|
| Onboarding | First-launch walkthrough, interactive tooltips, and a "try it" quickstart |
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export default defineConfig({
|
|||||||
preload: {
|
preload: {
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist-electron/preload',
|
outDir: 'dist-electron/preload',
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__dirname, 'src/preload/index.ts'),
|
||||||
|
quickprompt: resolve(__dirname, 'src/preload/quickprompt.ts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [externalizeDepsPlugin()],
|
plugins: [externalizeDepsPlugin()],
|
||||||
resolve: {
|
resolve: {
|
||||||
@@ -32,6 +38,12 @@ export default defineConfig({
|
|||||||
root: 'src/renderer',
|
root: 'src/renderer',
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist/renderer',
|
outDir: 'dist/renderer',
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__dirname, 'src/renderer/index.html'),
|
||||||
|
quickprompt: resolve(__dirname, 'src/renderer/quickprompt.html'),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ import {
|
|||||||
} from '@shared/domain/runTimeline';
|
} from '@shared/domain/runTimeline';
|
||||||
import {
|
import {
|
||||||
createSessionToolingSelection,
|
createSessionToolingSelection,
|
||||||
|
createDefaultQuickPromptSettings,
|
||||||
listApprovalToolNames,
|
listApprovalToolNames,
|
||||||
normalizeTerminalHeight,
|
normalizeTerminalHeight,
|
||||||
normalizeTheme,
|
normalizeTheme,
|
||||||
@@ -140,6 +141,7 @@ import {
|
|||||||
type AppearanceTheme,
|
type AppearanceTheme,
|
||||||
type LspProfileDefinition,
|
type LspProfileDefinition,
|
||||||
type McpServerDefinition,
|
type McpServerDefinition,
|
||||||
|
type QuickPromptSettings,
|
||||||
type SessionToolingSelection,
|
type SessionToolingSelection,
|
||||||
type WorkspaceToolingSettings,
|
type WorkspaceToolingSettings,
|
||||||
normalizeLspProfileDefinition,
|
normalizeLspProfileDefinition,
|
||||||
@@ -830,6 +832,17 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return this.persistAndBroadcast(workspace);
|
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> {
|
async describeTerminal(): Promise<TerminalSnapshot | undefined> {
|
||||||
return this.ptyManager.getSnapshot();
|
return this.ptyManager.getSnapshot();
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-3
@@ -4,16 +4,24 @@ import type { BrowserWindow as BrowserWindowType } from 'electron';
|
|||||||
import { registerIpcHandlers } from '@main/ipc/registerIpcHandlers';
|
import { registerIpcHandlers } from '@main/ipc/registerIpcHandlers';
|
||||||
import { AryxAppService } from '@main/AryxAppService';
|
import { AryxAppService } from '@main/AryxAppService';
|
||||||
import { AutoUpdateService } from '@main/services/autoUpdater';
|
import { AutoUpdateService } from '@main/services/autoUpdater';
|
||||||
|
import { GlobalHotkeyService } from '@main/services/globalHotkey';
|
||||||
import { createMainWindow } from '@main/windows/createMainWindow';
|
import { createMainWindow } from '@main/windows/createMainWindow';
|
||||||
|
import {
|
||||||
|
createQuickPromptWindow,
|
||||||
|
toggleQuickPromptWindow,
|
||||||
|
} from '@main/windows/createQuickPromptWindow';
|
||||||
import { applyTitleBarTheme } from '@main/windows/titleBarTheme';
|
import { applyTitleBarTheme } from '@main/windows/titleBarTheme';
|
||||||
import { SystemTray, setupCloseToTray, showAndFocusWindow } from '@main/services/systemTray';
|
import { SystemTray, setupCloseToTray, showAndFocusWindow } from '@main/services/systemTray';
|
||||||
|
import { createDefaultQuickPromptSettings } from '@shared/domain/tooling';
|
||||||
|
|
||||||
const { app, BrowserWindow } = electron;
|
const { app, BrowserWindow } = electron;
|
||||||
|
|
||||||
let mainWindow: BrowserWindowType | undefined;
|
let mainWindow: BrowserWindowType | undefined;
|
||||||
|
let quickPromptWindow: BrowserWindowType | undefined;
|
||||||
let appService: AryxAppService | undefined;
|
let appService: AryxAppService | undefined;
|
||||||
let systemTray: SystemTray | undefined;
|
let systemTray: SystemTray | undefined;
|
||||||
let autoUpdateService: AutoUpdateService | undefined;
|
let autoUpdateService: AutoUpdateService | undefined;
|
||||||
|
let globalHotkeyService: GlobalHotkeyService | undefined;
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
appService = new AryxAppService();
|
appService = new AryxAppService();
|
||||||
@@ -21,13 +29,14 @@ async function bootstrap(): Promise<void> {
|
|||||||
autoUpdateService = new AutoUpdateService({ isPackaged: app.isPackaged });
|
autoUpdateService = new AutoUpdateService({ isPackaged: app.isPackaged });
|
||||||
|
|
||||||
mainWindow = createMainWindow();
|
mainWindow = createMainWindow();
|
||||||
registerIpcHandlers(mainWindow, appService, autoUpdateService);
|
quickPromptWindow = createQuickPromptWindow();
|
||||||
|
registerIpcHandlers(mainWindow, appService, autoUpdateService, quickPromptWindow);
|
||||||
|
|
||||||
// Start workspace loading in parallel — don't block window from showing.
|
// Start workspace loading in parallel — don't block window from showing.
|
||||||
// The renderer fetches the workspace via its own IPC call after mount.
|
// The renderer fetches the workspace via its own IPC call after mount.
|
||||||
const workspaceReady = appService.loadWorkspace();
|
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
|
workspaceReady
|
||||||
.then((workspace) => {
|
.then((workspace) => {
|
||||||
if (!mainWindow) return;
|
if (!mainWindow) return;
|
||||||
@@ -47,6 +56,19 @@ async function bootstrap(): Promise<void> {
|
|||||||
appService!.on('workspace-updated', (updatedWorkspace) => {
|
appService!.on('workspace-updated', (updatedWorkspace) => {
|
||||||
systemTray?.updateRunningCount(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) => {
|
.catch((error) => {
|
||||||
console.error('[aryx bootstrap] workspace load failed', error);
|
console.error('[aryx bootstrap] workspace load failed', error);
|
||||||
@@ -72,7 +94,9 @@ app.on('window-all-closed', () => {
|
|||||||
if (process.platform === 'darwin') return;
|
if (process.platform === 'darwin') return;
|
||||||
|
|
||||||
const windows = BrowserWindow.getAllWindows();
|
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;
|
if (allHidden) return;
|
||||||
|
|
||||||
app.quit();
|
app.quit();
|
||||||
@@ -87,6 +111,7 @@ app.on('activate', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.on('before-quit', async () => {
|
app.on('before-quit', async () => {
|
||||||
|
globalHotkeyService?.dispose();
|
||||||
autoUpdateService?.dispose();
|
autoUpdateService?.dispose();
|
||||||
autoUpdateService = undefined;
|
autoUpdateService = undefined;
|
||||||
systemTray?.dispose();
|
systemTray?.dispose();
|
||||||
|
|||||||
@@ -51,14 +51,18 @@ import type {
|
|||||||
UpdateSessionModelConfigInput,
|
UpdateSessionModelConfigInput,
|
||||||
UpdateSessionApprovalSettingsInput,
|
UpdateSessionApprovalSettingsInput,
|
||||||
UpdateSessionToolingInput,
|
UpdateSessionToolingInput,
|
||||||
|
QuickPromptSendInput,
|
||||||
} from '@shared/contracts/ipc';
|
} from '@shared/contracts/ipc';
|
||||||
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
|
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 { AryxAppService } from '@main/AryxAppService';
|
||||||
import { AutoUpdateService } from '@main/services/autoUpdater';
|
import { AutoUpdateService } from '@main/services/autoUpdater';
|
||||||
import { createDesktopNotificationHandler } from '@main/services/desktopNotifications';
|
import { createDesktopNotificationHandler } from '@main/services/desktopNotifications';
|
||||||
import { applyTitleBarTheme } from '@main/windows/titleBarTheme';
|
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';
|
import type { UpdateStatus } from '@shared/contracts/ipc';
|
||||||
|
|
||||||
const { ipcMain } = electron;
|
const { ipcMain } = electron;
|
||||||
@@ -67,6 +71,7 @@ export function registerIpcHandlers(
|
|||||||
window: BrowserWindow,
|
window: BrowserWindow,
|
||||||
service: AryxAppService,
|
service: AryxAppService,
|
||||||
autoUpdateService: AutoUpdateService,
|
autoUpdateService: AutoUpdateService,
|
||||||
|
quickPromptWindow?: BrowserWindow,
|
||||||
): void {
|
): void {
|
||||||
window.on('focus', () => {
|
window.on('focus', () => {
|
||||||
if (service.isGitAutoRefreshEnabled()) {
|
if (service.isGitAutoRefreshEnabled()) {
|
||||||
@@ -348,4 +353,94 @@ export function registerIpcHandlers(
|
|||||||
service.on('terminal-exit', (info) => {
|
service.on('terminal-exit', (info) => {
|
||||||
window.webContents.send(ipcChannels.terminalExit, 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);
|
||||||
|
}
|
||||||
@@ -36,6 +36,8 @@ const api: ElectronApi = {
|
|||||||
setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled),
|
setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled),
|
||||||
setMinimizeToTray: (enabled) => ipcRenderer.invoke(ipcChannels.setMinimizeToTray, enabled),
|
setMinimizeToTray: (enabled) => ipcRenderer.invoke(ipcChannels.setMinimizeToTray, enabled),
|
||||||
setGitAutoRefreshEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setGitAutoRefreshEnabled, 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),
|
checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates),
|
||||||
installUpdate: () => ipcRenderer.invoke(ipcChannels.installUpdate),
|
installUpdate: () => ipcRenderer.invoke(ipcChannels.installUpdate),
|
||||||
saveMcpServer: (input) => ipcRenderer.invoke(ipcChannels.saveMcpServer, input),
|
saveMcpServer: (input) => ipcRenderer.invoke(ipcChannels.saveMcpServer, input),
|
||||||
|
|||||||
@@ -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<typeof listener>[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);
|
||||||
+16
-1
@@ -34,6 +34,7 @@ import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/proje
|
|||||||
import type { ProjectGitFileReference } from '@shared/domain/project';
|
import type { ProjectGitFileReference } from '@shared/domain/project';
|
||||||
import { applySessionModelConfig } from '@shared/domain/session';
|
import { applySessionModelConfig } from '@shared/domain/session';
|
||||||
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
|
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 { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
import type { UpdateStatus } from '@shared/contracts/ipc';
|
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 })));
|
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
|
// 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';
|
type BottomPanelTab = 'terminal' | 'git';
|
||||||
|
|
||||||
// Constants duplicated from BottomPanel to avoid importing the full module at startup
|
// Constants duplicated from BottomPanel to avoid importing the full module at startup
|
||||||
@@ -180,6 +181,9 @@ export default function App() {
|
|||||||
// Commit composer state
|
// Commit composer state
|
||||||
const [commitComposerCtx, setCommitComposerCtx] = useState<{ projectId: string; sessionId: string; runId?: string }>();
|
const [commitComposerCtx, setCommitComposerCtx] = useState<{ projectId: string; sessionId: string; runId?: string }>();
|
||||||
|
|
||||||
|
// Quick prompt settings
|
||||||
|
const [quickPromptSettings, setQuickPromptSettings] = useState<QuickPromptSettings>(createDefaultQuickPromptSettings);
|
||||||
|
|
||||||
// Bottom panel state (terminal + git)
|
// Bottom panel state (terminal + git)
|
||||||
const [bottomPanelOpen, setBottomPanelOpen] = useState(false);
|
const [bottomPanelOpen, setBottomPanelOpen] = useState(false);
|
||||||
const [bottomPanelTab, setBottomPanelTab] = useState<BottomPanelTab>('terminal');
|
const [bottomPanelTab, setBottomPanelTab] = useState<BottomPanelTab>('terminal');
|
||||||
@@ -198,6 +202,11 @@ export default function App() {
|
|||||||
.then((ws) => !disposed && setWorkspace(ws))
|
.then((ws) => !disposed && setWorkspace(ws))
|
||||||
.catch((e) => !disposed && setError(e instanceof Error ? e.message : String(e)));
|
.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) => {
|
const offWorkspace = api.onWorkspaceUpdated((ws) => {
|
||||||
setWorkspace(ws);
|
setWorkspace(ws);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
@@ -858,6 +867,12 @@ export default function App() {
|
|||||||
void api.resolveWorkspaceDiscoveredTooling({ serverIds, resolution });
|
void api.resolveWorkspaceDiscoveredTooling({ serverIds, resolution });
|
||||||
}}
|
}}
|
||||||
onGetQuota={() => api.getQuota()}
|
onGetQuota={() => api.getQuota()}
|
||||||
|
quickPromptSettings={quickPromptSettings}
|
||||||
|
onSetQuickPromptSettings={(patch) => {
|
||||||
|
const updated = { ...quickPromptSettings, ...patch };
|
||||||
|
setQuickPromptSettings(updated);
|
||||||
|
void api.setQuickPromptSettings(patch);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
) : null;
|
) : null;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react';
|
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 { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
|
||||||
import { WorkflowEditor } from '@renderer/components/WorkflowEditor';
|
import { WorkflowEditor } from '@renderer/components/WorkflowEditor';
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
type AppearanceTheme,
|
type AppearanceTheme,
|
||||||
type LspProfileDefinition,
|
type LspProfileDefinition,
|
||||||
type McpServerDefinition,
|
type McpServerDefinition,
|
||||||
|
type QuickPromptSettings,
|
||||||
type WorkspaceToolingSettings,
|
type WorkspaceToolingSettings,
|
||||||
} from '@shared/domain/tooling';
|
} from '@shared/domain/tooling';
|
||||||
import { normalizeWorkspaceAgentDefinition, findWorkspaceAgentUsages, type WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
import { normalizeWorkspaceAgentDefinition, findWorkspaceAgentUsages, type WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
||||||
@@ -62,9 +63,11 @@ interface SettingsPanelProps {
|
|||||||
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
||||||
workflowTemplates?: WorkflowTemplateDefinition[];
|
workflowTemplates?: WorkflowTemplateDefinition[];
|
||||||
onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise<void>;
|
onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise<void>;
|
||||||
|
quickPromptSettings?: QuickPromptSettings;
|
||||||
|
onSetQuickPromptSettings?: (patch: Partial<QuickPromptSettings>) => 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 {
|
interface NavItem {
|
||||||
id: SettingsSection;
|
id: SettingsSection;
|
||||||
@@ -82,6 +85,7 @@ const navGroups: NavGroup[] = [
|
|||||||
label: 'General',
|
label: 'General',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'appearance', label: 'Appearance', icon: <Palette className="size-3.5" /> },
|
{ id: 'appearance', label: 'Appearance', icon: <Palette className="size-3.5" /> },
|
||||||
|
{ id: 'quick-prompt', label: 'Quick Prompt', icon: <Sparkles className="size-3.5" /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -149,6 +153,8 @@ export function SettingsPanel({
|
|||||||
onGetQuota,
|
onGetQuota,
|
||||||
workflowTemplates,
|
workflowTemplates,
|
||||||
onCreateWorkflowFromTemplate,
|
onCreateWorkflowFromTemplate,
|
||||||
|
quickPromptSettings,
|
||||||
|
onSetQuickPromptSettings,
|
||||||
}: SettingsPanelProps) {
|
}: SettingsPanelProps) {
|
||||||
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
|
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
|
||||||
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDefinition | null>(null);
|
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDefinition | null>(null);
|
||||||
@@ -380,6 +386,13 @@ export function SettingsPanel({
|
|||||||
profiles={toolingSettings.lspProfiles}
|
profiles={toolingSettings.lspProfiles}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeSection === 'quick-prompt' && (
|
||||||
|
<QuickPromptSettingsSection
|
||||||
|
settings={quickPromptSettings}
|
||||||
|
availableModels={availableModels}
|
||||||
|
onUpdate={onSetQuickPromptSettings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{activeSection === 'troubleshooting' && (
|
{activeSection === 'troubleshooting' && (
|
||||||
<TroubleshootingSection
|
<TroubleshootingSection
|
||||||
onOpenAppDataFolder={onOpenAppDataFolder}
|
onOpenAppDataFolder={onOpenAppDataFolder}
|
||||||
@@ -1304,3 +1317,176 @@ function TroubleshootingAction({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function QuickPromptSettingsSection({
|
||||||
|
settings,
|
||||||
|
availableModels,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
settings?: QuickPromptSettings;
|
||||||
|
availableModels: ReadonlyArray<ModelDefinition>;
|
||||||
|
onUpdate?: (patch: Partial<QuickPromptSettings>) => 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 (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1">
|
||||||
|
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Quick Prompt</h3>
|
||||||
|
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
||||||
|
Press a global hotkey to ask the AI a quick question from anywhere
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enable / Disable */}
|
||||||
|
<button
|
||||||
|
className="mt-5 flex w-full items-center justify-between rounded-lg border border-[var(--color-border)] px-4 py-3 text-left transition hover:bg-[var(--color-surface-3)]/40"
|
||||||
|
onClick={() => onUpdate?.({ enabled: !enabled })}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
|
||||||
|
Enable global hotkey
|
||||||
|
</span>
|
||||||
|
<p className="text-[12px] text-[var(--color-text-muted)]">
|
||||||
|
Register a system-wide keyboard shortcut to summon the Quick Prompt overlay
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch enabled={enabled} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Hotkey display */}
|
||||||
|
<div className="mt-6 mb-1">
|
||||||
|
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Keyboard Shortcut</h3>
|
||||||
|
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
||||||
|
The key combination that opens the Quick Prompt overlay
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-3 rounded-lg border border-[var(--color-border)] px-4 py-3">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{hotkeyDisplay.split(' + ').map((key) => (
|
||||||
|
<kbd
|
||||||
|
key={key}
|
||||||
|
className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2 py-0.5 font-mono text-[12px] text-[var(--color-text-secondary)]"
|
||||||
|
>
|
||||||
|
{key.trim()}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Default Model */}
|
||||||
|
<div className="mt-8 mb-1">
|
||||||
|
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Default Model</h3>
|
||||||
|
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
||||||
|
The model used by Quick Prompt sessions. Can be overridden per-prompt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-1.5">
|
||||||
|
{/* "Use workflow default" option */}
|
||||||
|
<button
|
||||||
|
className={`flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left transition-all duration-200 ${
|
||||||
|
!defaultModel
|
||||||
|
? 'border-[var(--color-border-glow)] bg-[var(--color-accent-muted)]'
|
||||||
|
: 'border-[var(--color-border)] hover:bg-[var(--color-surface-3)]/40'
|
||||||
|
}`}
|
||||||
|
onClick={() => onUpdate?.({ defaultModel: undefined })}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex size-4 shrink-0 items-center justify-center rounded-full border-2 transition-all duration-200 ${
|
||||||
|
!defaultModel ? 'border-[var(--color-accent)]' : 'border-[var(--color-border)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!defaultModel && <div className="size-2 rounded-full bg-[var(--color-accent)]" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={`text-[13px] font-medium ${!defaultModel ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'}`}>
|
||||||
|
Use workflow default
|
||||||
|
</span>
|
||||||
|
<p className="text-[12px] text-[var(--color-text-muted)]">
|
||||||
|
Use whichever model is configured in the scratchpad workflow
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{availableModels.map((model) => {
|
||||||
|
const isSelected = defaultModel === model.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left transition-all duration-200 ${
|
||||||
|
isSelected
|
||||||
|
? 'border-[var(--color-border-glow)] bg-[var(--color-accent-muted)]'
|
||||||
|
: 'border-[var(--color-border)] hover:bg-[var(--color-surface-3)]/40'
|
||||||
|
}`}
|
||||||
|
key={model.id}
|
||||||
|
onClick={() => onUpdate?.({ defaultModel: model.id })}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex size-4 shrink-0 items-center justify-center rounded-full border-2 transition-all duration-200 ${
|
||||||
|
isSelected ? 'border-[var(--color-accent)]' : 'border-[var(--color-border)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected && <div className="size-2 rounded-full bg-[var(--color-accent)]" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className={`text-[13px] font-medium ${isSelected ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'}`}>
|
||||||
|
{model.name}
|
||||||
|
</span>
|
||||||
|
{model.tier && (
|
||||||
|
<span className="ml-2 rounded bg-[var(--color-surface-3)] px-1.5 py-px text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{model.tier}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reasoning Effort */}
|
||||||
|
{availableModels.some((m) => m.supportedReasoningEfforts?.length) && (
|
||||||
|
<>
|
||||||
|
<div className="mt-8 mb-1">
|
||||||
|
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Reasoning Effort</h3>
|
||||||
|
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
||||||
|
Default reasoning effort for Quick Prompt. Higher effort produces more thorough answers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
{([undefined, 'low', 'medium', 'high'] as const).map((effort) => {
|
||||||
|
const isActive = defaultReasoning === effort;
|
||||||
|
const label = effort ?? 'Default';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
onClick={() => onUpdate?.({ defaultReasoningEffort: effort })}
|
||||||
|
className={`flex-1 rounded-lg border py-2.5 text-[13px] font-medium transition-all duration-200 ${
|
||||||
|
isActive
|
||||||
|
? 'border-[var(--color-border-glow)] bg-[var(--color-accent-muted)] text-[var(--color-text-primary)]'
|
||||||
|
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-3)]/40'
|
||||||
|
}`}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{label.charAt(0).toUpperCase() + label.slice(1)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<ModelDefinition>;
|
||||||
|
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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="qp-dropdown-enter absolute bottom-0 left-4 right-4 z-10 translate-y-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-xl shadow-black/40"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Select model"
|
||||||
|
>
|
||||||
|
{/* Model list */}
|
||||||
|
<div className="max-h-[240px] overflow-y-auto p-1.5">
|
||||||
|
{models.map((model) => {
|
||||||
|
const isSelected = model.id === selectedModelId;
|
||||||
|
const tier = model.tier ? tierConfig[model.tier] : undefined;
|
||||||
|
const TierIcon = tier?.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={model.id}
|
||||||
|
onClick={() => onSelect(model)}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-primary)]'
|
||||||
|
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-primary)]'
|
||||||
|
}`}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
>
|
||||||
|
<span className="flex-1 text-[12px] font-medium">{model.name}</span>
|
||||||
|
|
||||||
|
{tier && TierIcon && (
|
||||||
|
<span className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium ${tier.className}`}>
|
||||||
|
<TierIcon className="size-2.5" />
|
||||||
|
{tier.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSelected && <Check className="size-3.5 flex-none text-[var(--color-accent)]" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reasoning effort selector */}
|
||||||
|
{supportedReasoningEfforts && supportedReasoningEfforts.length > 0 && (
|
||||||
|
<div className="border-t border-[var(--color-border-subtle)] p-3">
|
||||||
|
<p className="mb-2 text-[10px] font-medium tracking-wide text-[var(--color-text-muted)] uppercase">
|
||||||
|
Reasoning Effort
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{reasoningOptions
|
||||||
|
.filter((opt) => supportedReasoningEfforts.includes(opt.value))
|
||||||
|
.map((opt) => {
|
||||||
|
const isActive = selectedReasoning === opt.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => onReasoningChange(isActive ? undefined : opt.value)}
|
||||||
|
className={`flex-1 rounded-md py-1 text-[11px] font-medium transition ${
|
||||||
|
isActive
|
||||||
|
? 'bg-[var(--color-accent)] text-white'
|
||||||
|
: 'bg-[var(--color-surface-2)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]'
|
||||||
|
}`}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="qp-actions-enter flex items-center gap-2 border-t border-[var(--color-border-subtle)] px-5 py-3">
|
||||||
|
{/* Discard — destructive, muted */}
|
||||||
|
<button
|
||||||
|
onClick={onDiscard}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[12px] font-medium text-[var(--color-text-muted)] transition hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||||
|
type="button"
|
||||||
|
title="Delete this session"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Close — neutral, preserves session */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[12px] font-medium text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||||
|
type="button"
|
||||||
|
title="Close and keep session"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Continue in Aryx — primary action */}
|
||||||
|
<button
|
||||||
|
onClick={onContinueInAryx}
|
||||||
|
className="brand-gradient-bg flex items-center gap-1.5 rounded-lg px-4 py-1.5 text-[12px] font-semibold text-white shadow-md shadow-[var(--color-accent)]/15 transition hover:shadow-lg hover:shadow-[var(--color-accent)]/25"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Continue in Aryx
|
||||||
|
<ArrowRight className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<PromptPhase>('idle');
|
||||||
|
const [response, setResponse] = useState<StreamedMessage>({ content: '', thinkingContent: '', authorName: '' });
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string>();
|
||||||
|
const [capabilities, setCapabilities] = useState<QuickPromptCapabilities>();
|
||||||
|
const [selectedModel, setSelectedModel] = useState<string>();
|
||||||
|
const [selectedReasoning, setSelectedReasoning] = useState<ReasoningEffort>();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
const sessionIdRef = useRef<string | null>(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 (
|
||||||
|
<div className="qp-container flex h-screen w-screen items-start justify-center pt-0">
|
||||||
|
<div
|
||||||
|
className={`qp-panel qp-panel-enter flex w-full max-w-[680px] flex-col overflow-hidden rounded-2xl ${
|
||||||
|
phase === 'streaming' ? 'qp-border-streaming' : hasResponse ? 'qp-border-complete' : 'qp-border-idle'
|
||||||
|
}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Quick Prompt"
|
||||||
|
>
|
||||||
|
{/* Input area */}
|
||||||
|
<QuickPromptInput
|
||||||
|
onSend={handleSend}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
phase={phase}
|
||||||
|
models={capabilities?.models}
|
||||||
|
selectedModel={resolvedModel}
|
||||||
|
selectedReasoning={selectedReasoning}
|
||||||
|
onModelChange={handleModelChange}
|
||||||
|
onReasoningChange={handleReasoningChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Response area — grows dynamically */}
|
||||||
|
{hasResponse && (
|
||||||
|
<QuickPromptResponse
|
||||||
|
content={response.content}
|
||||||
|
thinkingContent={response.thinkingContent}
|
||||||
|
authorName={response.authorName}
|
||||||
|
phase={phase}
|
||||||
|
error={errorMessage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action bar */}
|
||||||
|
{(phase === 'complete' || phase === 'error') && (
|
||||||
|
<QuickPromptActions
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
onClose={handleClose}
|
||||||
|
onContinueInAryx={handleContinueInAryx}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ModelDefinition>;
|
||||||
|
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<HTMLTextAreaElement>(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 (
|
||||||
|
<div className="relative flex flex-col">
|
||||||
|
{/* Text input row */}
|
||||||
|
<div className="flex items-start gap-3 px-5 pt-4 pb-3">
|
||||||
|
{/* Spark icon */}
|
||||||
|
<div className="mt-0.5 flex-none">
|
||||||
|
{phase === 'streaming' ? (
|
||||||
|
<Loader2 className="size-[18px] animate-spin text-[var(--color-accent)]" />
|
||||||
|
) : (
|
||||||
|
<Zap className="size-[18px] text-[var(--color-text-muted)]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Textarea */}
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Ask anything..."
|
||||||
|
disabled={isDisabled}
|
||||||
|
rows={1}
|
||||||
|
className="auto-resize-textarea min-h-[28px] max-h-[120px] flex-1 resize-none bg-transparent font-[var(--font-body)] text-[14px] leading-[1.6] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-muted)] disabled:opacity-40"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Cancel button during streaming */}
|
||||||
|
{phase === 'streaming' && (
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="mt-0.5 flex-none rounded-md px-2.5 py-1 text-[12px] font-medium text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model selector row */}
|
||||||
|
<div className="flex items-center gap-2 border-t border-[var(--color-border-subtle)] px-5 py-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setModelSelectorOpen((prev) => !prev)}
|
||||||
|
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={modelSelectorOpen}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{selectedModel?.name ?? 'Select model'}</span>
|
||||||
|
{selectedReasoning && (
|
||||||
|
<span className="rounded bg-[var(--color-accent-muted)] px-1.5 py-px text-[10px] text-[var(--color-text-accent)]">
|
||||||
|
{selectedReasoning}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown className="size-3" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="ml-auto text-[10px] text-[var(--color-text-muted)] select-none opacity-60">
|
||||||
|
Enter ↵ to send · Esc to dismiss
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model selector dropdown */}
|
||||||
|
{modelSelectorOpen && models && (
|
||||||
|
<ModelSelector
|
||||||
|
models={models}
|
||||||
|
selectedModelId={selectedModel?.id}
|
||||||
|
selectedReasoning={selectedReasoning}
|
||||||
|
onSelect={(model) => {
|
||||||
|
onModelChange(model);
|
||||||
|
setModelSelectorOpen(false);
|
||||||
|
}}
|
||||||
|
onReasoningChange={onReasoningChange}
|
||||||
|
onClose={() => setModelSelectorOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { AlertCircle, Brain } from 'lucide-react';
|
||||||
|
|
||||||
|
type PromptPhase = 'idle' | 'streaming' | 'complete' | 'error';
|
||||||
|
|
||||||
|
interface QuickPromptResponseProps {
|
||||||
|
content: string;
|
||||||
|
thinkingContent: string;
|
||||||
|
authorName: string;
|
||||||
|
phase: PromptPhase;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickPromptResponse({
|
||||||
|
content,
|
||||||
|
thinkingContent,
|
||||||
|
phase,
|
||||||
|
error,
|
||||||
|
}: QuickPromptResponseProps) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom during streaming
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase === 'streaming' && scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [content, thinkingContent, phase]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="qp-response-enter max-h-[min(55vh,520px)] overflow-y-auto border-t border-[var(--color-border-subtle)]"
|
||||||
|
>
|
||||||
|
{/* Error state */}
|
||||||
|
{phase === 'error' && error && (
|
||||||
|
<div className="flex items-start gap-3 px-5 py-4">
|
||||||
|
<AlertCircle className="mt-0.5 size-4 flex-none text-[var(--color-status-error)]" />
|
||||||
|
<p className="text-[13px] leading-relaxed text-[var(--color-status-error)]">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Thinking block — collapsed visualization */}
|
||||||
|
{thinkingContent && (
|
||||||
|
<div className="mx-5 mt-4 mb-2 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-0)]/50 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-[11px] font-medium text-[var(--color-text-muted)]">
|
||||||
|
<Brain className="size-3.5" />
|
||||||
|
<span>Thinking</span>
|
||||||
|
{phase === 'streaming' && !content && (
|
||||||
|
<span className="flex gap-0.5 ml-1">
|
||||||
|
<span className="thinking-dot inline-block size-1 rounded-full bg-[var(--color-text-muted)]" />
|
||||||
|
<span className="thinking-dot inline-block size-1 rounded-full bg-[var(--color-text-muted)]" />
|
||||||
|
<span className="thinking-dot inline-block size-1 rounded-full bg-[var(--color-text-muted)]" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-[12px] leading-relaxed text-[var(--color-text-muted)] line-clamp-3">
|
||||||
|
{thinkingContent.slice(-300)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main response content */}
|
||||||
|
{content && (
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<div className="markdown-content text-[13.5px] text-[var(--color-text-primary)]">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Streaming indicator when no content yet */}
|
||||||
|
{phase === 'streaming' && !content && !thinkingContent && (
|
||||||
|
<div className="flex items-center gap-3 px-5 py-5">
|
||||||
|
<span className="flex gap-1">
|
||||||
|
<span className="thinking-dot inline-block size-1.5 rounded-full bg-[var(--color-accent)]" />
|
||||||
|
<span className="thinking-dot inline-block size-1.5 rounded-full bg-[var(--color-accent)]" />
|
||||||
|
<span className="thinking-dot inline-block size-1.5 rounded-full bg-[var(--color-accent)]" />
|
||||||
|
</span>
|
||||||
|
<span className="text-[12px] text-[var(--color-text-muted)]">Generating response…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0"
|
||||||
|
/>
|
||||||
|
<title>Aryx Quick Prompt</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; background: transparent; overflow: hidden;">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script
|
||||||
|
type="module"
|
||||||
|
src="./quickprompt.tsx"
|
||||||
|
></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
import '@fontsource-variable/outfit';
|
||||||
|
import '@fontsource-variable/dm-sans';
|
||||||
|
import '@fontsource-variable/jetbrains-mono';
|
||||||
|
|
||||||
|
import { QuickPromptApp } from '@renderer/components/quick-prompt/QuickPromptApp';
|
||||||
|
import '@renderer/styles.css';
|
||||||
|
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (!container) {
|
||||||
|
throw new Error('Could not find the root element.');
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(container).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QuickPromptApp />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
+118
-1
@@ -689,9 +689,126 @@ body {
|
|||||||
.update-banner-enter,
|
.update-banner-enter,
|
||||||
.turn-activity-enter,
|
.turn-activity-enter,
|
||||||
.turn-activity-row,
|
.turn-activity-row,
|
||||||
.turn-activity-row[role="separator"] {
|
.turn-activity-row[role="separator"],
|
||||||
|
.qp-panel-enter,
|
||||||
|
.qp-response-enter,
|
||||||
|
.qp-actions-enter,
|
||||||
|
.qp-dropdown-enter {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qp-border-streaming {
|
||||||
|
animation: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Quick Prompt — Glass Command Bar ────────────────────────── */
|
||||||
|
|
||||||
|
.qp-container {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-panel {
|
||||||
|
background: var(--color-glass);
|
||||||
|
backdrop-filter: blur(24px) saturate(1.4);
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||||
|
box-shadow:
|
||||||
|
0 24px 80px rgba(0, 0, 0, 0.55),
|
||||||
|
0 0 1px rgba(255, 255, 255, 0.06) inset;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Border states */
|
||||||
|
.qp-border-idle {
|
||||||
|
border: 1px solid var(--color-glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-border-complete {
|
||||||
|
border: 1px solid var(--color-border-glow);
|
||||||
|
box-shadow:
|
||||||
|
0 24px 80px rgba(0, 0, 0, 0.55),
|
||||||
|
0 0 20px rgba(36, 92, 249, 0.06),
|
||||||
|
0 0 1px rgba(255, 255, 255, 0.06) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated breathing border during streaming */
|
||||||
|
@keyframes qp-border-breathe {
|
||||||
|
0%, 100% {
|
||||||
|
border-color: rgba(36, 92, 249, 0.35);
|
||||||
|
box-shadow:
|
||||||
|
0 24px 80px rgba(0, 0, 0, 0.55),
|
||||||
|
0 0 20px rgba(36, 92, 249, 0.08),
|
||||||
|
0 0 1px rgba(255, 255, 255, 0.06) inset;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
border-color: rgba(138, 41, 230, 0.4);
|
||||||
|
box-shadow:
|
||||||
|
0 24px 80px rgba(0, 0, 0, 0.55),
|
||||||
|
0 0 32px rgba(138, 41, 230, 0.1),
|
||||||
|
0 0 1px rgba(255, 255, 255, 0.06) inset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-border-streaming {
|
||||||
|
border: 1px solid rgba(36, 92, 249, 0.35);
|
||||||
|
animation: qp-border-breathe 2.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel entrance animation */
|
||||||
|
@keyframes qp-panel-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96) translateY(-6px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-panel-enter {
|
||||||
|
animation: qp-panel-in 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Response area entrance */
|
||||||
|
@keyframes qp-response-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
max-height: min(55vh, 520px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-response-enter {
|
||||||
|
animation: qp-response-in 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action bar entrance */
|
||||||
|
@keyframes qp-actions-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-actions-enter {
|
||||||
|
animation: qp-actions-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) 0.05s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown entrance */
|
||||||
|
@keyframes qp-dropdown-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(calc(100% - 4px)) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-dropdown-enter {
|
||||||
|
animation: qp-dropdown-in 0.15s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Markdown prose styles ───────────────────────────────────── */
|
/* ── Markdown prose styles ───────────────────────────────────── */
|
||||||
|
|||||||
@@ -83,4 +83,18 @@ export const ipcChannels = {
|
|||||||
updateStatus: 'app:update-status',
|
updateStatus: 'app:update-status',
|
||||||
getQuota: 'sidecar:get-quota',
|
getQuota: 'sidecar:get-quota',
|
||||||
trayCreateScratchpad: 'tray:create-scratchpad',
|
trayCreateScratchpad: 'tray:create-scratchpad',
|
||||||
|
|
||||||
|
// Quick Prompt
|
||||||
|
quickPromptSend: 'quick-prompt:send',
|
||||||
|
quickPromptDiscard: 'quick-prompt:discard',
|
||||||
|
quickPromptClose: 'quick-prompt:close',
|
||||||
|
quickPromptContinueInAryx: 'quick-prompt:continue-in-aryx',
|
||||||
|
quickPromptCancelTurn: 'quick-prompt:cancel-turn',
|
||||||
|
quickPromptSetSettings: 'quick-prompt:set-settings',
|
||||||
|
quickPromptGetSettings: 'quick-prompt:get-settings',
|
||||||
|
quickPromptGetCapabilities: 'quick-prompt:get-capabilities',
|
||||||
|
quickPromptSessionCreated: 'quick-prompt:session-created',
|
||||||
|
quickPromptSessionEvent: 'quick-prompt:session-event',
|
||||||
|
quickPromptShow: 'quick-prompt:show',
|
||||||
|
quickPromptHide: 'quick-prompt:hide',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ import type {
|
|||||||
McpServerDefinition,
|
McpServerDefinition,
|
||||||
SessionToolingSelection,
|
SessionToolingSelection,
|
||||||
AppearanceTheme,
|
AppearanceTheme,
|
||||||
|
QuickPromptSettings,
|
||||||
} from '@shared/domain/tooling';
|
} from '@shared/domain/tooling';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||||
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
|
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
|
||||||
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
||||||
|
import type { ModelDefinition } from '@shared/domain/models';
|
||||||
|
|
||||||
export interface CreateSessionInput {
|
export interface CreateSessionInput {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -381,9 +383,42 @@ export interface ElectronApi {
|
|||||||
onSessionEvent(listener: (event: SessionEventRecord) => void): () => void;
|
onSessionEvent(listener: (event: SessionEventRecord) => void): () => void;
|
||||||
onUpdateStatus(listener: (status: UpdateStatus) => void): () => void;
|
onUpdateStatus(listener: (status: UpdateStatus) => void): () => void;
|
||||||
onTrayCreateScratchpad(listener: () => void): () => void;
|
onTrayCreateScratchpad(listener: () => void): () => void;
|
||||||
|
getQuickPromptSettings(): Promise<QuickPromptSettings>;
|
||||||
|
setQuickPromptSettings(settings: Partial<QuickPromptSettings>): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RendererSelectionState {
|
export interface RendererSelectionState {
|
||||||
selectedProject?: ProjectRecord;
|
selectedProject?: ProjectRecord;
|
||||||
selectedWorkflow?: WorkflowDefinition;
|
selectedWorkflow?: WorkflowDefinition;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Quick Prompt contracts ---
|
||||||
|
|
||||||
|
export interface QuickPromptSendInput {
|
||||||
|
content: string;
|
||||||
|
model?: string;
|
||||||
|
reasoningEffort?: ReasoningEffort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuickPromptSessionInfo {
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuickPromptCapabilities {
|
||||||
|
models: ReadonlyArray<ModelDefinition>;
|
||||||
|
defaultModel?: string;
|
||||||
|
defaultReasoningEffort?: ReasoningEffort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuickPromptElectronApi {
|
||||||
|
send(input: QuickPromptSendInput): Promise<QuickPromptSessionInfo>;
|
||||||
|
discard(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
continueInAryx(): Promise<void>;
|
||||||
|
cancelTurn(): Promise<void>;
|
||||||
|
getCapabilities(): Promise<QuickPromptCapabilities>;
|
||||||
|
setSettings(settings: Partial<QuickPromptSettings>): Promise<void>;
|
||||||
|
onSessionEvent(listener: (event: SessionEventRecord) => void): () => void;
|
||||||
|
onShow(listener: () => void): () => void;
|
||||||
|
onHide(listener: () => void): () => void;
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,20 @@ export interface WorkspaceToolingSettings {
|
|||||||
|
|
||||||
export type AppearanceTheme = 'dark' | 'light' | 'system';
|
export type AppearanceTheme = 'dark' | 'light' | 'system';
|
||||||
|
|
||||||
|
export interface QuickPromptSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
hotkey: string;
|
||||||
|
defaultModel?: string;
|
||||||
|
defaultReasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultQuickPromptSettings(): QuickPromptSettings {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
hotkey: 'Super+Shift+A',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkspaceSettings {
|
export interface WorkspaceSettings {
|
||||||
theme: AppearanceTheme;
|
theme: AppearanceTheme;
|
||||||
tooling: WorkspaceToolingSettings;
|
tooling: WorkspaceToolingSettings;
|
||||||
@@ -70,6 +84,7 @@ export interface WorkspaceSettings {
|
|||||||
notificationsEnabled?: boolean;
|
notificationsEnabled?: boolean;
|
||||||
minimizeToTray?: boolean;
|
minimizeToTray?: boolean;
|
||||||
gitAutoRefreshEnabled?: boolean;
|
gitAutoRefreshEnabled?: boolean;
|
||||||
|
quickPrompt?: QuickPromptSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionToolingSelection {
|
export interface SessionToolingSelection {
|
||||||
@@ -215,6 +230,7 @@ export function normalizeWorkspaceSettings(settings?: Partial<WorkspaceSettings>
|
|||||||
...(settings?.notificationsEnabled !== undefined ? { notificationsEnabled: settings.notificationsEnabled } : {}),
|
...(settings?.notificationsEnabled !== undefined ? { notificationsEnabled: settings.notificationsEnabled } : {}),
|
||||||
...(settings?.minimizeToTray !== undefined ? { minimizeToTray: settings.minimizeToTray } : {}),
|
...(settings?.minimizeToTray !== undefined ? { minimizeToTray: settings.minimizeToTray } : {}),
|
||||||
...(settings?.gitAutoRefreshEnabled !== undefined ? { gitAutoRefreshEnabled: settings.gitAutoRefreshEnabled } : {}),
|
...(settings?.gitAutoRefreshEnabled !== undefined ? { gitAutoRefreshEnabled: settings.gitAutoRefreshEnabled } : {}),
|
||||||
|
...(settings?.quickPrompt !== undefined ? { quickPrompt: settings.quickPrompt } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -354,6 +354,16 @@ import Base from '../layouts/Base.astro';
|
|||||||
<p class="mt-0.5 text-xs leading-relaxed text-warm-400">Minimize to tray, quick-launch scratchpads, see running session count.</p>
|
<p class="mt-0.5 text-xs leading-relaxed text-warm-400">Minimize to tray, quick-launch scratchpads, see running session count.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Quick Prompt -->
|
||||||
|
<div class="group flex gap-3.5 rounded-xl px-3 py-2.5 transition hover:bg-card/60" data-reveal data-reveal-d="6">
|
||||||
|
<div class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent/[0.07] text-accent transition group-hover:bg-accent/[0.12]">
|
||||||
|
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-warm-50">Quick Prompt</h4>
|
||||||
|
<p class="mt-0.5 text-xs leading-relaxed text-warm-400">Global hotkey summons a floating AI prompt from any app. Discard, close, or continue in Aryx.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Desktop Notifications -->
|
<!-- Desktop Notifications -->
|
||||||
<div class="group flex gap-3.5 rounded-xl px-3 py-2.5 transition hover:bg-card/60" data-reveal data-reveal-d="7">
|
<div class="group flex gap-3.5 rounded-xl px-3 py-2.5 transition hover:bg-card/60" data-reveal data-reveal-d="7">
|
||||||
<div class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-brand/[0.07] text-brand transition group-hover:bg-brand/[0.12]">
|
<div class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-brand/[0.07] text-brand transition group-hover:bg-brand/[0.12]">
|
||||||
|
|||||||
Reference in New Issue
Block a user