feat(quick-pane): merge constrained agent window
This commit is contained in:
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -304,7 +304,9 @@ export function createAgentRegistry({
|
|||||||
version: 2,
|
version: 2,
|
||||||
migrated: true,
|
migrated: true,
|
||||||
directories: [...managedWorktrees].sort(),
|
directories: [...managedWorktrees].sort(),
|
||||||
runtimes: [...runtimesByRuntimeId.values()].map((runtime) => ({
|
runtimes: [...runtimesByRuntimeId.values()]
|
||||||
|
.filter((runtime) => !runtime.ephemeral)
|
||||||
|
.map((runtime) => ({
|
||||||
runtimeId: runtime.runtimeId,
|
runtimeId: runtime.runtimeId,
|
||||||
worktreePath: runtime.worktreePath,
|
worktreePath: runtime.worktreePath,
|
||||||
...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}),
|
...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}),
|
||||||
@@ -321,7 +323,7 @@ export function createAgentRegistry({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addWorktreeRuntime(runtime) {
|
function addWorktreeRuntime(runtime) {
|
||||||
managedWorktrees.add(runtime.worktreePath);
|
if (!runtime.ephemeral) managedWorktrees.add(runtime.worktreePath);
|
||||||
let runtimes = runtimesByWorktreePath.get(runtime.worktreePath);
|
let runtimes = runtimesByWorktreePath.get(runtime.worktreePath);
|
||||||
if (!runtimes) {
|
if (!runtimes) {
|
||||||
runtimes = new Set();
|
runtimes = new Set();
|
||||||
@@ -556,6 +558,7 @@ export function createAgentRegistry({
|
|||||||
sessionPath,
|
sessionPath,
|
||||||
sessionId,
|
sessionId,
|
||||||
legacyDefault = false,
|
legacyDefault = false,
|
||||||
|
ephemeral = false,
|
||||||
openedAt = new Date().toISOString(),
|
openedAt = new Date().toISOString(),
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
@@ -566,6 +569,7 @@ export function createAgentRegistry({
|
|||||||
sessionPath,
|
sessionPath,
|
||||||
sessionId,
|
sessionId,
|
||||||
legacyDefault,
|
legacyDefault,
|
||||||
|
ephemeral,
|
||||||
openedAt,
|
openedAt,
|
||||||
state: "starting",
|
state: "starting",
|
||||||
stateVersion: 0,
|
stateVersion: 0,
|
||||||
@@ -701,13 +705,16 @@ export function createAgentRegistry({
|
|||||||
sessionPath,
|
sessionPath,
|
||||||
sessionId,
|
sessionId,
|
||||||
legacyDefault = false,
|
legacyDefault = false,
|
||||||
|
ephemeral = false,
|
||||||
openedAt,
|
openedAt,
|
||||||
},
|
},
|
||||||
{ restore = false } = {},
|
{ restore = false } = {},
|
||||||
) {
|
) {
|
||||||
if (stopping) throw new Error("agent registry is stopping");
|
if (stopping) throw new Error("agent registry is stopping");
|
||||||
const canonicalPath = await realpath(worktreePath);
|
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 mkdir(sessionDir, { recursive: true, mode: SESSION_DIRECTORY_MODE });
|
||||||
await chmod(sessionDir, SESSION_DIRECTORY_MODE);
|
await chmod(sessionDir, SESSION_DIRECTORY_MODE);
|
||||||
let ownedPath;
|
let ownedPath;
|
||||||
@@ -739,6 +746,7 @@ export function createAgentRegistry({
|
|||||||
sessionPath: ownedPath,
|
sessionPath: ownedPath,
|
||||||
sessionId,
|
sessionId,
|
||||||
legacyDefault,
|
legacyDefault,
|
||||||
|
ephemeral,
|
||||||
openedAt,
|
openedAt,
|
||||||
});
|
});
|
||||||
if (stopping) throw new Error("agent registry is stopping");
|
if (stopping) throw new Error("agent registry is stopping");
|
||||||
@@ -760,7 +768,7 @@ export function createAgentRegistry({
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await launch(runtime);
|
await launch(runtime);
|
||||||
await persistWorkspace();
|
if (!runtime.ephemeral) await persistWorkspace();
|
||||||
publishWorkspace("runtime_opened", runtime, {
|
publishWorkspace("runtime_opened", runtime, {
|
||||||
runtime: publicRuntime(runtime),
|
runtime: publicRuntime(runtime),
|
||||||
});
|
});
|
||||||
@@ -949,7 +957,7 @@ export function createAgentRegistry({
|
|||||||
// Remove desired-open/UI intent now, but retain the session lease until
|
// Remove desired-open/UI intent now, but retain the session lease until
|
||||||
// the old child can no longer write to its JSONL file.
|
// the old child can no longer write to its JSONL file.
|
||||||
unindexRuntime(runtime, { releaseSessionLease: false });
|
unindexRuntime(runtime, { releaseSessionLease: false });
|
||||||
await persistWorkspace();
|
if (!runtime.ephemeral) await persistWorkspace();
|
||||||
runtime.stopped = true;
|
runtime.stopped = true;
|
||||||
runtime.supervisor?.stop();
|
runtime.supervisor?.stop();
|
||||||
let stopConfirmed = true;
|
let stopConfirmed = true;
|
||||||
@@ -977,6 +985,8 @@ export function createAgentRegistry({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (stopConfirmed) releaseClosedSessionLease(runtime);
|
if (stopConfirmed) releaseClosedSessionLease(runtime);
|
||||||
|
if (runtime.ephemeral && stopConfirmed)
|
||||||
|
await rm(runtime.sessionDir, { recursive: true, force: true }).catch(() => {});
|
||||||
runtime.state = "stopped";
|
runtime.state = "stopped";
|
||||||
publishWorkspace("runtime_closed", runtime, {
|
publishWorkspace("runtime_closed", runtime, {
|
||||||
runtimeId: runtime.runtimeId,
|
runtimeId: runtime.runtimeId,
|
||||||
@@ -1183,10 +1193,18 @@ export function createAgentRegistry({
|
|||||||
async createSessionRuntime(worktreePath) {
|
async createSessionRuntime(worktreePath) {
|
||||||
return openRuntime({ worktreePath });
|
return openRuntime({ worktreePath });
|
||||||
},
|
},
|
||||||
|
async createQuickRuntime(worktreePath) {
|
||||||
|
return openRuntime({ worktreePath, ephemeral: true });
|
||||||
|
},
|
||||||
async openSessionRuntime(worktreePath, sessionPath) {
|
async openSessionRuntime(worktreePath, sessionPath) {
|
||||||
return openRuntime({ worktreePath, sessionPath });
|
return openRuntime({ worktreePath, sessionPath });
|
||||||
},
|
},
|
||||||
closeSessionRuntime: closeRuntime,
|
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() {
|
listAgents() {
|
||||||
return [...runtimesByAgentId.values()]
|
return [...runtimesByAgentId.values()]
|
||||||
.map(publicAgent)
|
.map(publicAgent)
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ export async function startBridgeService({
|
|||||||
request.payload.worktreePath,
|
request.payload.worktreePath,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
case "create_quick_runtime":
|
||||||
|
return {
|
||||||
|
runtime: await registry.createQuickRuntime(
|
||||||
|
request.payload.worktreePath,
|
||||||
|
),
|
||||||
|
};
|
||||||
case "open_session_runtime":
|
case "open_session_runtime":
|
||||||
return {
|
return {
|
||||||
runtime: await registry.openSessionRuntime(
|
runtime: await registry.openSessionRuntime(
|
||||||
@@ -55,6 +61,8 @@ export async function startBridgeService({
|
|||||||
};
|
};
|
||||||
case "close_session_runtime":
|
case "close_session_runtime":
|
||||||
return registry.closeSessionRuntime(request.payload.runtimeId);
|
return registry.closeSessionRuntime(request.payload.runtimeId);
|
||||||
|
case "close_quick_runtime":
|
||||||
|
return registry.closeQuickRuntime(request.payload.runtimeId);
|
||||||
case "list_directory_sessions":
|
case "list_directory_sessions":
|
||||||
return {
|
return {
|
||||||
sessions: await registry.listDirectorySessions(
|
sessions: await registry.listDirectorySessions(
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ const requestOperations = new Map([
|
|||||||
["get_workspace", { agent: false, payload: "none" }],
|
["get_workspace", { agent: false, payload: "none" }],
|
||||||
["get_workspace_summary", { agent: false, payload: "none" }],
|
["get_workspace_summary", { agent: false, payload: "none" }],
|
||||||
["create_session_runtime", { agent: false, payload: "worktree" }],
|
["create_session_runtime", { agent: false, payload: "worktree" }],
|
||||||
|
["create_quick_runtime", { agent: false, payload: "worktree" }],
|
||||||
["open_session_runtime", { agent: false, payload: "worktreeSession" }],
|
["open_session_runtime", { agent: false, payload: "worktreeSession" }],
|
||||||
["close_session_runtime", { agent: false, payload: "runtime" }],
|
["close_session_runtime", { agent: false, payload: "runtime" }],
|
||||||
|
["close_quick_runtime", { agent: false, payload: "runtime" }],
|
||||||
["list_directory_sessions", { agent: false, payload: "worktree" }],
|
["list_directory_sessions", { agent: false, payload: "worktree" }],
|
||||||
["get_session_runtime_snapshot", { agent: false, payload: "runtime" }],
|
["get_session_runtime_snapshot", { agent: false, payload: "runtime" }],
|
||||||
["subscribe_workspace", { agent: false, payload: "cursor" }],
|
["subscribe_workspace", { agent: false, payload: "cursor" }],
|
||||||
|
|||||||
@@ -124,8 +124,10 @@ test("accepts additive multi-session runtime operations and numeric workspace cu
|
|||||||
{ op: "get_workspace" },
|
{ op: "get_workspace" },
|
||||||
{ op: "get_workspace_summary" },
|
{ op: "get_workspace_summary" },
|
||||||
{ op: "create_session_runtime", payload: { worktreePath } },
|
{ op: "create_session_runtime", payload: { worktreePath } },
|
||||||
|
{ op: "create_quick_runtime", payload: { worktreePath } },
|
||||||
{ op: "open_session_runtime", payload: { worktreePath, sessionPath } },
|
{ op: "open_session_runtime", payload: { worktreePath, sessionPath } },
|
||||||
{ op: "close_session_runtime", payload: { runtimeId: "runtime-1" } },
|
{ op: "close_session_runtime", payload: { runtimeId: "runtime-1" } },
|
||||||
|
{ op: "close_quick_runtime", payload: { runtimeId: "runtime-1" } },
|
||||||
{ op: "list_directory_sessions", payload: { worktreePath } },
|
{ op: "list_directory_sessions", payload: { worktreePath } },
|
||||||
{
|
{
|
||||||
op: "get_session_runtime_snapshot",
|
op: "get_session_runtime_snapshot",
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capability for Pi Status windows",
|
||||||
"windows": [
|
"windows": [
|
||||||
"main"
|
"main",
|
||||||
|
"quick-pane"
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
|||||||
@@ -214,6 +214,17 @@ pub async fn create_session_runtime(
|
|||||||
)
|
)
|
||||||
.await
|
.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(
|
pub async fn open_session_runtime(
|
||||||
socket_path: &str,
|
socket_path: &str,
|
||||||
worktree_path: &str,
|
worktree_path: &str,
|
||||||
@@ -237,6 +248,17 @@ pub async fn close_session_runtime(
|
|||||||
)
|
)
|
||||||
.await
|
.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(
|
pub async fn list_directory_sessions(
|
||||||
socket_path: &str,
|
socket_path: &str,
|
||||||
worktree_path: &str,
|
worktree_path: &str,
|
||||||
|
|||||||
@@ -45,6 +45,21 @@ fn socket_path() -> Result<String, String> {
|
|||||||
bridge::default_socket_path()
|
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> {
|
fn requested_new_worktree_argument(args: &[String]) -> Option<String> {
|
||||||
args.iter()
|
args.iter()
|
||||||
.position(|arg| arg == "--worktree")
|
.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
|
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]
|
#[tauri::command]
|
||||||
async fn open_session_runtime(
|
async fn open_session_runtime(
|
||||||
worktree_path: String,
|
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
|
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]
|
#[tauri::command]
|
||||||
async fn list_directory_sessions(
|
async fn list_directory_sessions(
|
||||||
worktree_path: String,
|
worktree_path: String,
|
||||||
@@ -357,6 +382,10 @@ pub fn run() {
|
|||||||
task: Mutex::new(None),
|
task: Mutex::new(None),
|
||||||
})
|
})
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
.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);
|
launch_new_session(app.clone(), &args);
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
match window_action(&args, window.is_visible().unwrap_or(false)) {
|
match window_action(&args, window.is_visible().unwrap_or(false)) {
|
||||||
@@ -371,15 +400,21 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
|
if requested_quick_pane(&launch_args) {
|
||||||
|
show_quick_pane(app.handle());
|
||||||
|
} else {
|
||||||
launch_new_session(app.handle().clone(), &launch_args);
|
launch_new_session(app.handle().clone(), &launch_args);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_workspace,
|
get_workspace,
|
||||||
get_workspace_summary,
|
get_workspace_summary,
|
||||||
create_session_runtime,
|
create_session_runtime,
|
||||||
|
create_quick_runtime,
|
||||||
open_session_runtime,
|
open_session_runtime,
|
||||||
close_session_runtime,
|
close_session_runtime,
|
||||||
|
close_quick_runtime,
|
||||||
list_directory_sessions,
|
list_directory_sessions,
|
||||||
get_session_runtime_snapshot,
|
get_session_runtime_snapshot,
|
||||||
load_ui_state,
|
load_ui_state,
|
||||||
@@ -440,6 +475,12 @@ mod tests {
|
|||||||
assert_eq!(requested_new_worktree_argument(&relative).filter(|path| Path::new(path).is_absolute()), None);
|
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]
|
#[test]
|
||||||
fn toggle_hides_a_visible_window_and_shows_a_hidden_window() {
|
fn toggle_hides_a_visible_window_and_shows_a_hidden_window() {
|
||||||
let toggle = vec!["--toggle".to_owned()];
|
let toggle = vec!["--toggle".to_owned()];
|
||||||
|
|||||||
@@ -37,6 +37,34 @@ pub struct LastSeenState {
|
|||||||
pub last_activity: Option<String>,
|
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)]
|
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
pub struct UiStateV1 {
|
pub struct UiStateV1 {
|
||||||
@@ -61,6 +89,8 @@ pub struct UiStateV1 {
|
|||||||
pub default_new_session_worktree: Option<String>,
|
pub default_new_session_worktree: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub dismissed_collision_warning: Option<bool>,
|
pub dismissed_collision_warning: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub quick_pane: QuickPaneSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UiStateV1 {
|
impl Default for UiStateV1 {
|
||||||
@@ -77,6 +107,7 @@ impl Default for UiStateV1 {
|
|||||||
interface_scale: None,
|
interface_scale: None,
|
||||||
default_new_session_worktree: None,
|
default_new_session_worktree: None,
|
||||||
dismissed_collision_warning: 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 {
|
if total_string_bytes > MAX_TOTAL_STRING_BYTES {
|
||||||
return Err("UI state strings exceed the total size limit".to_owned());
|
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 let Some(scale) = state.interface_scale {
|
||||||
if !scale.is_finite() || !(0.5..=2.0).contains(&scale) {
|
if !scale.is_finite() || !(0.5..=2.0).contains(&scale) {
|
||||||
return Err("Interface scale is outside the supported range".to_owned());
|
return Err("Interface scale is outside the supported range".to_owned());
|
||||||
@@ -368,6 +432,22 @@ mod tests {
|
|||||||
.collect()
|
.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]
|
#[test]
|
||||||
fn second_save_replaces_valid_state_without_leaving_temporary_files() {
|
fn second_save_replaces_valid_state_without_leaving_temporary_files() {
|
||||||
let root = temp_dir("replace");
|
let root = temp_dir("replace");
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
|
"label": "main",
|
||||||
"title": "Pi Status UI",
|
"title": "Pi Status UI",
|
||||||
"width": 1100,
|
"width": 1100,
|
||||||
"height": 840,
|
"height": 840,
|
||||||
@@ -23,6 +24,20 @@
|
|||||||
"alwaysOnTop": true,
|
"alwaysOnTop": true,
|
||||||
"skipTaskbar": true,
|
"skipTaskbar": true,
|
||||||
"shadow": 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": {
|
"security": {
|
||||||
|
|||||||
+3
-1
@@ -1,9 +1,11 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import QuickPane from "./quick-pane/QuickPane";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
{getCurrentWindow().label === "quick-pane" ? <QuickPane /> : <App />}
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -221,6 +221,17 @@ export type WorkspaceState = {
|
|||||||
interfaceScale: number;
|
interfaceScale: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QuickPaneSettings = {
|
||||||
|
workspace: string;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
followUpProvider: string;
|
||||||
|
followUpModel: string;
|
||||||
|
instructions: string;
|
||||||
|
maximumSeconds: number;
|
||||||
|
maximumToolCalls: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type UiState = {
|
export type UiState = {
|
||||||
version: 1;
|
version: 1;
|
||||||
directoryOrder: string[];
|
directoryOrder: string[];
|
||||||
@@ -236,4 +247,5 @@ export type UiState = {
|
|||||||
interfaceScale?: number;
|
interfaceScale?: number;
|
||||||
defaultNewSessionWorktree?: string;
|
defaultNewSessionWorktree?: string;
|
||||||
dismissedCollisionWarning?: boolean;
|
dismissedCollisionWarning?: boolean;
|
||||||
|
quickPane?: QuickPaneSettings;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user