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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user