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
+128 -49
View File
@@ -2,57 +2,136 @@ import { useEffect, useRef, useState } from "react";
import type { Extension } from "../workspace/types";
function controls(container: HTMLElement | null) {
return [...(container?.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
) ?? [])];
return [
...(container?.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
) ?? []),
];
}
export function ExtensionDialog({ extension, onRespond }: { extension?: Extension; onRespond: (response: Record<string, unknown>) => void }) {
const ref = useRef<HTMLElement>(null);
const opener = useRef<HTMLElement | null>(null);
const respond = useRef(onRespond);
const [value, setValue] = useState("");
respond.current = onRespond;
export function ExtensionDialog({
extension,
onRespond,
}: {
extension?: Extension;
onRespond: (response: Record<string, unknown>) => void;
}) {
const ref = useRef<HTMLElement>(null);
const opener = useRef<HTMLElement | null>(null);
const respond = useRef(onRespond);
const [value, setValue] = useState("");
respond.current = onRespond;
useEffect(() => {
if (!extension) return;
opener.current = document.activeElement as HTMLElement | null;
setValue(extension.initialValue ?? extension.text ?? "");
const frame = requestAnimationFrame(() => controls(ref.current)[0]?.focus());
const keydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
respond.current({ cancelled: true });
return;
}
if (event.key !== "Tab") return;
const focusable = controls(ref.current);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", keydown);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("keydown", keydown);
opener.current?.focus();
};
}, [extension]);
useEffect(() => {
if (!extension) return;
opener.current = document.activeElement as HTMLElement | null;
setValue(
extension.prefill ?? extension.initialValue ?? extension.text ?? "",
);
const frame = requestAnimationFrame(() =>
controls(ref.current)[0]?.focus(),
);
const keydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
respond.current({ cancelled: true });
return;
}
if (event.key !== "Tab") return;
const focusable = controls(ref.current);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", keydown);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("keydown", keydown);
opener.current?.focus();
};
}, [extension]);
if (!extension) return null;
return <div className="extension-backdrop"><section ref={ref} className="extension" role="dialog" aria-modal="true" aria-labelledby="extension-title">
<h2 id="extension-title">{extension.title ?? "Pi needs input"}</h2>{extension.message && <p>{extension.message}</p>}
{extension.method === "select" && <div className="extension-options">{extension.options?.map((option) => <button key={option} onClick={() => onRespond({ value: option })}>{option}</button>)}</div>}
{extension.method === "confirm" && <div className="extension-options"><button onClick={() => onRespond({ confirmed: true })}>Confirm</button><button onClick={() => onRespond({ confirmed: false })}>Decline</button></div>}
{extension.method === "input" && <><input aria-label="Extension value" value={value} placeholder={extension.placeholder} onChange={(event) => setValue(event.currentTarget.value)} /><button onClick={() => onRespond({ value })}>Submit</button></>}
{extension.method === "editor" && <><textarea aria-label="Extension value" value={value} onChange={(event) => setValue(event.currentTarget.value)} /><button onClick={() => onRespond({ value })}>Submit</button></>}
<button className="quiet" onClick={() => onRespond({ cancelled: true })}>Cancel</button>
</section></div>;
if (!extension) return null;
return (
<div className="extension-backdrop">
<section
ref={ref}
className="extension"
role="dialog"
aria-modal="true"
aria-labelledby="extension-title"
>
<p className="extension-eyebrow">Pi agent request</p>
<h2 id="extension-title">Review request</h2>
<div className="extension-request">
<p className="extension-request-label">Requested action</p>
<p className="extension-request-action">
{extension.title ?? "Pi needs input"}
</p>
{extension.message && (
<>
<p className="extension-request-label">Request details</p>
<p className="extension-request-details">{extension.message}</p>
</>
)}
</div>
{extension.method === "select" && (
<div className="extension-options">
{extension.options?.map((option) => (
<button key={option} onClick={() => onRespond({ value: option })}>
{option}
</button>
))}
</div>
)}
{extension.method === "confirm" && (
<div className="extension-options">
<button onClick={() => onRespond({ confirmed: true })}>
Allow request
</button>
<button
className="quiet"
onClick={() => onRespond({ confirmed: false })}
>
Decline request
</button>
</div>
)}
{extension.method === "input" && (
<>
<input
aria-label="Extension value"
value={value}
placeholder={extension.placeholder}
onChange={(event) => setValue(event.currentTarget.value)}
/>
<button onClick={() => onRespond({ value })}>Submit</button>
</>
)}
{extension.method === "editor" && (
<>
<textarea
aria-label="Extension value"
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
/>
<button onClick={() => onRespond({ value })}>Submit</button>
</>
)}
<button
className="quiet"
onClick={() => onRespond({ cancelled: true })}
>
Cancel
</button>
</section>
</div>
);
}