379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
import { invoke } from "@tauri-apps/api/core";
|
|
import { listen } from "@tauri-apps/api/event";
|
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { ExtensionDialog } from "../components/ExtensionDialog";
|
|
import "./quick-pane.css";
|
|
import type {
|
|
Extension,
|
|
Model,
|
|
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 [settingsLoaded, setSettingsLoaded] = useState(false);
|
|
const [request, setRequest] = useState("");
|
|
const [thinkingLevel, setThinkingLevel] = useState("medium");
|
|
const [models, setModels] = useState<Model[]>([]);
|
|
const [selectedModel, setSelectedModel] = useState("");
|
|
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 requestRef = useRef(request);
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
const phaseRef = useRef<"idle" | "starting" | "running">("idle");
|
|
const invalid = useMemo(() => settingsError(settings), [settings]);
|
|
|
|
const dismissPane = () => getCurrentWindow().hide();
|
|
const focusInput = () => inputRef.current?.focus();
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
void (async () => {
|
|
let nextSettings: QuickPaneSettings;
|
|
try {
|
|
const state = await invoke<UiState>("load_ui_state");
|
|
if (!active) return;
|
|
nextSettings = { ...defaults, ...state.quickPane };
|
|
} catch (reason) {
|
|
if (active)
|
|
setError(`Could not load settings: ${String(reason)}`);
|
|
return;
|
|
}
|
|
|
|
setSettings(nextSettings);
|
|
setSettingsLoaded(true);
|
|
setSelectedModel(`${nextSettings.provider}/${nextSettings.model}`);
|
|
setStatus("");
|
|
try {
|
|
const catalog = await invoke<{ models?: Model[] }>("get_model_catalog");
|
|
if (active) setModels(catalog?.models ?? []);
|
|
} catch (reason) {
|
|
if (active)
|
|
setError(`Could not load model catalog: ${String(reason)}`);
|
|
}
|
|
})();
|
|
return () => {
|
|
active = false;
|
|
const current = runtimeRef.current;
|
|
if (current)
|
|
invoke("close_quick_runtime", { runtimeId: current.runtimeId }).catch(
|
|
() => undefined,
|
|
);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (settingsLoaded) focusInput();
|
|
}, [settingsLoaded]);
|
|
|
|
useEffect(() => {
|
|
const paneWindow = getCurrentWindow();
|
|
const settingsListener = listen<QuickPaneSettings>(
|
|
"quick-pane-settings-saved",
|
|
(event) => {
|
|
const nextSettings = { ...defaults, ...event.payload };
|
|
setSettings(nextSettings);
|
|
setSelectedModel(`${nextSettings.provider}/${nextSettings.model}`);
|
|
},
|
|
);
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") void dismissPane();
|
|
};
|
|
window.addEventListener("keydown", onKeyDown);
|
|
const focusListener = paneWindow.onFocusChanged((event) => {
|
|
if (event.payload) {
|
|
focusInput();
|
|
} else if (phaseRef.current === "idle" && !requestRef.current.trim()) {
|
|
void dismissPane();
|
|
}
|
|
});
|
|
const closeListener = paneWindow.onCloseRequested((event) => {
|
|
event.preventDefault();
|
|
void dismissPane();
|
|
});
|
|
return () => {
|
|
window.removeEventListener("keydown", onKeyDown);
|
|
void settingsListener.then((unlisten) => unlisten());
|
|
void focusListener.then((unlisten) => unlisten());
|
|
void closeListener.then((unlisten) => unlisten());
|
|
};
|
|
}, []);
|
|
|
|
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 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);
|
|
phaseRef.current = "starting";
|
|
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);
|
|
const [provider, modelId] = selectedModel.split("/", 2);
|
|
await invoke("set_model", {
|
|
agentId: result.runtime.agentId,
|
|
provider: provider || settings.provider,
|
|
modelId: modelId || settings.model,
|
|
});
|
|
await invoke("set_thinking_level", {
|
|
agentId: result.runtime.agentId,
|
|
level: thinkingLevel,
|
|
});
|
|
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,
|
|
});
|
|
phaseRef.current = "running";
|
|
setStatus(
|
|
"Quick request running. Advisory limits are not trusted enforcement.",
|
|
);
|
|
} catch (reason) {
|
|
phaseRef.current = "idle";
|
|
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 className="quick-pane">
|
|
<header style={{ display: "flex", justifyContent: "space-between" }}>
|
|
<strong>Quick Pane</strong>
|
|
<button onClick={dismissPane}>Close</button>
|
|
</header>
|
|
<textarea
|
|
autoFocus
|
|
ref={inputRef}
|
|
value={request}
|
|
onChange={(e) => {
|
|
requestRef.current = e.target.value;
|
|
setRequest(e.target.value);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (
|
|
event.key === "Enter" &&
|
|
!event.shiftKey &&
|
|
!event.nativeEvent.isComposing
|
|
) {
|
|
event.preventDefault();
|
|
void run();
|
|
}
|
|
}}
|
|
placeholder="Ask a quick question or give a quick instruction"
|
|
style={{ width: "100%", minHeight: 90, marginTop: 14 }}
|
|
/>
|
|
<label>
|
|
Model
|
|
<select
|
|
value={selectedModel}
|
|
onChange={(event) => setSelectedModel(event.currentTarget.value)}
|
|
>
|
|
<option value={`${settings.provider}/${settings.model}`}>
|
|
{settings.provider}/{settings.model}
|
|
</option>
|
|
{models.map((model) => (
|
|
<option
|
|
key={`${model.provider}/${model.id}`}
|
|
value={`${model.provider}/${model.id}`}
|
|
>
|
|
{model.provider}/{model.id ?? model.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Thinking
|
|
<select
|
|
value={thinkingLevel}
|
|
onChange={(event) => setThinkingLevel(event.currentTarget.value)}
|
|
>
|
|
<option value="low">Low</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="high">High</option>
|
|
</select>
|
|
</label>
|
|
<button
|
|
disabled={!settingsLoaded || !!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>
|
|
);
|
|
}
|