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
+160
View File
@@ -0,0 +1,160 @@
import { useEffect, useRef, useState } from "react";
import type { RpcCommand, RpcField } from "../commands/rpc";
const controls = (container: HTMLElement | null) =>
Array.from(
container?.querySelectorAll<HTMLElement>(
"button, input, select, textarea",
) ?? [],
).filter((node) => !node.hasAttribute("disabled"));
function valueFor(field: RpcField, value: string | boolean): unknown {
if (field.kind === "boolean") return value === true;
if (field.kind === "json")
return value ? JSON.parse(value as string) : undefined;
return value || undefined;
}
export function CommandFormDialog({
command,
onSubmit,
onCancel,
}: {
command?: RpcCommand;
onSubmit: (input: Record<string, unknown>) => void;
onCancel: () => void;
}) {
const ref = useRef<HTMLElement>(null);
const opener = useRef<HTMLElement | null>(null);
const [values, setValues] = useState<Record<string, string | boolean>>({});
const [error, setError] = useState<string>();
useEffect(() => {
if (!command) return;
opener.current = document.activeElement as HTMLElement | null;
setValues({});
setError(undefined);
const frame = requestAnimationFrame(() =>
controls(ref.current)[0]?.focus(),
);
return () => {
cancelAnimationFrame(frame);
opener.current?.focus();
};
}, [command]);
if (!command) return null;
const fields = command.input ?? [];
const submit = () => {
try {
const input: Record<string, unknown> = {};
for (const field of fields) {
const value = valueFor(
field,
values[field.name] ?? (field.kind === "boolean" ? false : ""),
);
if (field.required && (value === undefined || value === ""))
throw new Error(`${field.label} is required`);
if (value !== undefined) input[field.name] = value;
}
onSubmit(input);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Invalid input");
}
};
return (
<div className="extension-backdrop">
<section
ref={ref}
className="extension"
role="dialog"
aria-modal="true"
aria-labelledby="command-form-title"
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
onCancel();
}
if (event.key === "Tab") {
const items = controls(ref.current),
first = items[0],
last = items[items.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();
}
}
}}
>
<p className="extension-eyebrow">Pi RPC command</p>
<h2 id="command-form-title">/{command.command}</h2>
<p>{command.description}</p>
{fields.map((field) => (
<label key={field.name}>
{field.label}
{field.kind === "boolean" ? (
<input
aria-label={field.label}
type="checkbox"
checked={values[field.name] === true}
onChange={(event) =>
setValues({
...values,
[field.name]: event.currentTarget.checked,
})
}
/>
) : field.kind === "enum" ? (
<select
aria-label={field.label}
value={(values[field.name] as string) ?? ""}
onChange={(event) =>
setValues({
...values,
[field.name]: event.currentTarget.value,
})
}
>
<option value="">Select…</option>
{field.options?.map((option) => (
<option key={option}>{option}</option>
))}
</select>
) : field.kind === "json" ? (
<textarea
aria-label={field.label}
value={(values[field.name] as string) ?? ""}
onChange={(event) =>
setValues({
...values,
[field.name]: event.currentTarget.value,
})
}
/>
) : (
<input
aria-label={field.label}
value={(values[field.name] as string) ?? ""}
onChange={(event) =>
setValues({
...values,
[field.name]: event.currentTarget.value,
})
}
/>
)}
</label>
))}
{error && <p role="alert">{error}</p>}
<div className="extension-options">
<button onClick={submit}>Run command</button>
<button className="quiet" onClick={onCancel}>
Cancel
</button>
</div>
</section>
</div>
);
}