feat(quick-pane): add constrained agent window

This commit is contained in:
2026-08-25 18:30:39 +02:00
parent 9f2c840ae7
commit 4f57114f19
16 changed files with 760 additions and 9 deletions
+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,
+42 -1
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,
@@ -352,6 +377,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)) {
@@ -366,15 +395,21 @@ pub fn run() {
}
}))
.setup(move |app| {
launch_new_session(app.handle().clone(), &launch_args);
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,
@@ -434,6 +469,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
@@ -219,6 +219,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[];
@@ -234,4 +245,5 @@ export type UiState = {
interfaceScale?: number;
defaultNewSessionWorktree?: string;
dismissedCollisionWarning?: boolean;
quickPane?: QuickPaneSettings;
};