Files
pi-gui/ui/src/quick-pane/QuickPane.tsx
T

690 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ExtensionDialog } from "../components/ExtensionDialog";
import {
catalogModels,
getModelCatalog,
modelIdentity,
modelIdentityFromValue,
modelIdentityValue,
type ModelIdentity,
} from "../model-catalog";
import "./quick-pane.css";
import type {
Extension,
Model,
QuickPaneSettings,
RuntimeSnapshot,
UiState,
} from "../workspace/types";
const ESCALATION_MARKER = "[ESCALATE]";
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 };
type HistoryEntry = { id: string; request: string; answer: string };
type ActivitySnapshot = {
activeTool?: string;
hasAssistantText: boolean;
};
function historyLabel(request: string) {
const concise = request.replace(/\s+/g, " ").trim();
return concise.length > 42 ? `${concise.slice(0, 39)}…` : concise;
}
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");
}
function toolActivityLabel(tool: string) {
const segments = tool.split(".");
const name = segments[segments.length - 1]?.replace(/[_-]+/g, " ") ?? tool;
const labels: Record<string, string> = {
bash: "Running command",
edit: "Editing file",
find: "Finding files",
grep: "Searching files",
ls: "Listing files",
read: "Reading file",
write: "Writing file",
};
return (
labels[name.toLowerCase()] ??
`${name.charAt(0).toUpperCase()}${name.slice(1)}`
);
}
export default function QuickPane() {
const [settings, setSettings] = useState<QuickPaneSettings>(defaults);
const [settingsLoaded, setSettingsLoaded] = useState(false);
const [request, setRequest] = useState("");
const [currentRequest, setCurrentRequest] = useState("");
const [submissionStarting, setSubmissionStarting] = useState(false);
const [thinkingLevel, setThinkingLevel] = useState("medium");
const [models, setModels] = useState<Model[]>([]);
const [selectedModel, setSelectedModel] = useState<ModelIdentity>({
provider: "",
modelId: "",
});
const [notice, setNotice] = useState("Loading Quick Pane settings…");
const [error, setError] = useState<string>();
const [catalogError, setCatalogError] = useState<string>();
const [runtime, setRuntime] = useState<Runtime>();
const [answer, setAnswer] = useState("");
const [completedRuntimeId, setCompletedRuntimeId] = useState<string>();
const [failedRuntimeId, setFailedRuntimeId] = useState<string>();
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [selectedHistoryId, setSelectedHistoryId] = useState("");
const [escalation, setEscalation] = useState<string>();
const [extension, setExtension] = useState<Extension>();
const [activitySnapshot, setActivitySnapshot] = useState<ActivitySnapshot>({
hasAssistantText: false,
});
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const runtimeRef = useRef<Runtime | undefined>(undefined);
const submittedRequestRef = useRef<
{ runtimeId: string; request: string } | undefined
>(undefined);
const promptSubmittedRuntimeRef = useRef<string | undefined>(undefined);
const completedRuntimeRef = useRef<string | undefined>(undefined);
const submissionStartingRef = useRef(false);
const runStartedAtRef = useRef<number | undefined>(undefined);
const inputRef = useRef<HTMLTextAreaElement>(null);
const invalid = useMemo(() => settingsError(settings), [settings]);
const modelOptions = useMemo(
() => catalogModels(models, [selectedModel]),
[models, selectedModel],
);
const selectedModelValue =
selectedModel.provider && selectedModel.modelId
? modelIdentityValue(selectedModel)
: "";
const runActive =
submissionStarting ||
(!!runtime &&
completedRuntimeId !== runtime.runtimeId &&
failedRuntimeId !== runtime.runtimeId);
const viewingHistory = !!selectedHistoryId && !runActive;
const displayedEntry = viewingHistory
? history.find((entry) => entry.id === selectedHistoryId)
: undefined;
const displayedRequest = displayedEntry?.request ?? currentRequest;
const displayedAnswer = displayedEntry?.answer ?? answer;
const displayedAnswerComplete =
!viewingHistory && !!runtime && completedRuntimeId === runtime.runtimeId;
const activityPhase = submissionStarting
? "Starting"
: runActive && activitySnapshot.activeTool
? toolActivityLabel(activitySnapshot.activeTool)
: runActive && activitySnapshot.hasAssistantText
? "Answering"
: runActive
? "Thinking"
: "";
const activity =
activityPhase === "Thinking"
? `${activityPhase} · ${elapsedSeconds}s`
: activityPhase;
const dismissPane = () => getCurrentWindow().hide();
const focusInput = () => inputRef.current?.focus();
const loadModelCatalog = useCallback(async () => {
try {
setModels(await getModelCatalog());
setCatalogError(undefined);
} catch (reason) {
setCatalogError(`Could not load model catalog: ${String(reason)}`);
}
}, []);
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({
provider: nextSettings.provider,
modelId: nextSettings.model,
});
setNotice("");
if (active) await loadModelCatalog();
})();
return () => {
active = false;
const current = runtimeRef.current;
if (current)
invoke("close_quick_runtime", { runtimeId: current.runtimeId }).catch(
() => undefined,
);
};
}, [loadModelCatalog]);
useEffect(() => {
if (settingsLoaded) focusInput();
}, [settingsLoaded]);
useEffect(() => {
if (!runActive) return;
const updateElapsed = () =>
setElapsedSeconds(
Math.max(
0,
Math.floor(
(Date.now() - (runStartedAtRef.current ?? Date.now())) / 1000,
),
),
);
updateElapsed();
const interval = window.setInterval(updateElapsed, 1_000);
return () => window.clearInterval(interval);
}, [runActive]);
useEffect(() => {
const paneWindow = getCurrentWindow();
const settingsListener = listen<QuickPaneSettings>(
"quick-pane-settings-saved",
(event) => {
const nextSettings = { ...defaults, ...event.payload };
setSettings(nextSettings);
setSelectedModel({
provider: nextSettings.provider,
modelId: 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();
void loadModelCatalog();
}
});
return () => {
window.removeEventListener("keydown", onKeyDown);
void settingsListener.then((unlisten) => unlisten());
void focusListener.then((unlisten) => unlisten());
};
}, [loadModelCatalog]);
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);
const responseComplete =
snapshot.state?.data?.isStreaming === false &&
promptSubmittedRuntimeRef.current === runtime.runtimeId;
const escalated = text.startsWith(ESCALATION_MARKER);
const awaitingEscalationMarker =
!!text && ESCALATION_MARKER.startsWith(text);
const result = awaitingEscalationMarker
? ""
: escalated
? text.slice(ESCALATION_MARKER.length).trim()
: text;
setActivitySnapshot({
activeTool: snapshot.runtime.activeTool,
hasAssistantText: !!text,
});
if (awaitingEscalationMarker) {
setAnswer("");
} else if (text) {
setAnswer(result);
}
if (escalated && responseComplete) {
setEscalation(result || "Quick agent requested a full session.");
}
if (
responseComplete &&
completedRuntimeRef.current !== runtime.runtimeId
) {
completedRuntimeRef.current = runtime.runtimeId;
setCompletedRuntimeId(runtime.runtimeId);
setNotice("");
const submitted = submittedRequestRef.current;
if (submitted?.runtimeId === runtime.runtimeId) {
setRequest((current) =>
current === submitted.request ? "" : current,
);
setHistory((current) =>
[
{
id: runtime.runtimeId,
request: submitted.request,
answer: result,
},
...current.filter((entry) => entry.id !== runtime.runtimeId),
].slice(0, 5),
);
}
window.requestAnimationFrame(focusInput);
}
setExtension(snapshot.extensions[0]);
if (snapshot.runtime.error?.message) {
setError(snapshot.runtime.error.message);
setFailedRuntimeId(runtime.runtimeId);
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 || completedRuntimeId === runtime.runtimeId) return;
const timeout = window.setTimeout(() => {
setEscalation(
`Quick time budget of ${settings.maximumSeconds} seconds reached.`,
);
setFailedRuntimeId(runtime.runtimeId);
invoke("abort", { agentId: runtime.agentId }).catch(() => undefined);
}, settings.maximumSeconds * 1000);
return () => window.clearTimeout(timeout);
}, [completedRuntimeId, runtime, settings.maximumSeconds]);
async function run() {
if (submissionStartingRef.current || runActive) return;
if (invalid || !request.trim())
return setError(invalid ?? "Enter a request.");
submissionStartingRef.current = true;
setSubmissionStarting(true);
const submittedRequest = request;
setCurrentRequest(submittedRequest);
setSelectedHistoryId("");
runStartedAtRef.current = Date.now();
setElapsedSeconds(0);
try {
const previous = runtimeRef.current;
if (previous)
await invoke("close_quick_runtime", {
runtimeId: previous.runtimeId,
}).catch(() => undefined);
runtimeRef.current = undefined;
submittedRequestRef.current = undefined;
promptSubmittedRuntimeRef.current = undefined;
completedRuntimeRef.current = undefined;
setRuntime(undefined);
setCompletedRuntimeId(undefined);
setFailedRuntimeId(undefined);
setError(undefined);
setAnswer("");
setEscalation(undefined);
setExtension(undefined);
setActivitySnapshot({ hasAssistantText: false });
setNotice("");
const result = await invoke<{ runtime: Runtime }>(
"create_quick_runtime",
{
worktreePath: settings.workspace,
},
);
runtimeRef.current = result.runtime;
submittedRequestRef.current = {
runtimeId: result.runtime.runtimeId,
request: submittedRequest,
};
setRuntime(result.runtime);
await invoke("set_model", {
agentId: result.runtime.agentId,
provider: selectedModel.provider || settings.provider,
modelId: selectedModel.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: ${submittedRequest}`;
await invoke("submit_prompt", {
agentId: result.runtime.agentId,
message: guardrails,
});
promptSubmittedRuntimeRef.current = result.runtime.runtimeId;
} catch (reason) {
setError(`Could not start quick request: ${String(reason)}`);
setFailedRuntimeId(runtimeRef.current?.runtimeId);
} finally {
submissionStartingRef.current = false;
setSubmissionStarting(false);
focusInput();
window.requestAnimationFrame(focusInput);
}
}
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() {
const handoffRequest =
request.trim() || submittedRequestRef.current?.request || currentRequest;
if (invalid || !handoffRequest)
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: `${handoffRequest}\n\nQuick Pane escalation: ${escalation ?? "Continue this in a full session."}\n\nPartial quick result:\n${answer}`,
});
setNotice("Full session started with follow-up model.");
} catch (reason) {
setError(`Could not start full session: ${String(reason)}`);
}
}
return (
<main className="quick-pane">
<header className="quick-pane-toolbar">
<div className="quick-pane-primary-controls">
<label className="quick-pane-field quick-pane-model">
<span className="full-label">Model</span>
<span className="compact-label" aria-hidden="true">
M
</span>
<select
aria-label="Model"
value={selectedModelValue}
disabled={runActive}
onChange={(event) => {
const identity = modelIdentityFromValue(
event.currentTarget.value,
);
if (identity) setSelectedModel(identity);
}}
>
<option value="" disabled>
Select model
</option>
{modelOptions.map((model) => {
const identity = modelIdentity(model);
if (!identity) return null;
return (
<option
key={modelIdentityValue(identity)}
value={modelIdentityValue(identity)}
>
{identity.provider}/{identity.modelId}
</option>
);
})}
</select>
</label>
<label className="quick-pane-field quick-pane-thinking">
<span className="full-label">Thinking</span>
<span className="compact-label" aria-hidden="true">
T
</span>
<select
aria-label="Thinking"
value={thinkingLevel}
disabled={runActive}
onChange={(event) => setThinkingLevel(event.currentTarget.value)}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</label>
</div>
<div className="quick-pane-header-actions">
{history.length > 0 && (
<label className="quick-pane-field quick-pane-history">
<span className="full-label">History</span>
<span className="compact-label" aria-hidden="true">
H
</span>
<select
aria-label="Answer history"
value={viewingHistory ? selectedHistoryId : ""}
disabled={runActive}
onChange={(event) =>
setSelectedHistoryId(event.currentTarget.value)
}
>
<option value="">Current</option>
{history.map((entry) => (
<option key={entry.id} value={entry.id}>
{historyLabel(entry.request)}
</option>
))}
</select>
</label>
)}
<button
className="quick-pane-close"
type="button"
aria-label="Close Quick Pane"
title="Close Quick Pane (Esc)"
onClick={dismissPane}
>
<span className="full-label">Close</span>
<span className="compact-label" aria-hidden="true">
×
</span>
</button>
</div>
</header>
<section className="quick-pane-exchange" aria-label="Quick exchange">
{displayedRequest && (
<div className="quick-pane-user-row">
<article className="quick-pane-user-message">
<span className="message-author">You</span>
<p>{displayedRequest}</p>
</article>
</div>
)}
{(viewingHistory
? !!displayedAnswer
: runActive || !!displayedAnswer || displayedAnswerComplete) && (
<article
className="quick-pane-assistant-message"
role="region"
aria-label="Assistant response"
aria-live={viewingHistory ? "off" : "polite"}
aria-busy={!viewingHistory && runActive}
>
{displayedAnswer && (
<>
<span className="message-author">Assistant</span>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{displayedAnswer}
</ReactMarkdown>
</>
)}
{displayedAnswerComplete && (
<span className="sr-only">Response complete.</span>
)}
</article>
)}
{!viewingHistory && activity && (
<div className="quick-pane-activity" aria-hidden="true">
<span className="typing-dots">
<i />
<i />
<i />
</span>
<span>{activity}</span>
</div>
)}
<span
className="quick-pane-activity-announcement sr-only"
aria-live="polite"
aria-atomic="true"
>
{viewingHistory ? "" : activityPhase}
</span>
{escalation && (
<aside className="quick-pane-notice escalation" role="status">
<div>
<strong>Continue in full session</strong>
<p>{escalation}</p>
</div>
<button type="button" onClick={continueFull}>
Continue
</button>
</aside>
)}
{error && (
<aside className="quick-pane-notice error" role="alert">
<p>{error}</p>
{!!runtime && !escalation && (
<button type="button" onClick={continueFull}>
Continue
</button>
)}
</aside>
)}
{catalogError && (
<aside className="quick-pane-notice error" role="alert">
<p>{catalogError}</p>
</aside>
)}
{notice && (
<p className="quick-pane-system-notice" role="status">
{notice}
</p>
)}
<ExtensionDialog extension={extension} onRespond={respond} />
</section>
<div className="quick-pane-composer">
<textarea
className="quick-pane-request"
aria-label="Request"
autoFocus
ref={inputRef}
readOnly={runActive}
aria-busy={runActive}
value={request}
onChange={(event) => setRequest(event.currentTarget.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"
/>
<button
className="quick-pane-send"
type="button"
aria-label={submissionStarting ? "Starting request" : "Send request"}
title="Send (Enter)"
disabled={
runActive || !settingsLoaded || !!invalid || !request.trim()
}
onClick={run}
>
<span aria-hidden="true">{submissionStarting ? "•••" : "↑"}</span>
</button>
<span className="quick-pane-composer-hint">
Shift+Enter for newline
</span>
</div>
</main>
);
}