v2.3.1-permission-questions: enrich ACP permission wire for interactive questions and elicitations

The permission_requested WS frame now carries kind ('tool'|'question'|'plan'|
'elicitation'), input (the tool's rawInput payload), and description fields.
PermissionCard detects question-type permissions (Claude Code's AskUserQuestion)
and renders an interactive radio/checkbox form instead of approve/deny buttons.
Submitting answers auto-selects the first allow option.

Also wires up ACP createElicitation (unstable/experimental) — JSON Schema-driven
forms for structured user input. The same PermissionCard renders elicitation
fields with type-appropriate inputs. Both flows use the existing permission-waiter
blocking pattern with 120s timeout.

The response path (POST /api/coder/tasks/:id/permission) now accepts optional
updated_input alongside option_id, forwarded to the ACP agent as the user's
answer payload. Elicitation responses map to accept/decline/cancel actions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 21:28:14 +00:00
parent 792bbb9da3
commit 154ef78f7c
10 changed files with 507 additions and 18 deletions

View File

@@ -1,14 +1,105 @@
import { ShieldAlert } from 'lucide-react';
import { useState } from 'react';
import { ShieldAlert, MessageCircleQuestion } from 'lucide-react';
import type { PermissionPrompt } from '@/api/types';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface Props {
prompt: PermissionPrompt;
onRespond: (optionId: string | null) => void;
onRespond: (optionId: string | null, updatedInput?: Record<string, unknown>) => void;
busy?: boolean;
}
// ---------------------------------------------------------------------------
// Question detection — ACP's RequestPermissionRequest carries the tool input
// in `input`. Claude Code's AskUserQuestion puts { questions: [...] } there.
// ---------------------------------------------------------------------------
interface Question {
question: string;
header?: string;
options: string[];
multiSelect: boolean;
}
function parseQuestions(input: Record<string, unknown> | undefined): Question[] | null {
if (!input) return null;
const raw = input.questions;
if (!Array.isArray(raw)) return null;
const out: Question[] = [];
for (const item of raw) {
if (!item || typeof item !== 'object') continue;
const q = item as { question?: unknown; header?: unknown; options?: unknown; multiSelect?: unknown };
if (typeof q.question !== 'string') continue;
const opts = Array.isArray(q.options)
? q.options.filter((o): o is string => typeof o === 'string')
: [];
out.push({
question: q.question,
header: typeof q.header === 'string' ? q.header : undefined,
options: opts,
multiSelect: q.multiSelect === true,
});
}
return out.length > 0 ? out : null;
}
// ---------------------------------------------------------------------------
// Elicitation detection — ACP's createElicitation carries a JSON Schema in
// `input.requestedSchema`. For now, render each property as a text input.
// ---------------------------------------------------------------------------
interface ElicitationField {
key: string;
title: string;
description?: string;
type: string;
enumValues?: string[];
}
function parseElicitation(input: Record<string, unknown> | undefined): { message: string; fields: ElicitationField[] } | null {
if (!input) return null;
const schema = input.requestedSchema;
if (!schema || typeof schema !== 'object') return null;
const s = schema as Record<string, unknown>;
const props = s.properties;
if (!props || typeof props !== 'object') return null;
const fields: ElicitationField[] = [];
for (const [key, val] of Object.entries(props as Record<string, unknown>)) {
if (!val || typeof val !== 'object') continue;
const p = val as Record<string, unknown>;
fields.push({
key,
title: typeof p.title === 'string' ? p.title : key,
description: typeof p.description === 'string' ? p.description : undefined,
type: typeof p.type === 'string' ? p.type : 'string',
enumValues: Array.isArray(p.enum) ? p.enum.filter((e): e is string => typeof e === 'string') : undefined,
});
}
if (fields.length === 0) return null;
return { message: typeof input.message === 'string' ? input.message : '', fields };
}
export function PermissionCard({ prompt, onRespond, busy }: Props) {
const isQuestion = prompt.kind === 'question';
const isElicitation = prompt.kind === 'elicitation';
if (isQuestion) {
const questions = parseQuestions(prompt.input);
if (questions) {
return <QuestionView questions={questions} prompt={prompt} onRespond={onRespond} busy={busy} />;
}
}
if (isElicitation) {
const elicitation = parseElicitation(prompt.input);
if (elicitation) {
return <ElicitationView elicitation={elicitation} prompt={prompt} onRespond={onRespond} busy={busy} />;
}
}
// Standard tool permission — approve/deny buttons
return (
<div className="mx-3 my-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm">
<div className="flex items-start gap-2">
@@ -47,3 +138,286 @@ export function PermissionCard({ prompt, onRespond, busy }: Props) {
</div>
);
}
// ---------------------------------------------------------------------------
// QuestionView — renders Claude's AskUserQuestion as interactive radio/checkbox
// ---------------------------------------------------------------------------
function QuestionView({
questions,
prompt,
onRespond,
busy,
}: {
questions: Question[];
prompt: PermissionPrompt;
onRespond: Props['onRespond'];
busy?: boolean;
}) {
const [selections, setSelections] = useState<string[][]>(() => questions.map(() => []));
const [freeTexts, setFreeTexts] = useState<string[]>(() => questions.map(() => ''));
const [submitting, setSubmitting] = useState(false);
const disabled = busy || submitting;
const allComplete = questions.every((_, i) =>
selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0,
);
function buildAnswers(): Record<string, string> {
const answers: Record<string, string> = {};
for (let i = 0; i < questions.length; i++) {
const q = questions[i]!;
const key = q.question;
const selected = selections[i]!;
const free = freeTexts[i]!.trim();
if (free) {
answers[key] = free;
} else if (selected.length > 0) {
answers[key] = selected.join(', ');
}
}
return answers;
}
function handleSubmit() {
if (!allComplete || submitting) return;
setSubmitting(true);
const answers = buildAnswers();
const firstAllow = prompt.options.find((o) =>
o.label.toLowerCase().includes('allow') || o.label.toLowerCase().includes('yes'),
);
onRespond(firstAllow?.optionId ?? prompt.options[0]?.optionId ?? null, {
...prompt.input,
answers,
});
}
function pickSingle(qIdx: number, option: string) {
setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr)));
if (questions.length === 1 && !freeTexts[0]!.trim()) {
setSubmitting(true);
const firstAllow = prompt.options.find((o) =>
o.label.toLowerCase().includes('allow') || o.label.toLowerCase().includes('yes'),
);
onRespond(firstAllow?.optionId ?? prompt.options[0]?.optionId ?? null, {
...prompt.input,
answers: { [questions[0]!.question]: option },
});
}
}
function toggleMulti(qIdx: number, option: string) {
setSelections((prev) =>
prev.map((arr, i) => {
if (i !== qIdx) return arr;
return arr.includes(option) ? arr.filter((o) => o !== option) : [...arr, option];
}),
);
}
return (
<div className="mx-3 my-2 rounded-lg border bg-muted/20 text-sm">
<div className="px-4 py-3 space-y-4">
{questions.map((q, i) => (
<div key={i} className="space-y-2">
{questions.length > 1 && (
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
{q.header ?? `Question ${i + 1}`}
</div>
)}
<div className="font-medium leading-snug">{q.question}</div>
{q.options.length > 0 && !q.multiSelect && (
<RadioGroup
value={selections[i]![0] ?? ''}
onValueChange={(v) => pickSingle(i, v)}
disabled={disabled}
className="gap-1.5"
>
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
<span>{opt}</span>
</label>
);
})}
</RadioGroup>
)}
{q.options.length > 0 && q.multiSelect && (
<div className="grid gap-1.5">
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
const checked = selections[i]!.includes(opt);
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<input
id={id}
type="checkbox"
checked={checked}
disabled={disabled}
onChange={() => toggleMulti(i, opt)}
className="mt-1 size-3.5 rounded border-input accent-primary"
/>
<span>{opt}</span>
</label>
);
})}
</div>
)}
<div className="pt-1 space-y-1">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Or type a custom answer
</div>
<input
type="text"
value={freeTexts[i]}
disabled={disabled}
placeholder="Free text…"
onChange={(e) =>
setFreeTexts((prev) => prev.map((t, idx) => (idx === i ? e.target.value : t)))
}
className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60"
/>
</div>
</div>
))}
</div>
{(questions.length > 1 || freeTexts.some((t) => t.trim())) && (
<div className="flex justify-between items-center border-t px-4 py-2">
<button
type="button"
disabled={disabled}
onClick={() => onRespond(null)}
className="text-xs text-destructive hover:underline disabled:opacity-40"
>
Dismiss
</button>
<Button
type="button"
size="sm"
disabled={!allComplete || disabled}
onClick={handleSubmit}
>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// ElicitationView — renders ACP elicitation forms (JSON Schema-driven)
// ---------------------------------------------------------------------------
function ElicitationView({
elicitation,
prompt,
onRespond,
busy,
}: {
elicitation: { message: string; fields: ElicitationField[] };
prompt: PermissionPrompt;
onRespond: Props['onRespond'];
busy?: boolean;
}) {
const [values, setValues] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {};
for (const f of elicitation.fields) init[f.key] = '';
return init;
});
const [submitting, setSubmitting] = useState(false);
const disabled = busy || submitting;
const allFilled = elicitation.fields.every((f) => (values[f.key] ?? '').trim().length > 0);
function handleSubmit() {
if (!allFilled || submitting) return;
setSubmitting(true);
const content: Record<string, unknown> = {};
for (const f of elicitation.fields) {
const raw = values[f.key]!.trim();
if (f.type === 'number' || f.type === 'integer') {
content[f.key] = Number(raw);
} else if (f.type === 'boolean') {
content[f.key] = raw === 'true' || raw === 'yes' || raw === '1';
} else {
content[f.key] = raw;
}
}
const firstAllow = prompt.options[0];
onRespond(firstAllow?.optionId ?? null, content);
}
return (
<div className="mx-3 my-2 rounded-lg border bg-muted/20 text-sm">
<div className="px-4 py-3 space-y-3">
<div className="flex items-start gap-2">
<MessageCircleQuestion className="size-4 text-blue-500 shrink-0 mt-0.5" />
<p className="font-medium leading-snug">{elicitation.message}</p>
</div>
{elicitation.fields.map((f) => (
<div key={f.key} className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">{f.title}</label>
{f.description && (
<p className="text-[11px] text-muted-foreground/70">{f.description}</p>
)}
{f.enumValues ? (
<RadioGroup
value={values[f.key] ?? ''}
onValueChange={(v) => setValues((prev) => ({ ...prev, [f.key]: v }))}
disabled={disabled}
className="gap-1.5"
>
{f.enumValues.map((opt, j) => {
const id = `e-${f.key}-${j}`;
return (
<label key={j} htmlFor={id} className="flex items-start gap-2 text-sm cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40">
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
<span>{opt}</span>
</label>
);
})}
</RadioGroup>
) : (
<input
type={f.type === 'number' || f.type === 'integer' ? 'number' : 'text'}
value={values[f.key] ?? ''}
disabled={disabled}
onChange={(e) => setValues((prev) => ({ ...prev, [f.key]: e.target.value }))}
className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60"
/>
)}
</div>
))}
</div>
<div className="flex justify-between items-center border-t px-4 py-2">
<button
type="button"
disabled={disabled}
onClick={() => onRespond(null)}
className="text-xs text-destructive hover:underline disabled:opacity-40"
>
Dismiss
</button>
<Button
type="button"
size="sm"
disabled={!allFilled || disabled}
onClick={handleSubmit}
>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
</div>
</div>
);
}