166b065ad4
Add in-place session renewal, searchable model selection, clearer progress feedback, tool-result previews, and per-session presentation state. Keep commands, errors, loading, and lifecycle indicators attached to their owning runtime so parallel tabs cannot interfere.
172 lines
4.7 KiB
TypeScript
172 lines
4.7 KiB
TypeScript
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,
|
|
pending = false,
|
|
commandError,
|
|
}: {
|
|
command?: RpcCommand;
|
|
onSubmit: (input: Record<string, unknown>) => void;
|
|
onCancel: () => void;
|
|
pending?: boolean;
|
|
commandError?: string;
|
|
}) {
|
|
const ref = useRef<HTMLElement>(null);
|
|
const opener = useRef<HTMLElement | null>(null);
|
|
const [values, setValues] = useState<Record<string, string | boolean>>({});
|
|
const [validationError, setValidationError] = useState<string>();
|
|
useEffect(() => {
|
|
if (!command) return;
|
|
opener.current = document.activeElement as HTMLElement | null;
|
|
setValues({});
|
|
setValidationError(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 = () => {
|
|
if (pending) return;
|
|
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) {
|
|
setValidationError(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"
|
|
aria-busy={pending}
|
|
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}
|
|
disabled={pending}
|
|
onChange={(event) =>
|
|
setValues({
|
|
...values,
|
|
[field.name]: event.currentTarget.checked,
|
|
})
|
|
}
|
|
/>
|
|
) : field.kind === "enum" ? (
|
|
<select
|
|
aria-label={field.label}
|
|
value={(values[field.name] as string) ?? ""}
|
|
disabled={pending}
|
|
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) ?? ""}
|
|
disabled={pending}
|
|
onChange={(event) =>
|
|
setValues({
|
|
...values,
|
|
[field.name]: event.currentTarget.value,
|
|
})
|
|
}
|
|
/>
|
|
) : (
|
|
<input
|
|
aria-label={field.label}
|
|
value={(values[field.name] as string) ?? ""}
|
|
disabled={pending}
|
|
onChange={(event) =>
|
|
setValues({
|
|
...values,
|
|
[field.name]: event.currentTarget.value,
|
|
})
|
|
}
|
|
/>
|
|
)}
|
|
</label>
|
|
))}
|
|
{(validationError || commandError) && (
|
|
<p role="alert">{validationError || commandError}</p>
|
|
)}
|
|
<div className="extension-options">
|
|
<button disabled={pending} onClick={submit}>
|
|
{pending ? "Running…" : "Run command"}
|
|
</button>
|
|
<button className="quiet" disabled={pending} onClick={onCancel}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|