feat(maestro): add elicitation web UI and TUI stub

ElicitationView renders form-mode questions from the agent:
single-property string enums as a button group (the common
multiple-choice case), general schemas as a form with typed
inputs.

Wired into TranscriptRow, StructuredPane, and the model
helpers. TUI renders elicitation rows as plain text for now.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-09-17 08:32:11 +01:00
co-authored by Claude Opus 4.6
parent 1e628789fb
commit 5bdedc8d23
10 changed files with 496 additions and 30 deletions
+17
View File
@@ -12,6 +12,7 @@ import type {
RecentDirs,
Session,
StructuredApprovalDecision,
StructuredElicitationAction,
StructuredConfigRequest,
StructuredPromptRequest,
Uuid,
@@ -219,4 +220,20 @@ export async function resolveStructuredApproval(
);
}
/** Resolve a pending elicitation. Same idempotency as approvals. */
export async function resolveStructuredElicitation(
id: Uuid,
nonce: string,
action: StructuredElicitationAction,
): Promise<void> {
await jsonOrError(
await fetch(routes.structuredElicitation(id, nonce), {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify(action),
}),
);
}
export { eventsWsUrl, structuredWsUrl } from "./routes.js";
@@ -0,0 +1,229 @@
// An `Elicitation` row. The daemon mints the nonce; this only solicits one
// of the three possible actions (accept, decline, cancel).
import { useState } from "react";
import type {
AcpElicitationProperty,
AcpElicitationSchema,
StructuredElicitationAction,
} from "../../types.js";
export interface ElicitationViewProps {
/** The `CreateElicitationRequest` the row carries — serialized without
* its nonce, which never leaves the daemon's live wire. */
request: {
message?: string;
requestedSchema?: AcpElicitationSchema;
};
/** Whether this is the one live elicitation. */
pending: boolean;
onDecide: (action: StructuredElicitationAction) => Promise<void>;
}
/** For a single-property string enum schema (the common Claude Code
* multiple-choice case), extract the property key and its options. */
function singleSelectEnum(
schema: AcpElicitationSchema | undefined,
): { key: string; options: Array<{ value: string; title: string; description?: string }> } | null {
if (!schema) return null;
const keys = Object.keys(schema.properties);
if (keys.length !== 1) return null;
const key = keys[0];
const prop = schema.properties[key];
if (prop.oneOf && prop.oneOf.length > 0) {
return {
key,
options: prop.oneOf.map((o) => ({
value: o.const,
title: o.title,
description: o.description,
})),
};
}
if (prop.enum && prop.enum.length > 0) {
return {
key,
options: prop.enum.map((v) => ({ value: v, title: v })),
};
}
return null;
}
export function ElicitationView({ request, pending, onDecide }: ElicitationViewProps) {
const [busy, setBusy] = useState(false);
const [formValues, setFormValues] = useState<Record<string, string | number | boolean>>({});
const message = request.message ?? "Input requested";
const schema = request.requestedSchema;
if (!pending) {
return <div className="st-row st-row-elicitation st-elicitation-compact">{message}</div>;
}
const decide = (action: StructuredElicitationAction) => {
setBusy(true);
onDecide(action).catch(() => setBusy(false));
};
const singleSelect = singleSelectEnum(schema);
// Fast path: single-property string enum → button group (the common
// Claude Code multiple-choice question).
if (singleSelect) {
return (
<div className="st-row st-row-elicitation">
<div className="st-elicitation">
<div className="st-elicitation-title">
<span aria-hidden="true">?</span> {message}
</div>
<div className="st-elicitation-options">
{singleSelect.options.map((opt) => (
<button
key={opt.value}
type="button"
className="act"
disabled={busy}
onClick={() =>
decide({ action: "accept", content: { [singleSelect.key]: opt.value } })
}
>
{opt.title}
</button>
))}
</div>
<div className="st-elicitation-acts">
<button
type="button"
className="act deny"
disabled={busy}
onClick={() => decide({ action: "decline" })}
>
Skip
</button>
</div>
</div>
</div>
);
}
// General form: render inputs for each property.
const properties = schema?.properties ?? {};
const propertyKeys = Object.keys(properties);
const requiredSet = new Set(schema?.required ?? []);
const handleSubmit = () => {
decide({ action: "accept", content: formValues });
};
const updateField = (key: string, value: string | number | boolean) => {
setFormValues((prev) => ({ ...prev, [key]: value }));
};
return (
<div className="st-row st-row-elicitation">
<div className="st-elicitation">
<div className="st-elicitation-title">
<span aria-hidden="true">?</span> {message}
</div>
<div className="st-elicitation-form">
{propertyKeys.map((key) => {
const prop = properties[key];
return (
<ElicitationField
key={key}
name={key}
prop={prop}
required={requiredSet.has(key)}
disabled={busy}
value={formValues[key]}
onChange={(v) => updateField(key, v)}
/>
);
})}
</div>
<div className="st-elicitation-acts">
<button type="button" className="act primary" disabled={busy} onClick={handleSubmit}>
Submit
</button>
<button
type="button"
className="act deny"
disabled={busy}
onClick={() => decide({ action: "decline" })}
>
Skip
</button>
</div>
</div>
</div>
);
}
function ElicitationField({
name,
prop,
required,
disabled,
value,
onChange,
}: {
name: string;
prop: AcpElicitationProperty;
required: boolean;
disabled: boolean;
value: string | number | boolean | undefined;
onChange: (value: string | number | boolean) => void;
}) {
const label = prop.title ?? name;
const type = prop.type ?? "string";
if (type === "boolean") {
return (
<label className="st-elicitation-field">
<input
type="checkbox"
checked={typeof value === "boolean" ? value : (prop.default === true)}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
{label}
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
</label>
);
}
if (type === "number" || type === "integer") {
return (
<label className="st-elicitation-field">
{label}
{required && <span className="st-elicitation-required">*</span>}
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
<input
type="number"
step={type === "integer" ? 1 : "any"}
min={prop.minimum}
max={prop.maximum}
value={typeof value === "number" ? value : ""}
disabled={disabled}
onChange={(e) => onChange(type === "integer" ? parseInt(e.target.value) : parseFloat(e.target.value))}
/>
</label>
);
}
// Default: string
return (
<label className="st-elicitation-field">
{label}
{required && <span className="st-elicitation-required">*</span>}
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
<input
type="text"
value={typeof value === "string" ? value : ""}
disabled={disabled}
placeholder={typeof prop.default === "string" ? prop.default : undefined}
onChange={(e) => onChange(e.target.value)}
/>
</label>
);
}
@@ -21,6 +21,7 @@ import {
import {
cancelStructuredTurn,
resolveStructuredApproval,
resolveStructuredElicitation,
sendStructuredPrompt,
setStructuredConfig,
uploadStructuredFile,
@@ -29,6 +30,7 @@ import { formatContextTokens, formatElapsed } from "./render.js";
import type {
AcpSessionConfigOption,
StructuredApprovalDecision,
StructuredElicitationAction,
TranscriptRow as Row,
Uuid,
} from "../../types.js";
@@ -36,6 +38,7 @@ import { Composer } from "./Composer.js";
import {
commandsFromTranscript,
pendingApprovalRowId as pendingApprovalRowIdOf,
pendingElicitationRowId as pendingElicitationRowIdOf,
transcriptItems,
turnIsActive,
} from "./model.js";
@@ -124,9 +127,10 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
const turnActive = turnIsActive(state);
const pendingRowId = useMemo(() => pendingApprovalRowIdOf(rows, state), [rows, state]);
const pendingElicitationRowId = useMemo(() => pendingElicitationRowIdOf(rows, state), [rows, state]);
const items = useMemo(
() => transcriptItems(rows, pendingRowId, turnActive),
[rows, pendingRowId, turnActive],
() => transcriptItems(rows, pendingRowId, turnActive, pendingElicitationRowId),
[rows, pendingRowId, turnActive, pendingElicitationRowId],
);
const commands = useMemo(() => commandsFromTranscript(rows), [rows]);
const configOptions: AcpSessionConfigOption[] = state.config_options?.configOptions ?? [];
@@ -193,6 +197,20 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
[sessionId, state.pending_approval?.nonce, pendingRowId],
);
const decideElicitation = useCallback(
async (rowId: string, action: StructuredElicitationAction): Promise<void> => {
const nonce = state.pending_elicitation?.nonce;
if (!nonce || rowId !== pendingElicitationRowId) return;
try {
await resolveStructuredElicitation(sessionId, nonce, action);
} catch (e: unknown) {
setError(String(e));
throw e;
}
},
[sessionId, state.pending_elicitation?.nonce, pendingElicitationRowId],
);
const dispatchPrompt = useCallback(
(text: string, files: File[]) => {
const generation = ++submission.current;
@@ -311,6 +329,8 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
row={row}
pendingApproval={row.id === pendingRowId}
onDecide={(decision) => decide(row.id, decision)}
pendingElicitation={row.id === pendingElicitationRowId}
onElicitationDecide={(action) => decideElicitation(row.id, action)}
expanded={expandedRows.has(row.id)}
onExpandedChange={(expanded) => setRowExpanded(row.id, expanded)}
imageUrls={imageUrls.current}
@@ -10,14 +10,17 @@ import { useEffect, useRef, type ReactNode } from "react";
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "./render.js";
import type {
AcpContentBlock,
AcpElicitationSchema,
AcpPermissionOption,
AcpToolCallContent,
AcpToolCallLike,
StructuredApprovalDecision,
StructuredElicitationAction,
TranscriptRow as Row,
TurnMeta,
} from "../../types.js";
import { ApprovalView } from "./ApprovalView.js";
import { ElicitationView } from "./ElicitationView.js";
import { Copyable } from "./Copyable.js";
import { DiffView } from "./DiffView.js";
import {
@@ -337,6 +340,9 @@ export interface TranscriptRowProps {
* rules). Every other approval row is history. */
pendingApproval?: boolean;
onDecide?: (decision: StructuredApprovalDecision) => Promise<void>;
/** This row is the one live elicitation. */
pendingElicitation?: boolean;
onElicitationDecide?: (action: StructuredElicitationAction) => Promise<void>;
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
imageUrls?: Map<string, string>;
@@ -348,6 +354,8 @@ export function TranscriptRow({
row,
pendingApproval = false,
onDecide,
pendingElicitation = false,
onElicitationDecide,
expanded = false,
onExpandedChange,
imageUrls,
@@ -374,6 +382,16 @@ export function TranscriptRow({
/>
);
}
case "elicitation": {
const request = (row.data ?? {}) as { message?: string; requestedSchema?: AcpElicitationSchema };
return (
<ElicitationView
request={request}
pending={pendingElicitation}
onDecide={onElicitationDecide ?? (() => Promise.resolve())}
/>
);
}
case "plan":
return null;
case "notice": {
+15 -5
View File
@@ -134,6 +134,11 @@ export function pendingApprovalRowId(rows: TranscriptRow[], state: ReducedState)
return state.pending_approval ? lastRowIdOfKind(rows, "approval") : null;
}
/** Same for elicitations — one live at a time. */
export function pendingElicitationRowId(rows: TranscriptRow[], state: ReducedState): string | null {
return state.pending_elicitation ? lastRowIdOfKind(rows, "elicitation") : null;
}
export function lastRowIdOfKind(rows: TranscriptRow[], kind: TranscriptRowKind): string | null {
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
return null;
@@ -168,6 +173,7 @@ export function activityGroupsByTurn(
rows: TranscriptRow[],
pendingApprovalId: string | null,
_turnActive: boolean,
pendingElicitationId: string | null = null,
): Map<string, ActivityGroup> {
const groups = new Map<string, ActivityGroup>();
let turnId = "before-first-message";
@@ -204,8 +210,10 @@ export function activityGroupsByTurn(
flushRun();
continue;
}
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
if (isResolvedApproval) {
const isResolvedInteraction =
(row.kind === "approval" && row.id !== pendingApprovalId) ||
(row.kind === "elicitation" && row.id !== pendingElicitationId);
if (isResolvedInteraction) {
runPending.push(row);
continue;
}
@@ -239,8 +247,9 @@ export function transcriptItems(
rows: TranscriptRow[],
pendingApprovalId: string | null,
turnActive: boolean,
pendingElicitationId: string | null = null,
): TranscriptItem[] {
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive);
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive, pendingElicitationId);
// Reverse index: row id → group id that contains it.
const rowToGroup = new Map<string, string>();
for (const [groupId, group] of groups) for (const r of group.rows) rowToGroup.set(r.id, groupId);
@@ -259,9 +268,10 @@ export function transcriptItems(
}
if (row.kind === "plan") continue;
if (row.kind === "notice" && isStateOnlyNotice(row)) continue;
// A resolved approval outside a group adds nothing the tool row does not
// already say. A pending one always renders.
// A resolved approval/elicitation outside a group adds nothing the tool
// row does not already say. A pending one always renders.
if (row.kind === "approval" && row.id !== pendingApprovalId) continue;
if (row.kind === "elicitation" && row.id !== pendingElicitationId) continue;
items.push({ type: "row", row });
}
@@ -913,6 +913,90 @@
font-style: italic;
}
/* Elicitation: the structured question the agent asks the operator.
Visually parallel to approvals but accent-tinted instead of warn-tinted. */
.st-elicitation {
border: 1px solid var(--accent);
border-radius: var(--r-2);
background: color-mix(in oklab, var(--accent) 10%, var(--surface));
box-shadow: var(--shadow-1);
padding: var(--sp-3);
display: flex;
flex-direction: column;
gap: var(--sp-2);
min-width: 0;
}
.st-elicitation-title {
color: var(--accent);
font-size: var(--fs-11);
letter-spacing: 0.1em;
text-transform: uppercase;
display: flex;
align-items: center;
gap: var(--sp-2);
}
.st-elicitation-options {
display: flex;
gap: var(--sp-2);
flex-wrap: wrap;
}
.st-elicitation-acts {
display: flex;
gap: var(--sp-2);
flex-wrap: wrap;
}
.st-elicitation-form {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.st-elicitation-field {
display: flex;
flex-direction: column;
gap: var(--sp-1);
font-size: var(--fs-12);
color: var(--ink);
}
.st-elicitation-field input[type="text"],
.st-elicitation-field input[type="number"] {
height: 30px;
padding: 0 var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--r-1);
background: var(--surface);
color: var(--ink);
font: inherit;
}
.st-elicitation-field input[type="checkbox"] {
margin-right: var(--sp-1);
accent-color: var(--accent);
}
.st-elicitation-required {
color: var(--danger);
}
.st-elicitation-desc {
color: var(--muted);
font-size: var(--fs-11);
}
.st-elicitation-compact {
font-size: var(--fs-11);
color: var(--faint);
}
.st-row-elicitation:hover {
background: var(--surface-2);
}
.act {
appearance: none;
font: inherit;
+6
View File
@@ -67,6 +67,12 @@ export function structuredApproval(id: Uuid, nonce: string): string {
return `${session(id)}/structured/approvals/${encodeURIComponent(nonce)}`;
}
/** `POST /api/sessions/:id/structured/elicitations/:nonce` — answer an
* elicitation request. */
export function structuredElicitation(id: Uuid, nonce: string): string {
return `${session(id)}/structured/elicitations/${encodeURIComponent(nonce)}`;
}
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
export function withPathQuery(route: string, value: string): string {
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
+31
View File
@@ -395,6 +395,7 @@ export type TranscriptRowKind =
| "tool_call"
| "tool_diff"
| "approval"
| "elicitation"
| "plan"
| "notice";
@@ -435,6 +436,7 @@ export interface TurnState {
export interface ReducedState {
turn: TurnState;
pending_approval?: { nonce: string; request: AcpRequestPermissionRequest } | null;
pending_elicitation?: { nonce: string; message?: string; schema?: AcpElicitationSchema } | null;
plan?: AcpPlan | null;
usage?: AcpUsage | null;
context?: AcpUsage | null;
@@ -486,6 +488,35 @@ export interface AcpSessionConfigOption {
* string (a bracketless Rust enum), not `{decision: ...}`. */
export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny";
/** `POST /api/sessions/:id/structured/elicitations/:nonce` body. */
export type StructuredElicitationAction =
| { action: "accept"; content: Record<string, string | number | boolean> }
| { action: "decline" }
| { action: "cancel" };
/** Property schema within an ACP elicitation request. */
export interface AcpElicitationProperty {
type?: "string" | "number" | "integer" | "boolean";
title?: string;
description?: string;
default?: string | number | boolean;
enum?: string[];
oneOf?: Array<{ const: string; title: string; description?: string }>;
minLength?: number;
maxLength?: number;
minimum?: number;
maximum?: number;
}
/** The `requested_schema` carried by an ACP `elicitation/create` request. */
export interface AcpElicitationSchema {
type?: "object";
title?: string;
description?: string;
properties: Record<string, AcpElicitationProperty>;
required?: string[];
}
// ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ----
export interface AcpEmbeddedResource {