feat(rpc): add validated desktop Pi RPC command palette

This commit is contained in:
2026-08-24 20:16:51 +02:00
parent 475f95ba70
commit 99084cc5a5
25 changed files with 2170 additions and 344 deletions
+144 -20
View File
@@ -6,6 +6,8 @@ import "./App.css";
import { ConversationWorkspace } from "./components/ConversationWorkspace";
import { DirectorySidebar } from "./components/DirectorySidebar";
import { ExtensionDialog } from "./components/ExtensionDialog";
import { CommandFormDialog } from "./components/CommandFormDialog";
import type { RpcCommand } from "./commands/rpc";
import { SessionPicker } from "./components/SessionPicker";
import { SessionTabs } from "./components/SessionTabs";
import type {
@@ -37,6 +39,7 @@ type LocalOperation = {
export default function App() {
const { state, dispatch, refresh, loadSnapshot } = useWorkspace();
const [status, setStatus] = useState("Connecting to Pi Status Bridge…");
const [commandForm, setCommandForm] = useState<RpcCommand | undefined>();
const [view, setView] = useState<"conversation" | "settings">("conversation");
const [adding, setAdding] = useState(false);
const [folderPath, setFolderPath] = useState("");
@@ -64,6 +67,7 @@ export default function App() {
const mountedRef = useRef(true);
const operationSequence = useRef(0);
const [operation, setOperation] = useState<LocalOperation | undefined>();
const [abortingRuntimeId, setAbortingRuntimeId] = useState<string>();
const [restoreTabFocus, setRestoreTabFocus] = useState(false);
const startOperation = (
kind: LocalOperation["kind"],
@@ -99,6 +103,14 @@ export default function App() {
);
}, [selected, dispatch, operation]);
useEffect(() => {
if (
abortingRuntimeId &&
state.runtimesById[abortingRuntimeId]?.summary.state !== "streaming"
)
setAbortingRuntimeId(undefined);
}, [abortingRuntimeId, state.runtimesById]);
async function createRuntime(path = state.selectedDirectoryPath) {
if (!path || !mountedRef.current) return;
const operationId = startOperation(
@@ -292,9 +304,13 @@ export default function App() {
}
const runtimeId = selected.summary.runtimeId,
id = ++submissionId,
initialDelivery =
selected.summary.state === "streaming" ? "follow_up" : "prompt",
operationId = startOperation(
"submitting",
"Sending prompt to Pi…",
initialDelivery === "follow_up"
? "Queueing follow-up…"
: "Sending prompt to Pi…",
selected.summary.runtimeId,
);
dispatch({
@@ -307,24 +323,49 @@ export default function App() {
(message) => message.role === "user",
).length,
phase: "sending",
delivery: initialDelivery,
},
});
dispatch({ type: "draftChanged", runtimeId, draft: "" });
const sent = await agentCommand(
"submit_prompt",
{ message: text },
false,
selected,
false,
);
if (sent) {
dispatch({ type: "submissionSent", runtimeId, id });
setStatus("Prompt accepted · waiting for Pi…");
} else {
try {
const result = await invoke<{ delivery?: "prompt" | "follow_up" }>(
"submit_prompt",
{ agentId: selected.summary.agentId, message: text },
);
const delivery = result.delivery ?? initialDelivery;
dispatch({ type: "submissionSent", runtimeId, id, delivery });
setStatus(
delivery === "follow_up"
? "Follow-up queued · waiting for Pi…"
: "Prompt accepted · waiting for Pi…",
);
} catch (error) {
setStatus(`Pi command failed: ${String(error)}`);
dispatch({ type: "submissionRemoved", runtimeId, id });
dispatch({ type: "draftChanged", runtimeId, draft: text });
} finally {
finishOperation(operationId);
}
finishOperation(operationId);
}
async function abortSelected() {
const target = selected;
if (!target?.summary.agentId) return;
setAbortingRuntimeId(target.summary.runtimeId);
setStatus("Aborting Pi…");
try {
await invoke("abort", { agentId: target.summary.agentId });
} catch (error) {
setStatus(`Pi command failed: ${String(error)}`);
setAbortingRuntimeId(undefined);
}
}
async function runRpcCommand(
command: RpcCommand,
input: Record<string, unknown> = {},
) {
if (!selected) return;
await agentCommand("pi_rpc_command", { command: command.command, input });
setCommandForm(undefined);
}
async function respond(response: Record<string, unknown>) {
const target = selected;
@@ -342,10 +383,15 @@ export default function App() {
const pendingSubmission = pendingSubmissions[pendingSubmissions.length - 1];
const displayedStatus =
operation?.detail ??
state.newSessionLaunch?.detail ??
(pendingSubmission
? pendingSubmission.phase === "sending"
? "Sending prompt to Pi…"
: "Prompt accepted · waiting for Pi…"
? pendingSubmission.delivery === "follow_up"
? "Queueing follow-up…"
: "Sending prompt to Pi…"
: pendingSubmission.delivery === "follow_up"
? "Follow-up queued · waiting for Pi…"
: "Prompt accepted · waiting for Pi…"
: undefined) ??
(selected?.progress.phase === "working" ||
selected?.progress.phase === "recovering"
@@ -353,11 +399,19 @@ export default function App() {
: undefined) ??
status;
const isWorking =
state.newSessionLaunch?.phase === "starting" ||
selected?.progress.phase === "working" ||
selected?.progress.phase === "recovering";
const isStartingSession =
state.newSessionLaunch?.phase === "starting" ||
operation?.kind === "creating";
const sameDirectoryCollision = directory && directory.openCount > 1;
return (
<main className="app-shell" style={{ zoom: state.interfaceScale }}>
<main
className="app-shell"
style={{ zoom: state.interfaceScale }}
aria-busy={isStartingSession}
>
<header
className="workflow-header"
onMouseDown={(event) => {
@@ -379,15 +433,21 @@ export default function App() {
{isWorking && (
<span className="working-pulse" aria-hidden="true" />
)}
{isWorking ? "Working" : selected.summary.state}
{state.newSessionLaunch?.phase === "starting"
? "Starting"
: isWorking
? "Working"
: selected.summary.state}
</span>
)}
</div>
<div className="status-row">
{isWorking && (
<span className="working-banner" role="status">
<span className="working-pulse" aria-hidden="true" /> Pi is
working
<span className="working-pulse" aria-hidden="true" />{" "}
{state.newSessionLaunch?.phase === "starting"
? "Starting new session"
: "Pi is working"}
</span>
)}
<span role="status" aria-live="polite">
@@ -503,6 +563,29 @@ export default function App() {
{view === "settings" ? (
<section className="settings">
<h2>Session controls</h2>
<label className="default-worktree-control">
Default new-session directory
<select
value={state.defaultNewSessionWorktree ?? ""}
onChange={(event) =>
dispatch({
type: "setDefaultNewSessionWorktree",
worktreePath: event.currentTarget.value || undefined,
})
}
>
<option value="">Use system default</option>
{state.directoryOrder.map((path) => (
<option key={path} value={path}>
{path}
</option>
))}
</select>
<small>
Used by Ctrl+Super+Space. Only currently open directories are
listed.
</small>
</label>
{selected ? (
<>
<p>{selected.summary.worktreePath}</p>
@@ -553,7 +636,7 @@ export default function App() {
})
}
onSubmit={() => void submit()}
onAbort={() => void agentCommand("abort")}
onAbort={() => void abortSelected()}
onScroll={(atBottom, scrollTop) => {
dispatch({
type: "scrollChanged",
@@ -582,6 +665,12 @@ export default function App() {
operation?.kind === "submitting" &&
operation.runtimeId === selected.summary.runtimeId
}
isAborting={abortingRuntimeId === selected.summary.runtimeId}
onCommand={(command) =>
command.input?.length
? setCommandForm(command)
: void runRpcCommand(command)
}
/>
) : (
<section className="empty-workspace">
@@ -605,10 +694,45 @@ export default function App() {
onClose={closePicker}
/>
)}
<CommandFormDialog
command={commandForm}
onCancel={() => setCommandForm(undefined)}
onSubmit={(input) =>
commandForm && void runRpcCommand(commandForm, input)
}
/>
<ExtensionDialog
extension={selected?.extensions[0]}
onRespond={(response) => void respond(response)}
/>
{isStartingSession && (
<div
className="startup-blocker"
role="dialog"
aria-modal="true"
aria-labelledby="startup-blocker-title"
tabIndex={-1}
autoFocus
>
<div className="startup-blocker-card">
<span className="working-bars startup-bars" aria-hidden="true">
<i />
<i />
<i />
</span>
<div>
<strong id="startup-blocker-title">Starting new session</strong>
<p>
{state.newSessionLaunch?.detail ??
"Creating a fresh Pi runtime…"}
</p>
<small>
Pi is loading its session and extensions. Please wait.
</small>
</div>
</div>
</div>
)}
</main>
);
}