diff --git a/docs/quick-pane-v1-design.md b/docs/quick-pane-v1-design.md new file mode 100644 index 0000000..fea70e9 --- /dev/null +++ b/docs/quick-pane-v1-design.md @@ -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. diff --git a/docs/quick-pane-v1-implementation-plan.md b/docs/quick-pane-v1-implementation-plan.md new file mode 100644 index 0000000..3a67ff1 --- /dev/null +++ b/docs/quick-pane-v1-implementation-plan.md @@ -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. diff --git a/docs/quick-pane-v1-test-plan.md b/docs/quick-pane-v1-test-plan.md new file mode 100644 index 0000000..d75c015 --- /dev/null +++ b/docs/quick-pane-v1-test-plan.md @@ -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. diff --git a/src/bridge/agent-registry.js b/src/bridge/agent-registry.js index 539f32d..e8fa161 100644 --- a/src/bridge/agent-registry.js +++ b/src/bridge/agent-registry.js @@ -309,7 +309,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 } : {}), @@ -326,7 +328,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(); @@ -573,6 +575,7 @@ export function createAgentRegistry({ sessionPath, sessionId, legacyDefault = false, + ephemeral = false, openedAt = new Date().toISOString(), }) { return { @@ -583,6 +586,7 @@ export function createAgentRegistry({ sessionPath, sessionId, legacyDefault, + ephemeral, openedAt, state: "starting", stateVersion: 0, @@ -719,13 +723,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; @@ -757,6 +764,7 @@ export function createAgentRegistry({ sessionPath: ownedPath, sessionId, legacyDefault, + ephemeral, openedAt, }); if (stopping) throw new Error("agent registry is stopping"); @@ -778,7 +786,7 @@ export function createAgentRegistry({ }); try { await launch(runtime); - await persistWorkspace(); + if (!runtime.ephemeral) await persistWorkspace(); publishWorkspace("runtime_opened", runtime, { runtime: publicRuntime(runtime), }); @@ -974,7 +982,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; @@ -1002,6 +1010,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, @@ -1206,10 +1216,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) diff --git a/src/bridge/service.js b/src/bridge/service.js index f41eaaa..3907d92 100644 --- a/src/bridge/service.js +++ b/src/bridge/service.js @@ -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( diff --git a/src/protocol/index.js b/src/protocol/index.js index 3151bda..37ee92e 100644 --- a/src/protocol/index.js +++ b/src/protocol/index.js @@ -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" }], diff --git a/test/protocol.test.js b/test/protocol.test.js index 2fc2a83..e4cb00b 100644 --- a/test/protocol.test.js +++ b/test/protocol.test.js @@ -115,8 +115,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", diff --git a/test/quick-pane-ui.test.js b/test/quick-pane-ui.test.js new file mode 100644 index 0000000..b87bdbe --- /dev/null +++ b/test/quick-pane-ui.test.js @@ -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, + /
\{answer\}<\/pre>/,
+  );
+  assert.match(
+    source,
+    /\{\(escalation \|\| error\) && \(\s*
+          
+        
+      
+      {showSettings && (
+        
+ + + + +