Compare commits
1 Commits
v2.3.0-sam
...
v2.3.1-per
| Author | SHA1 | Date | |
|---|---|---|---|
| 154ef78f7c |
@@ -85,7 +85,9 @@ async function main() {
|
||||
type: 'permission_requested',
|
||||
task_id: prompt.taskId,
|
||||
session_id: prompt.sessionId,
|
||||
kind: prompt.kind,
|
||||
tool_title: prompt.toolTitle,
|
||||
...(prompt.input ? { input: prompt.input } : {}),
|
||||
options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })),
|
||||
} as WsFrame);
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ const CreateBody = z.object({
|
||||
|
||||
const PermissionBody = z.object({
|
||||
option_id: z.string().max(200).nullable(),
|
||||
updated_input: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const ListQuery = z.object({
|
||||
@@ -164,7 +165,7 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const ok = respondToPermission(req.params.id, parsed.data.option_id);
|
||||
const ok = respondToPermission(req.params.id, parsed.data.option_id, parsed.data.updated_input as Record<string, unknown> | undefined);
|
||||
if (!ok) {
|
||||
reply.code(404);
|
||||
return { error: 'no pending permission' };
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
type WriteTextFileResponse,
|
||||
type CreateTerminalRequest,
|
||||
type CreateTerminalResponse,
|
||||
type CreateElicitationRequest,
|
||||
type CreateElicitationResponse,
|
||||
type SessionConfigOption,
|
||||
type ClientSideConnection as ConnectionType,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
@@ -26,7 +28,7 @@ import { spawn } from 'node:child_process';
|
||||
import { findThoughtLevelConfigId } from './acp-derive.js';
|
||||
import { resolveAcpSpawnArgs } from './acp-spawn.js';
|
||||
import { createAcpNdJsonStream } from './acp-stream.js';
|
||||
import { waitForPermissionResponse, cancelPendingPermission } from './permission-waiter.js';
|
||||
import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js';
|
||||
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
|
||||
import { readWorktreeTextFile, writeWorktreeTextFile } from './acp-client-fs.js';
|
||||
import {
|
||||
@@ -254,6 +256,12 @@ class AcpStreamContext {
|
||||
createTerminal: async (_params: CreateTerminalRequest): Promise<CreateTerminalResponse> => {
|
||||
return { terminalId: 'noop' };
|
||||
},
|
||||
unstable_createElicitation: async (params: CreateElicitationRequest): Promise<CreateElicitationResponse> => {
|
||||
if (taskId && sessionId) {
|
||||
return waitForElicitationResponse(taskId, sessionId, agent, modeId, params);
|
||||
}
|
||||
return { action: 'decline' };
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Blocks ACP dispatch on permission prompts until the user responds via API.
|
||||
* Blocks ACP dispatch on permission/elicitation prompts until the user responds via API.
|
||||
*/
|
||||
import type { RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk';
|
||||
import type { RequestPermissionRequest, RequestPermissionResponse, CreateElicitationRequest, CreateElicitationResponse } from '@agentclientprotocol/sdk';
|
||||
import { isUnattendedMode } from './provider-manifest.js';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
|
||||
interface PendingPermission {
|
||||
type: 'permission';
|
||||
request: RequestPermissionRequest;
|
||||
sessionId: string;
|
||||
resolve: (response: RequestPermissionResponse) => void;
|
||||
@@ -14,11 +15,27 @@ interface PendingPermission {
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
const pendingByTask = new Map<string, PendingPermission>();
|
||||
interface PendingElicitation {
|
||||
type: 'elicitation';
|
||||
request: CreateElicitationRequest;
|
||||
sessionId: string;
|
||||
resolve: (response: CreateElicitationResponse) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
type PendingEntry = PendingPermission | PendingElicitation;
|
||||
|
||||
const pendingByTask = new Map<string, PendingEntry>();
|
||||
|
||||
export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation';
|
||||
|
||||
export interface PermissionPrompt {
|
||||
taskId: string;
|
||||
kind: PermissionKind;
|
||||
toolTitle?: string;
|
||||
description?: string;
|
||||
input?: Record<string, unknown>;
|
||||
options: Array<{ optionId: string; label: string }>;
|
||||
}
|
||||
|
||||
@@ -33,10 +50,25 @@ export function setPermissionHooks(next: PermissionHooks): void {
|
||||
hooks = next;
|
||||
}
|
||||
|
||||
function resolveKind(params: RequestPermissionRequest): PermissionKind {
|
||||
const input = params.toolCall?.rawInput;
|
||||
if (input && typeof input === 'object' && !Array.isArray(input) && 'questions' in input && Array.isArray((input as Record<string, unknown>).questions)) {
|
||||
return 'question';
|
||||
}
|
||||
return 'tool';
|
||||
}
|
||||
|
||||
function toPrompt(taskId: string, params: RequestPermissionRequest): PermissionPrompt {
|
||||
const kind = resolveKind(params);
|
||||
const rawInput = params.toolCall?.rawInput;
|
||||
const input = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? rawInput as Record<string, unknown>
|
||||
: undefined;
|
||||
return {
|
||||
taskId,
|
||||
kind,
|
||||
toolTitle: params.toolCall?.title ?? undefined,
|
||||
...(input ? { input } : {}),
|
||||
options: params.options.map((o) => ({
|
||||
optionId: o.optionId,
|
||||
label: o.name,
|
||||
@@ -73,24 +105,33 @@ export function waitForPermissionResponse(
|
||||
resolve({ outcome: { outcome: 'cancelled' } });
|
||||
}, timeoutMs);
|
||||
|
||||
pendingByTask.set(taskId, { request: params, sessionId, resolve, reject, timer });
|
||||
pendingByTask.set(taskId, { type: 'permission', request: params, sessionId, resolve, reject, timer });
|
||||
|
||||
const prompt = toPrompt(taskId, params);
|
||||
void hooks.onPrompt?.({ ...prompt, sessionId });
|
||||
});
|
||||
}
|
||||
|
||||
export function respondToPermission(taskId: string, optionId: string | null): boolean {
|
||||
export function respondToPermission(taskId: string, optionId: string | null, updatedInput?: Record<string, unknown>): boolean {
|
||||
const pending = pendingByTask.get(taskId);
|
||||
if (!pending) return false;
|
||||
|
||||
clearTimeout(pending.timer);
|
||||
pendingByTask.delete(taskId);
|
||||
|
||||
if (optionId) {
|
||||
pending.resolve({ outcome: { outcome: 'selected', optionId } });
|
||||
if (pending.type === 'elicitation') {
|
||||
if (updatedInput) {
|
||||
const content = updatedInput as { [key: string]: string | number | boolean | string[] };
|
||||
pending.resolve({ action: 'accept', content });
|
||||
} else {
|
||||
pending.resolve({ action: 'decline' });
|
||||
}
|
||||
} else {
|
||||
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
||||
if (optionId) {
|
||||
pending.resolve({ outcome: { outcome: 'selected', optionId } });
|
||||
} else {
|
||||
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
||||
}
|
||||
}
|
||||
|
||||
void hooks.onResolved?.(taskId, pending.sessionId);
|
||||
@@ -100,14 +141,67 @@ export function respondToPermission(taskId: string, optionId: string | null): bo
|
||||
export function getPendingPermission(taskId: string): PermissionPrompt | null {
|
||||
const pending = pendingByTask.get(taskId);
|
||||
if (!pending) return null;
|
||||
if (pending.type === 'elicitation') {
|
||||
return elicitationToPrompt(taskId, pending.request);
|
||||
}
|
||||
return toPrompt(taskId, pending.request);
|
||||
}
|
||||
|
||||
function elicitationToPrompt(taskId: string, params: CreateElicitationRequest): PermissionPrompt {
|
||||
const input: Record<string, unknown> = { message: params.message };
|
||||
if ('requestedSchema' in params) {
|
||||
input.requestedSchema = params.requestedSchema;
|
||||
}
|
||||
return {
|
||||
taskId,
|
||||
kind: 'elicitation',
|
||||
toolTitle: params.message,
|
||||
input,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function waitForElicitationResponse(
|
||||
taskId: string,
|
||||
sessionId: string,
|
||||
provider: string,
|
||||
modeId: string | undefined,
|
||||
params: CreateElicitationRequest,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<CreateElicitationResponse> {
|
||||
if (isUnattendedMode(provider, modeId)) {
|
||||
return Promise.resolve({ action: 'decline' });
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const existing = pendingByTask.get(taskId);
|
||||
if (existing) {
|
||||
clearTimeout(existing.timer);
|
||||
existing.reject(new Error('superseded by newer elicitation request'));
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
pendingByTask.delete(taskId);
|
||||
void hooks.onResolved?.(taskId, sessionId);
|
||||
resolve({ action: 'cancel' });
|
||||
}, timeoutMs);
|
||||
|
||||
pendingByTask.set(taskId, { type: 'elicitation', request: params, sessionId, resolve, reject, timer });
|
||||
|
||||
const prompt = elicitationToPrompt(taskId, params);
|
||||
void hooks.onPrompt?.({ ...prompt, sessionId });
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelPendingPermission(taskId: string): void {
|
||||
const pending = pendingByTask.get(taskId);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
pendingByTask.delete(taskId);
|
||||
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
||||
if (pending.type === 'elicitation') {
|
||||
pending.resolve({ action: 'cancel' });
|
||||
} else {
|
||||
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
||||
}
|
||||
void hooks.onResolved?.(taskId, pending.sessionId);
|
||||
}
|
||||
|
||||
@@ -272,7 +272,9 @@ export const PermissionRequestedFrame = z.object({
|
||||
type: z.literal('permission_requested'),
|
||||
task_id: Uuid,
|
||||
session_id: Uuid,
|
||||
kind: z.enum(['tool', 'question', 'plan', 'elicitation']).optional(),
|
||||
tool_title: z.string().optional(),
|
||||
input: z.record(z.unknown()).optional(),
|
||||
options: z.array(PermissionOptionShape),
|
||||
});
|
||||
|
||||
|
||||
@@ -319,10 +319,10 @@ export const api = {
|
||||
}),
|
||||
getTaskPermission: (taskId: string) =>
|
||||
request<PermissionPrompt>(`/api/coder/tasks/${taskId}/permission`),
|
||||
respondTaskPermission: (taskId: string, optionId: string | null) =>
|
||||
respondTaskPermission: (taskId: string, optionId: string | null, updatedInput?: Record<string, unknown>) =>
|
||||
request<{ ok: boolean }>(`/api/coder/tasks/${taskId}/permission`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ option_id: optionId }),
|
||||
body: JSON.stringify({ option_id: optionId, ...(updatedInput ? { updated_input: updatedInput } : {}) }),
|
||||
}),
|
||||
getTaskCommands: (taskId: string) =>
|
||||
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),
|
||||
|
||||
@@ -250,9 +250,13 @@ export interface AgentSessionConfig {
|
||||
thinkingOptionId: string | null;
|
||||
}
|
||||
|
||||
export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation';
|
||||
|
||||
export interface PermissionPrompt {
|
||||
taskId: string;
|
||||
kind?: PermissionKind;
|
||||
toolTitle?: string;
|
||||
input?: Record<string, unknown>;
|
||||
options: Array<{ optionId: string; label: string }>;
|
||||
}
|
||||
|
||||
|
||||
@@ -272,7 +272,9 @@ export const PermissionRequestedFrame = z.object({
|
||||
type: z.literal('permission_requested'),
|
||||
task_id: Uuid,
|
||||
session_id: Uuid,
|
||||
kind: z.enum(['tool', 'question', 'plan', 'elicitation']).optional(),
|
||||
tool_title: z.string().optional(),
|
||||
input: z.record(z.unknown()).optional(),
|
||||
options: z.array(PermissionOptionShape),
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -290,7 +290,9 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
|
||||
} else if (frame.type === 'permission_requested') {
|
||||
handlersRef.current.onPermissionRequested?.({
|
||||
taskId: frame.task_id,
|
||||
kind: frame.kind,
|
||||
toolTitle: frame.tool_title,
|
||||
...(frame.input ? { input: frame.input as Record<string, unknown> } : {}),
|
||||
options: (frame.options ?? []).map((o: { option_id: string; label: string }) => ({
|
||||
optionId: o.option_id,
|
||||
label: o.label,
|
||||
@@ -565,11 +567,11 @@ export function CoderPane({
|
||||
setProviderCommands(commands);
|
||||
}, []);
|
||||
|
||||
const handlePermissionRespond = useCallback(async (optionId: string | null) => {
|
||||
const handlePermissionRespond = useCallback(async (optionId: string | null, updatedInput?: Record<string, unknown>) => {
|
||||
if (!permissionPrompt) return;
|
||||
setPermissionBusy(true);
|
||||
try {
|
||||
await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId);
|
||||
await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId, updatedInput);
|
||||
setPermissionPrompt(null);
|
||||
} finally {
|
||||
setPermissionBusy(false);
|
||||
@@ -716,7 +718,7 @@ export function CoderPane({
|
||||
{permissionPrompt && (
|
||||
<PermissionCard
|
||||
prompt={permissionPrompt}
|
||||
onRespond={(id) => void handlePermissionRespond(id)}
|
||||
onRespond={(id, input) => void handlePermissionRespond(id, input)}
|
||||
busy={permissionBusy}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user