From 67063464e77095b9b5bf86825916ddb84596dbea Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Tue, 25 Aug 2026 19:14:30 +0200 Subject: [PATCH] feat(quick-pane): refine spotlight workflow --- src/bridge/agent-registry.js | 10 ++ src/bridge/service.js | 2 + src/protocol/index.js | 1 + test/quick-pane-ui.test.js | 23 ++++ ui/src-tauri/src/bridge.rs | 3 + ui/src-tauri/src/lib.rs | 6 + ui/src/App.css | 38 ++++++ ui/src/App.tsx | 208 ++++++++++++++++++++++++++++++ ui/src/quick-pane/QuickPane.tsx | 215 +++++++++++++++---------------- ui/src/quick-pane/quick-pane.css | 20 +++ ui/src/workspace/persistence.ts | 5 +- 11 files changed, 418 insertions(+), 113 deletions(-) create mode 100644 ui/src/quick-pane/quick-pane.css diff --git a/src/bridge/agent-registry.js b/src/bridge/agent-registry.js index d40a274..b6b314d 100644 --- a/src/bridge/agent-registry.js +++ b/src/bridge/agent-registry.js @@ -1205,6 +1205,16 @@ export function createAgentRegistry({ if (!runtime.ephemeral) throw new Error("runtime is not a quick runtime"); return closeRuntime(runtimeId); }, + async getModelCatalog() { + const runtime = [...runtimesByAgentId.values()].find( + (candidate) => candidate.adapter, + ); + if (!runtime?.adapter) return []; + const response = await enqueue(runtime, () => + runtime.adapter.send({ type: "get_available_models" }), + ); + return response?.data?.models ?? []; + }, listAgents() { return [...runtimesByAgentId.values()] .map(publicAgent) diff --git a/src/bridge/service.js b/src/bridge/service.js index 5920531..4daf5ea 100644 --- a/src/bridge/service.js +++ b/src/bridge/service.js @@ -40,6 +40,8 @@ export async function startBridgeService({ return registry.getWorkspace(); case "get_workspace_summary": return registry.getWorkspaceSummary(); + case "get_model_catalog": + return { models: await registry.getModelCatalog() }; case "create_session_runtime": return { runtime: await registry.createSessionRuntime( diff --git a/src/protocol/index.js b/src/protocol/index.js index fcaa770..fe122a6 100644 --- a/src/protocol/index.js +++ b/src/protocol/index.js @@ -11,6 +11,7 @@ const requestOperations = new Map([ ["forget_directory", { agent: false, payload: "worktree" }], ["get_workspace", { agent: false, payload: "none" }], ["get_workspace_summary", { agent: false, payload: "none" }], + ["get_model_catalog", { agent: false, payload: "none" }], ["create_session_runtime", { agent: false, payload: "worktree" }], ["create_quick_runtime", { agent: false, payload: "worktree" }], ["open_session_runtime", { agent: false, payload: "worktreeSession" }], diff --git a/test/quick-pane-ui.test.js b/test/quick-pane-ui.test.js index b87bdbe..a93f121 100644 --- a/test/quick-pane-ui.test.js +++ b/test/quick-pane-ui.test.js @@ -19,6 +19,29 @@ test("Quick Pane polls snapshots, renders answers, and gates handoff", async () ); }); +test("main Settings owns Quick Pane configuration while pane keeps request controls", async () => { + const [pane, app, persistence] = await Promise.all([ + readFile( + new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url), + "utf8", + ), + readFile(new URL("../ui/src/App.tsx", import.meta.url), "utf8"), + readFile( + new URL("../ui/src/workspace/persistence.ts", import.meta.url), + "utf8", + ), + ]); + assert.match(app, /load_ui_state/); + assert.match(app, /save_ui_state/); + assert.match(app, /Save Quick Pane settings/); + assert.match(pane, /get_model_catalog/); + assert.match(pane, /set_thinking_level/); + assert.doesNotMatch(pane, /showSettings/); + assert.doesNotMatch(pane, /Save Quick Pane settings/); + assert.doesNotMatch(pane, /setRequest\(""\)/); + assert.match(persistence, /quickPane: current\.quickPane/); +}); + test("Quick Pane routes extension responses through existing bridge command", async () => { const source = await readFile( new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url), diff --git a/ui/src-tauri/src/bridge.rs b/ui/src-tauri/src/bridge.rs index ad056fe..9fed851 100644 --- a/ui/src-tauri/src/bridge.rs +++ b/ui/src-tauri/src/bridge.rs @@ -203,6 +203,9 @@ pub async fn get_workspace(socket_path: &str) -> Result { pub async fn get_workspace_summary(socket_path: &str) -> Result { typed_request(socket_path, "get_workspace_summary", None).await } +pub async fn get_model_catalog(socket_path: &str) -> Result { + request(socket_path, "get_model_catalog", None, None).await +} pub async fn create_session_runtime( socket_path: &str, worktree_path: &str, diff --git a/ui/src-tauri/src/lib.rs b/ui/src-tauri/src/lib.rs index 2ebfde3..9799bfd 100644 --- a/ui/src-tauri/src/lib.rs +++ b/ui/src-tauri/src/lib.rs @@ -122,6 +122,11 @@ async fn get_workspace_summary() -> Result { bridge::get_workspace_summary(&socket_path()?).await } +#[tauri::command] +async fn get_model_catalog() -> Result { + bridge::get_model_catalog(&socket_path()?).await +} + #[tauri::command] async fn create_session_runtime(worktree_path: String) -> Result { bridge::create_session_runtime(&socket_path()?, &worktree_path).await @@ -410,6 +415,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ get_workspace, get_workspace_summary, + get_model_catalog, create_session_runtime, create_quick_runtime, open_session_runtime, diff --git a/ui/src/App.css b/ui/src/App.css index fafa814..cda3873 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -1043,6 +1043,44 @@ select:focus-visible { color: #b9afa4; font-weight: 400; } +.quick-pane-settings { + display: grid; + gap: 8px; + max-width: 560px; + margin-top: 20px; + padding-top: 14px; + border-top: 1px solid #4b4b4b; +} +.quick-pane-settings h2 { + margin: 0; + font-size: 15px; +} +.quick-pane-settings label { + display: grid; + gap: 4px; + color: #ddd8d0; + font-size: 12px; + font-weight: 700; +} +.quick-pane-settings input, +.quick-pane-settings textarea { + width: 100%; + padding: 6px 8px; + border: 1px solid #70522f; + border-radius: 4px; + color: #f2ece4; + background: #2c2a27; +} +.quick-pane-settings textarea { + min-height: 90px; + resize: vertical; +} +.quick-pane-settings button { + justify-self: start; +} +.quick-pane-settings p { + margin: 0; +} .extension-widget { padding: 5px 8px; border-left: 3px solid #f0a347; diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d1c40b4..a0c8a52 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,4 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; +import { emitTo } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { open } from "@tauri-apps/plugin-dialog"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -14,7 +15,9 @@ import type { DirectorySession, DirectoryWorkspace, Model, + QuickPaneSettings, RuntimeView, + UiState, } from "./workspace/types"; import { useWorkspace } from "./workspace/useWorkspace"; @@ -22,6 +25,39 @@ const minimumUiScale = 0.9, maximumUiScale = 1.4; const clamp = (value: number) => Math.min(maximumUiScale, Math.max(minimumUiScale, value)); +const defaultQuickPaneSettings: QuickPaneSettings = { + workspace: "", + provider: "", + model: "", + followUpProvider: "", + followUpModel: "", + instructions: + "Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or work likely to exceed budget.", + maximumSeconds: 60, + maximumToolCalls: 6, +}; +function validateQuickPaneSettings(settings: QuickPaneSettings) { + if (!settings.workspace.startsWith("/")) + return "Workspace must be an absolute path."; + if (!settings.provider || !settings.model) + return "Quick provider and model are required."; + if (!settings.followUpProvider || !settings.followUpModel) + return "Follow-up provider and model are required."; + if (!settings.instructions.trim()) + return "Supplemental instructions are required."; + if ( + !Number.isInteger(settings.maximumSeconds) || + settings.maximumSeconds < 1 || + settings.maximumSeconds > 600 + ) + return "Seconds must be an integer from 1 to 600."; + if ( + !Number.isInteger(settings.maximumToolCalls) || + settings.maximumToolCalls < 1 || + settings.maximumToolCalls > 64 + ) + return "Tool calls must be an integer from 1 to 64."; +} let submissionId = 0; type LocalOperation = { id: number; @@ -119,6 +155,12 @@ export default function App() { () => new Set(), ); const [restoreTabFocus, setRestoreTabFocus] = useState(false); + const [uiState, setUiState] = useState(); + const [quickPaneSettings, setQuickPaneSettings] = useState( + defaultQuickPaneSettings, + ); + const [quickPaneSettingsStatus, setQuickPaneSettingsStatus] = useState(""); + const [quickPaneSettingsError, setQuickPaneSettingsError] = useState(); const setRuntimeStatus = (runtimeId: string, detail: string) => setRuntimeStatuses((current) => ({ ...current, [runtimeId]: detail })); const clearRuntimeStatus = (runtimeId: string) => @@ -161,6 +203,28 @@ export default function App() { }; }, []); + useEffect(() => { + let active = true; + void invoke("load_ui_state") + .then((loaded) => { + if (!active) return; + setUiState(loaded); + setQuickPaneSettings({ + ...defaultQuickPaneSettings, + ...loaded.quickPane, + }); + }) + .catch((error) => { + if (active) + setQuickPaneSettingsError( + `Could not load Quick Pane settings: ${String(error)}`, + ); + }); + return () => { + active = false; + }; + }, []); + const selectedRuntimeId = selected?.summary.runtimeId; useEffect(() => { if (!selectedRuntimeId) return; @@ -181,6 +245,32 @@ export default function App() { }); }, [state.runtimesById]); + async function saveQuickPaneSettings() { + const validationError = validateQuickPaneSettings(quickPaneSettings); + if (validationError) { + setQuickPaneSettingsError(validationError); + return; + } + if (!uiState) return; + try { + const current = await invoke("load_ui_state"); + const next = { ...current, quickPane: quickPaneSettings }; + await invoke("save_ui_state", { state: next }); + void emitTo( + "quick-pane", + "quick-pane-settings-saved", + quickPaneSettings, + ).catch(() => undefined); + setUiState(next); + setQuickPaneSettingsError(undefined); + setQuickPaneSettingsStatus("Quick Pane settings saved."); + } catch (error) { + setQuickPaneSettingsError( + `Could not save Quick Pane settings: ${String(error)}`, + ); + } + } + async function createRuntime(path = state.selectedDirectoryPath) { if (!path || !mountedRef.current) return; const operationId = startOperation("creating", "Starting a new Pi session…"); @@ -763,6 +853,124 @@ export default function App() { Used by Ctrl+Super+Space. Only currently open directories are listed. +
{ + event.preventDefault(); + void saveQuickPaneSettings(); + }} + > +

Quick Pane

+ + + + + + + +