feat(quick-pane): merge constrained agent window

This commit is contained in:
2026-08-25 18:34:29 +02:00
16 changed files with 760 additions and 9 deletions
+66
View File
@@ -0,0 +1,66 @@
# Quick Pane v1 Design
## Goal
Quick Pane is a small native window opened by a desktop/window-manager hotkey. It accepts one short request and returns either a useful answer or an explicit escalation within 60 seconds.
## Scope
- Cold-launchable, independent Tauri window.
- Compact request input, quick-model picker, and workspace picker.
- Configured default workspace, quick model, follow-up model, supplemental prompt, time limit, and read-only tool limit.
- Ephemeral Quick Pane UI state and best-effort runtime cleanup.
- Advisory read-only/no-subagent instructions, inline permission/clarification prompts, and explicit normal-session handoff.
## Non-goals
- Persisted quick history.
- Named profiles.
- App-managed WM binding writes.
- Trusted prevention of mutable tools or direct edits.
- Trusted prevention of subagents.
- A memory-only Pi runtime; Pi may write temporary session data while running.
## Invocation
Desktop/WM configuration owns the hotkey so it works while Pi GUI is absent. Settings displays and copies the required command; it does not register or modify system bindings.
The app creates or focuses a dedicated small Quick Pane window. `Esc` closes it and discards quick-only state.
## Request lifecycle
1. Pane chooses configured default workspace and quick model, with per-request overrides.
2. It starts an isolated Pi RPC runtime in the chosen workspace.
3. Immutable prompt text instructs one request, read-only inspection, zero subagents, configured wall-clock budget, and configured tool-call budget. Current Pi RPC cannot trusted-enforce those instructions; event-derived caps and cleanup are best effort.
4. Supplemental user instructions append beneath those prompt guardrails.
5. Pane streams answer, tool status, and inline extension requests.
6. On success it displays final answer. On a cap it preserves partial answer, explains the cap, and offers handoff.
## Escalation
Mutations, subagents, multi-step work, and likely-over-budget work preflight-escalate. The quick agent explains why. The user clicks **Continue in full session** to open a new normal session with:
- original request;
- escalation note or partial findings;
- selected workspace; and
- configured follow-up model.
No full session opens automatically.
## Permissions and privacy
Quick Pane surfaces Pi extension `select`, `confirm`, `input`, and `editor` requests inline. User explicitly answers every prompt. Existing policy remains authoritative: ordinary workspace reads may be allowed, external-directory reads may ask, and sensitive paths may deny.
Quick Pane UI request, output, and transcript stay in memory only. Closing pane removes them and triggers best-effort quick-runtime cleanup. Pi may write temporary session data while the child runs. A deliberate handoff persists only in newly created normal-session history.
## Failure handling
Unavailable models, startup failures, and crashes render inline actionable errors: retry, pick another quick model, or open normal session. No silent fallback model is used.
## Settings validation
Settings reject invalid model, workspace, custom prompt, or numeric limit. Failed save keeps previously stored configuration and marks invalid fields.
## Acceptance
From cold or hot desktop state, the hotkey produces an answer or explicit escalation within 60 seconds. Automated component, bridge, persistence, and native tests cover behavior; a manual cold-launch/hotkey smoke test verifies desktop integration.
+43
View File
@@ -0,0 +1,43 @@
# Quick Pane v1 Implementation Plan
## 1. Define persisted configuration
- Add Quick Pane settings types, defaults, validation, serialization, and migration-safe native storage.
- Fields: default workspace, quick model, follow-up model, supplemental prompt, maximum seconds, maximum read-only tool calls.
- Preserve current configuration when validation or save fails.
## 2. Add native window and launch contract
- Extend Tauri configuration and Rust window management with a dedicated Quick Pane window label.
- Add command-line action used by external WM hotkey to create/show/focus pane, including cold launch.
- Keep `--toggle` and ordinary `--new` behavior unchanged.
## 3. Build Quick Pane React surface
- Add isolated state reducer/component for request, selected model/workspace, progress, final answer, partial answer, escalation, extension UI, and errors.
- Keep pane transcript in component memory only and request best-effort runtime/session cleanup on close.
- Add Settings controls and copyable WM launch command.
## 4. Add constrained bridge runtime mode
- Extend runtime creation with Quick Pane metadata and ephemeral session lifecycle.
- Build immutable advisory quick-agent instructions around supplemental custom prompt.
- Use observed tool events and wall-clock cancellation for best-effort caps; document that current Pi RPC cannot trusted-enforce read-only/no-subagent policy or memory-only runtime sessions.
- Instruct preflight escalation for mutation, subagent, multi-step, or likely-over-budget requests.
## 5. Route interaction and handoff
- Reuse existing extension request/response protocol for inline Quick Pane permission and clarification dialogs.
- On explicit continuation, create normal runtime using workspace and follow-up model.
- Prefill it with original request plus escalation note/partial findings.
## 6. Test and document
- Add unit tests for settings validation/persistence and guardrail classification.
- Add component tests for pane states, picker behavior, inline prompts, error/retry, and cleanup.
- Add Rust/bridge tests for window command routing, budget cancellation, ephemeral cleanup, and handoff payload.
- Add manual WM hotkey/cold-launch instructions and smoke checklist.
## Sequencing
Land configuration/runtime contract first, then native window and UI, then handoff and test coverage. Preserve existing normal-session paths throughout.
+42
View File
@@ -0,0 +1,42 @@
# Quick Pane v1 Test Plan and QA Checklist
## Automated coverage
### Settings
- Valid defaults load when no Quick Pane configuration exists.
- Valid configuration round-trips through native storage.
- Invalid workspace, model, prompt, time, and tool values show field errors and retain prior saved state.
- Quick model picker affects quick request only; follow-up model remains configured default.
### Runtime guardrails
- Quick runtime receives immutable advisory read-only/no-subagent prompt text plus supplemental prompt.
- Mutation, subagent, multi-step, and likely-over-budget requests are instructed to preflight-escalate.
- Runtime applies best-effort configured 60-second/6-tool caps from observed events and exposes reason.
- Partial result survives a cap; pane transcript is removed and runtime cleanup requested after pane close.
- Tests/documentation state current limitations: Pi RPC cannot trusted-enforce prompt policy or guarantee memory-only temporary sessions.
### UI and bridge
- Quick Pane opens/focuses through dedicated native command without changing normal `--new` or `--toggle` behavior.
- Workspace and quick-model picker values reach runtime creation.
- Permission and clarification extension requests render inline and send correct response payloads.
- Retry, model change, and normal-session actions appear for startup/model/crash errors.
- Handoff creates normal session with original request, escalation note, workspace, and follow-up model.
## Manual smoke checklist
1. Configure default workspace, models, prompt, and 60-second/6-tool limits.
2. Add desktop/WM hotkey using Settings launch command.
3. From no running GUI process, invoke hotkey and verify Quick Pane opens/focuses.
4. Submit short answer-only request; verify result within 60 seconds.
5. Submit read-only inspection request; answer any external-read permission prompt inline.
6. Submit mutation or multi-step request; verify reason and **Continue in full session**.
7. Continue; verify normal session receives handoff and follow-up model.
8. Close Quick Pane; reopen and verify quick transcript is absent.
9. Select unavailable model or induce startup failure; verify inline recovery options.
## Exit criteria
All targeted automated tests pass. Manual cold-launch/hotkey smoke completes. No regressions in normal sessions, `--toggle`, or `--new` behavior.
+23 -5
View File
@@ -304,7 +304,9 @@ export function createAgentRegistry({
version: 2,
migrated: true,
directories: [...managedWorktrees].sort(),
runtimes: [...runtimesByRuntimeId.values()].map((runtime) => ({
runtimes: [...runtimesByRuntimeId.values()]
.filter((runtime) => !runtime.ephemeral)
.map((runtime) => ({
runtimeId: runtime.runtimeId,
worktreePath: runtime.worktreePath,
...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}),
@@ -321,7 +323,7 @@ export function createAgentRegistry({
}
function addWorktreeRuntime(runtime) {
managedWorktrees.add(runtime.worktreePath);
if (!runtime.ephemeral) managedWorktrees.add(runtime.worktreePath);
let runtimes = runtimesByWorktreePath.get(runtime.worktreePath);
if (!runtimes) {
runtimes = new Set();
@@ -556,6 +558,7 @@ export function createAgentRegistry({
sessionPath,
sessionId,
legacyDefault = false,
ephemeral = false,
openedAt = new Date().toISOString(),
}) {
return {
@@ -566,6 +569,7 @@ export function createAgentRegistry({
sessionPath,
sessionId,
legacyDefault,
ephemeral,
openedAt,
state: "starting",
stateVersion: 0,
@@ -701,13 +705,16 @@ export function createAgentRegistry({
sessionPath,
sessionId,
legacyDefault = false,
ephemeral = false,
openedAt,
},
{ restore = false } = {},
) {
if (stopping) throw new Error("agent registry is stopping");
const canonicalPath = await realpath(worktreePath);
const sessionDir = sessionDirectoryFor(sessionRoot, canonicalPath);
const sessionDir = ephemeral
? path.join(sessionRoot, "quick", runtimeId)
: sessionDirectoryFor(sessionRoot, canonicalPath);
await mkdir(sessionDir, { recursive: true, mode: SESSION_DIRECTORY_MODE });
await chmod(sessionDir, SESSION_DIRECTORY_MODE);
let ownedPath;
@@ -739,6 +746,7 @@ export function createAgentRegistry({
sessionPath: ownedPath,
sessionId,
legacyDefault,
ephemeral,
openedAt,
});
if (stopping) throw new Error("agent registry is stopping");
@@ -760,7 +768,7 @@ export function createAgentRegistry({
});
try {
await launch(runtime);
await persistWorkspace();
if (!runtime.ephemeral) await persistWorkspace();
publishWorkspace("runtime_opened", runtime, {
runtime: publicRuntime(runtime),
});
@@ -949,7 +957,7 @@ export function createAgentRegistry({
// Remove desired-open/UI intent now, but retain the session lease until
// the old child can no longer write to its JSONL file.
unindexRuntime(runtime, { releaseSessionLease: false });
await persistWorkspace();
if (!runtime.ephemeral) await persistWorkspace();
runtime.stopped = true;
runtime.supervisor?.stop();
let stopConfirmed = true;
@@ -977,6 +985,8 @@ export function createAgentRegistry({
}
}
if (stopConfirmed) releaseClosedSessionLease(runtime);
if (runtime.ephemeral && stopConfirmed)
await rm(runtime.sessionDir, { recursive: true, force: true }).catch(() => {});
runtime.state = "stopped";
publishWorkspace("runtime_closed", runtime, {
runtimeId: runtime.runtimeId,
@@ -1183,10 +1193,18 @@ export function createAgentRegistry({
async createSessionRuntime(worktreePath) {
return openRuntime({ worktreePath });
},
async createQuickRuntime(worktreePath) {
return openRuntime({ worktreePath, ephemeral: true });
},
async openSessionRuntime(worktreePath, sessionPath) {
return openRuntime({ worktreePath, sessionPath });
},
closeSessionRuntime: closeRuntime,
async closeQuickRuntime(runtimeId) {
const runtime = getRuntime(runtimeId);
if (!runtime.ephemeral) throw new Error("runtime is not a quick runtime");
return closeRuntime(runtimeId);
},
listAgents() {
return [...runtimesByAgentId.values()]
.map(publicAgent)
+8
View File
@@ -46,6 +46,12 @@ export async function startBridgeService({
request.payload.worktreePath,
),
};
case "create_quick_runtime":
return {
runtime: await registry.createQuickRuntime(
request.payload.worktreePath,
),
};
case "open_session_runtime":
return {
runtime: await registry.openSessionRuntime(
@@ -55,6 +61,8 @@ export async function startBridgeService({
};
case "close_session_runtime":
return registry.closeSessionRuntime(request.payload.runtimeId);
case "close_quick_runtime":
return registry.closeQuickRuntime(request.payload.runtimeId);
case "list_directory_sessions":
return {
sessions: await registry.listDirectorySessions(
+2
View File
@@ -12,8 +12,10 @@ const requestOperations = new Map([
["get_workspace", { agent: false, payload: "none" }],
["get_workspace_summary", { 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" }],
["close_session_runtime", { agent: false, payload: "runtime" }],
["close_quick_runtime", { agent: false, payload: "runtime" }],
["list_directory_sessions", { agent: false, payload: "worktree" }],
["get_session_runtime_snapshot", { agent: false, payload: "runtime" }],
["subscribe_workspace", { agent: false, payload: "cursor" }],
+2
View File
@@ -124,8 +124,10 @@ test("accepts additive multi-session runtime operations and numeric workspace cu
{ op: "get_workspace" },
{ op: "get_workspace_summary" },
{ op: "create_session_runtime", payload: { worktreePath } },
{ op: "create_quick_runtime", payload: { worktreePath } },
{ op: "open_session_runtime", payload: { worktreePath, sessionPath } },
{ op: "close_session_runtime", payload: { runtimeId: "runtime-1" } },
{ op: "close_quick_runtime", payload: { runtimeId: "runtime-1" } },
{ op: "list_directory_sessions", payload: { worktreePath } },
{
op: "get_session_runtime_snapshot",
+33
View File
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("Quick Pane polls snapshots, renders answers, and gates handoff", async () => {
const source = await readFile(
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
"utf8",
);
assert.match(source, /get_session_runtime_snapshot/);
assert.match(source, /setInterval\(\(\) => void refresh\(\), 800\)/);
assert.match(
source,
/<pre style=\{\{ whiteSpace: "pre-wrap" \}\}>\{answer\}<\/pre>/,
);
assert.match(
source,
/\{\(escalation \|\| error\) && \(\s*<button onClick=\{continueFull\}>Continue in full session<\/button>\s*\)\}/,
);
});
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),
"utf8",
);
assert.match(
source,
/<ExtensionDialog extension=\{extension\} onRespond=\{respond\} \/>/,
);
assert.match(source, /invoke\("respond_to_extension"/);
assert.match(source, /requestId: extension\.id/);
});
+3 -2
View File
@@ -1,9 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"description": "Capability for Pi Status windows",
"windows": [
"main"
"main",
"quick-pane"
],
"permissions": [
"core:default",
+22
View File
@@ -214,6 +214,17 @@ pub async fn create_session_runtime(
)
.await
}
pub async fn create_quick_runtime(
socket_path: &str,
worktree_path: &str,
) -> Result<RuntimeResult, String> {
typed_request(
socket_path,
"create_quick_runtime",
Some(json!({ "worktreePath": worktree_path })),
)
.await
}
pub async fn open_session_runtime(
socket_path: &str,
worktree_path: &str,
@@ -237,6 +248,17 @@ pub async fn close_session_runtime(
)
.await
}
pub async fn close_quick_runtime(
socket_path: &str,
runtime_id: &str,
) -> Result<CloseRuntimeResult, String> {
typed_request(
socket_path,
"close_quick_runtime",
Some(json!({ "runtimeId": runtime_id })),
)
.await
}
pub async fn list_directory_sessions(
socket_path: &str,
worktree_path: &str,
+41
View File
@@ -45,6 +45,21 @@ fn socket_path() -> Result<String, String> {
bridge::default_socket_path()
}
fn requested_quick_pane(args: &[String]) -> bool {
args.iter().any(|arg| arg == "--quick")
}
fn show_quick_pane(app: &AppHandle) {
if let Some(main) = app.get_webview_window("main") {
let _ = main.hide();
}
if let Some(window) = app.get_webview_window("quick-pane") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
fn requested_new_worktree_argument(args: &[String]) -> Option<String> {
args.iter()
.position(|arg| arg == "--worktree")
@@ -112,6 +127,11 @@ async fn create_session_runtime(worktree_path: String) -> Result<bridge::Runtime
bridge::create_session_runtime(&socket_path()?, &worktree_path).await
}
#[tauri::command]
async fn create_quick_runtime(worktree_path: String) -> Result<bridge::RuntimeResult, String> {
bridge::create_quick_runtime(&socket_path()?, &worktree_path).await
}
#[tauri::command]
async fn open_session_runtime(
worktree_path: String,
@@ -125,6 +145,11 @@ async fn close_session_runtime(runtime_id: String) -> Result<bridge::CloseRuntim
bridge::close_session_runtime(&socket_path()?, &runtime_id).await
}
#[tauri::command]
async fn close_quick_runtime(runtime_id: String) -> Result<bridge::CloseRuntimeResult, String> {
bridge::close_quick_runtime(&socket_path()?, &runtime_id).await
}
#[tauri::command]
async fn list_directory_sessions(
worktree_path: String,
@@ -357,6 +382,10 @@ pub fn run() {
task: Mutex::new(None),
})
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
if requested_quick_pane(&args) {
show_quick_pane(app);
return;
}
launch_new_session(app.clone(), &args);
if let Some(window) = app.get_webview_window("main") {
match window_action(&args, window.is_visible().unwrap_or(false)) {
@@ -371,15 +400,21 @@ pub fn run() {
}
}))
.setup(move |app| {
if requested_quick_pane(&launch_args) {
show_quick_pane(app.handle());
} else {
launch_new_session(app.handle().clone(), &launch_args);
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_workspace,
get_workspace_summary,
create_session_runtime,
create_quick_runtime,
open_session_runtime,
close_session_runtime,
close_quick_runtime,
list_directory_sessions,
get_session_runtime_snapshot,
load_ui_state,
@@ -440,6 +475,12 @@ mod tests {
assert_eq!(requested_new_worktree_argument(&relative).filter(|path| Path::new(path).is_absolute()), None);
}
#[test]
fn quick_flag_is_detected_without_affecting_toggle() {
assert!(requested_quick_pane(&["--quick".to_owned()]));
assert!(!requested_quick_pane(&["--toggle".to_owned()]));
}
#[test]
fn toggle_hides_a_visible_window_and_shows_a_hidden_window() {
let toggle = vec!["--toggle".to_owned()];
+80
View File
@@ -37,6 +37,34 @@ pub struct LastSeenState {
pub last_activity: Option<String>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct QuickPaneSettings {
pub workspace: String,
pub provider: String,
pub model: String,
pub follow_up_provider: String,
pub follow_up_model: String,
pub instructions: String,
pub maximum_seconds: u32,
pub maximum_tool_calls: u32,
}
impl Default for QuickPaneSettings {
fn default() -> Self {
Self {
workspace: String::new(),
provider: String::new(),
model: String::new(),
follow_up_provider: String::new(),
follow_up_model: String::new(),
instructions: "Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or work likely to exceed budget.".to_owned(),
maximum_seconds: 60,
maximum_tool_calls: 6,
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UiStateV1 {
@@ -61,6 +89,8 @@ pub struct UiStateV1 {
pub default_new_session_worktree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dismissed_collision_warning: Option<bool>,
#[serde(default)]
pub quick_pane: QuickPaneSettings,
}
impl Default for UiStateV1 {
@@ -77,6 +107,7 @@ impl Default for UiStateV1 {
interface_scale: None,
default_new_session_worktree: None,
dismissed_collision_warning: None,
quick_pane: QuickPaneSettings::default(),
}
}
}
@@ -182,6 +213,39 @@ pub fn validate(state: &UiStateV1) -> Result<(), String> {
if total_string_bytes > MAX_TOTAL_STRING_BYTES {
return Err("UI state strings exceed the total size limit".to_owned());
}
let quick = &state.quick_pane;
let quick_is_default = quick.workspace.is_empty()
&& quick.provider.is_empty()
&& quick.model.is_empty()
&& quick.follow_up_provider.is_empty()
&& quick.follow_up_model.is_empty();
if !quick_is_default {
bounded(&quick.workspace, "quick-pane workspace")?;
if !Path::new(&quick.workspace).is_absolute() || !Path::new(&quick.workspace).is_dir() {
return Err("Quick-pane workspace must be an existing absolute directory".to_owned());
}
bounded(&quick.provider, "quick-pane provider")?;
bounded(&quick.model, "quick-pane model")?;
bounded(&quick.follow_up_provider, "quick-pane follow-up provider")?;
bounded(&quick.follow_up_model, "quick-pane follow-up model")?;
}
if quick.instructions.is_empty() || quick.instructions.len() > 4096 {
return Err("Quick-pane instructions must contain 1 to 4096 bytes".to_owned());
}
if !(1..=600).contains(&quick.maximum_seconds) || !(1..=64).contains(&quick.maximum_tool_calls)
{
return Err("Quick-pane limits are outside the supported range".to_owned());
}
total_string_bytes = total_string_bytes
.saturating_add(quick.workspace.len())
.saturating_add(quick.provider.len())
.saturating_add(quick.model.len())
.saturating_add(quick.follow_up_provider.len())
.saturating_add(quick.follow_up_model.len())
.saturating_add(quick.instructions.len());
if total_string_bytes > MAX_TOTAL_STRING_BYTES {
return Err("UI state strings exceed the total size limit".to_owned());
}
if let Some(scale) = state.interface_scale {
if !scale.is_finite() || !(0.5..=2.0).contains(&scale) {
return Err("Interface scale is outside the supported range".to_owned());
@@ -368,6 +432,22 @@ mod tests {
.collect()
}
#[test]
fn quick_pane_settings_validate_complete_existing_workspace() {
let root = temp_dir("quick-pane");
fs::create_dir_all(&root).unwrap();
let mut state = UiStateV1::default();
state.quick_pane.workspace = root.to_string_lossy().into_owned();
state.quick_pane.provider = "provider".into();
state.quick_pane.model = "quick-model".into();
state.quick_pane.follow_up_provider = "provider".into();
state.quick_pane.follow_up_model = "follow-up-model".into();
assert!(validate(&state).is_ok());
state.quick_pane.maximum_seconds = 0;
assert!(validate(&state).is_err());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn second_save_replaces_valid_state_without_leaving_temporary_files() {
let root = temp_dir("replace");
+15
View File
@@ -12,6 +12,7 @@
"app": {
"windows": [
{
"label": "main",
"title": "Pi Status UI",
"width": 1100,
"height": 840,
@@ -23,6 +24,20 @@
"alwaysOnTop": true,
"skipTaskbar": true,
"shadow": true
},
{
"label": "quick-pane",
"title": "Pi Quick Pane",
"width": 520,
"height": 360,
"minWidth": 400,
"minHeight": 260,
"center": true,
"visible": false,
"decorations": false,
"alwaysOnTop": true,
"skipTaskbar": true,
"shadow": true
}
],
"security": {
+3 -1
View File
@@ -1,9 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { getCurrentWindow } from "@tauri-apps/api/window";
import App from "./App";
import QuickPane from "./quick-pane/QuickPane";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
{getCurrentWindow().label === "quick-pane" ? <QuickPane /> : <App />}
</React.StrictMode>,
);
+364
View File
@@ -0,0 +1,364 @@
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useEffect, useMemo, useRef, useState } from "react";
import { ExtensionDialog } from "../components/ExtensionDialog";
import type {
Extension,
QuickPaneSettings,
RuntimeSnapshot,
UiState,
} from "../workspace/types";
const defaults: 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,
};
type Runtime = { agentId: string; runtimeId: string };
function settingsError(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.";
}
function assistantText(snapshot: RuntimeSnapshot) {
const messages = snapshot.transcript?.data?.messages ?? [];
const message = [...messages]
.reverse()
.find((candidate) => candidate.role === "assistant");
if (!message) return "";
if (typeof message.content === "string") return message.content;
return (message.content ?? [])
.map((part) => part.text ?? "")
.filter(Boolean)
.join("\n");
}
export default function QuickPane() {
const [settings, setSettings] = useState<QuickPaneSettings>(defaults);
const [uiState, setUiState] = useState<UiState>();
const [request, setRequest] = useState("");
const [showSettings, setShowSettings] = useState(false);
const [status, setStatus] = useState("Loading Quick Pane settings…");
const [error, setError] = useState<string>();
const [runtime, setRuntime] = useState<Runtime>();
const [answer, setAnswer] = useState("");
const [escalation, setEscalation] = useState<string>();
const [extension, setExtension] = useState<Extension>();
const runtimeRef = useRef<Runtime | undefined>(undefined);
const invalid = useMemo(() => settingsError(settings), [settings]);
useEffect(() => {
let active = true;
invoke<UiState>("load_ui_state")
.then((state) => {
if (!active) return;
setUiState(state);
setSettings({ ...defaults, ...state.quickPane });
setStatus("");
})
.catch(
(reason) =>
active && setError(`Could not load settings: ${String(reason)}`),
);
return () => {
active = false;
const current = runtimeRef.current;
if (current)
invoke("close_quick_runtime", { runtimeId: current.runtimeId }).catch(
() => undefined,
);
};
}, []);
useEffect(() => {
if (!runtime) return;
let active = true;
const refresh = async () => {
try {
const snapshot = await invoke<RuntimeSnapshot>(
"get_session_runtime_snapshot",
{
runtimeId: runtime.runtimeId,
},
);
if (!active) return;
const text = assistantText(snapshot);
if (text) {
setAnswer(text);
if (text.includes("[ESCALATE]")) {
setEscalation(
text.replace("[ESCALATE]", "").trim() ||
"Quick agent requested a full session.",
);
} else if (!snapshot.state?.data?.isStreaming) {
setStatus("Quick answer ready.");
}
}
setExtension(snapshot.extensions[0]);
if (snapshot.runtime.error?.message) {
setError(snapshot.runtime.error.message);
setEscalation(
"Quick runtime failed. Continue in a full session if needed.",
);
}
} catch (reason) {
if (active)
setError(`Could not refresh quick result: ${String(reason)}`);
}
};
void refresh();
const interval = window.setInterval(() => void refresh(), 800);
return () => {
active = false;
window.clearInterval(interval);
};
}, [runtime]);
useEffect(() => {
if (!runtime) return;
const timeout = window.setTimeout(() => {
setEscalation(
`Quick time budget of ${settings.maximumSeconds} seconds reached.`,
);
setStatus(
"Quick time budget reached. Showing partial result if available.",
);
invoke("abort", { agentId: runtime.agentId }).catch(() => undefined);
}, settings.maximumSeconds * 1000);
return () => window.clearTimeout(timeout);
}, [runtime, settings.maximumSeconds]);
async function save() {
if (!uiState) return;
if (invalid) return setError(invalid);
try {
await invoke("save_ui_state", {
state: { ...uiState, quickPane: settings },
});
setUiState({ ...uiState, quickPane: settings });
setError(undefined);
setShowSettings(false);
setStatus("Quick Pane settings saved.");
} catch (reason) {
setError(`Could not save settings: ${String(reason)}`);
}
}
async function run() {
if (invalid || !request.trim())
return setError(invalid ?? "Enter a request.");
const previous = runtimeRef.current;
if (previous)
await invoke("close_quick_runtime", {
runtimeId: previous.runtimeId,
}).catch(() => undefined);
setError(undefined);
setAnswer("");
setEscalation(undefined);
setExtension(undefined);
setStatus("Starting advisory quick agent…");
try {
const result = await invoke<{ runtime: Runtime }>(
"create_quick_runtime",
{
worktreePath: settings.workspace,
},
);
runtimeRef.current = result.runtime;
setRuntime(result.runtime);
await invoke("set_model", {
agentId: result.runtime.agentId,
provider: settings.provider,
modelId: settings.model,
});
const guardrails = `[Quick Pane advisory mode: target ${settings.maximumSeconds}s and ${settings.maximumToolCalls} read-only tool calls. Do not use subagents. Do not mutate files or system state. If request needs mutation, multi-step work, subagents, or more budget, start final response with [ESCALATE] and explain why.]\n${settings.instructions}\n\nUser request: ${request}`;
await invoke("submit_prompt", {
agentId: result.runtime.agentId,
message: guardrails,
});
setStatus(
"Quick request running. Advisory limits are not trusted enforcement.",
);
} catch (reason) {
setError(`Could not start quick request: ${String(reason)}`);
setStatus("");
}
}
async function respond(response: Record<string, unknown>) {
if (!runtime || !extension?.id) return;
try {
await invoke("respond_to_extension", {
agentId: runtime.agentId,
requestId: extension.id,
response,
});
setExtension(undefined);
} catch (reason) {
setError(`Could not answer Pi request: ${String(reason)}`);
}
}
async function continueFull() {
if (invalid || !request.trim())
return setError(invalid ?? "Enter a request.");
try {
const result = await invoke<{ runtime: Runtime }>(
"create_session_runtime",
{
worktreePath: settings.workspace,
},
);
await invoke("set_model", {
agentId: result.runtime.agentId,
provider: settings.followUpProvider,
modelId: settings.followUpModel,
});
await invoke("submit_prompt", {
agentId: result.runtime.agentId,
message: `${request}\n\nQuick Pane escalation: ${escalation ?? "Continue this in a full session."}\n\nPartial quick result:\n${answer}`,
});
setStatus("Full session started with follow-up model.");
} catch (reason) {
setError(`Could not start full session: ${String(reason)}`);
}
}
return (
<main
style={{
padding: 18,
fontFamily: "system-ui",
color: "#e7e7e7",
background: "#1d2027",
minHeight: "100vh",
}}
>
<header style={{ display: "flex", justifyContent: "space-between" }}>
<strong>Quick Pane</strong>
<span>
<button onClick={() => setShowSettings(!showSettings)}>
Settings
</button>
<button onClick={() => getCurrentWindow().hide()}>Close</button>
</span>
</header>
{showSettings && (
<section>
<label>
Workspace{" "}
<input
value={settings.workspace}
onChange={(e) =>
setSettings({ ...settings, workspace: e.target.value })
}
/>
</label>
<label>
Quick provider/model{" "}
<input
value={settings.provider}
onChange={(e) =>
setSettings({ ...settings, provider: e.target.value })
}
/>
<input
value={settings.model}
onChange={(e) =>
setSettings({ ...settings, model: e.target.value })
}
/>
</label>
<label>
Follow-up provider/model{" "}
<input
value={settings.followUpProvider}
onChange={(e) =>
setSettings({ ...settings, followUpProvider: e.target.value })
}
/>
<input
value={settings.followUpModel}
onChange={(e) =>
setSettings({ ...settings, followUpModel: e.target.value })
}
/>
</label>
<label>
Seconds/tools{" "}
<input
type="number"
value={settings.maximumSeconds}
onChange={(e) =>
setSettings({
...settings,
maximumSeconds: Number(e.target.value),
})
}
/>
<input
type="number"
value={settings.maximumToolCalls}
onChange={(e) =>
setSettings({
...settings,
maximumToolCalls: Number(e.target.value),
})
}
/>
</label>
<textarea
value={settings.instructions}
onChange={(e) =>
setSettings({ ...settings, instructions: e.target.value })
}
/>
<button onClick={save}>Save</button>
</section>
)}
<textarea
autoFocus
value={request}
onChange={(e) => setRequest(e.target.value)}
placeholder="Ask a quick question or give a quick instruction"
style={{ width: "100%", minHeight: 90, marginTop: 14 }}
/>
<button disabled={!uiState || !!invalid || !request.trim()} onClick={run}>
Ask
</button>
{(escalation || error) && (
<button onClick={continueFull}>Continue in full session</button>
)}
{status && <p>{status}</p>}
{answer && <pre style={{ whiteSpace: "pre-wrap" }}>{answer}</pre>}
{error && <p role="alert">{error}</p>}
<ExtensionDialog extension={extension} onRespond={respond} />
</main>
);
}
+12
View File
@@ -221,6 +221,17 @@ export type WorkspaceState = {
interfaceScale: number;
};
export type QuickPaneSettings = {
workspace: string;
provider: string;
model: string;
followUpProvider: string;
followUpModel: string;
instructions: string;
maximumSeconds: number;
maximumToolCalls: number;
};
export type UiState = {
version: 1;
directoryOrder: string[];
@@ -236,4 +247,5 @@ export type UiState = {
interfaceScale?: number;
defaultNewSessionWorktree?: string;
dismissedCollisionWarning?: boolean;
quickPane?: QuickPaneSettings;
};