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
+10
View File
@@ -355,6 +355,15 @@ fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
Tone::Muted,
false,
),
TranscriptRowKind::Elicitation => push_wrapped(
&mut out,
row,
"? ",
content_text(row),
row_width,
Tone::Muted,
false,
),
TranscriptRowKind::Plan => {}
}
if out.len() > start
@@ -399,6 +408,7 @@ pub fn copy_text(row: &TranscriptRow) -> String {
.and_then(serde_json::Value::as_str)
.unwrap_or("permission requested")
.into(),
TranscriptRowKind::Elicitation => content_text(row),
TranscriptRowKind::Notice => "[notice]".into(),
TranscriptRowKind::Plan => String::new(),
}
+64 -23
View File
@@ -6,11 +6,11 @@ pane. Alongside `docs/WORKTREES.md`, `docs/SIDEBAR.md` and `docs/RESTART.md`,
read this before changing anything that renders a structured session — both
the web and TUI renderers are written against it.
It covers the eight transcript row kinds, what the daemon decides versus what
It covers the nine transcript row kinds, what the daemon decides versus what
a client decides, the two projections and their delta/cursor semantics, the
ACP handshake and the capabilities it declines, the adapter's install
command, and the approval nonce rules. **Non-goal: the detached runner.** It
does not exist yet, and nothing here describes it.
command, the approval nonce rules, and the elicitation flow. **Non-goal: the
detached runner.** It does not exist yet, and nothing here describes it.
## What a structured session is
@@ -88,10 +88,12 @@ declares, deliberately, almost nothing:
Declining all three keeps the daemon out of the file/terminal surface
entirely — the adapter already has direct access to the session's `cwd`, and
routing every read, write, and shell command through the daemon would only
add a hop with no new capability. Every other capability namespace ACP
defines (session extensions, auth, elicitation) is simply left at its
default; the daemon does not advertise support because neither supported
adapter needs it, not because it was weighed and refused.
add a hop with no new capability.
The daemon does declare `elicitation.form` support. This tells the adapter
that form-mode elicitation (`CreateElicitationRequest`) will be handled and
resolved by the operator, rather than declined outright. See
[Elicitations](#elicitations) below.
The one client method that *is* implemented for real is
`session/request_permission` (#139) — see [Approvals](#approvals) below.
@@ -111,12 +113,12 @@ in the projections below.
Codex and Claude differ in what they emit, not in this lifecycle. Codex maps
shell commands, file changes, MCP calls, web search, image generation/view,
reasoning, plans, and token usage into standard ACP chunks and tool calls;
those fold into the same eight rows. Codex's `available_commands_update` and
those fold into the same nine rows. Codex's `available_commands_update` and
`session_info_update`, like equivalent Claude updates, remain opaque
`Notice` rows. Its `session/load` capability is required for daemon recovery
just as it is for Claude.
## The eight transcript row kinds
## The nine transcript row kinds
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
transcript is written in. Every row has a stable `id` (so later deltas can
@@ -132,6 +134,7 @@ crate.
| `ToolCall` | A `tool_call` / `tool_call_update` pair whose content carries no diff. | `tool:<tool_call_id>` — one row for the call's whole life; updates patch it in place, they never create a second row. |
| `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:<tool_call_id>` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. |
| `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:<uuid>` — minted fresh per request, unrelated to the ACP request's own id. |
| `Elicitation` | A daemon-originated elicitation request (`CreateElicitationRequest`), serialized without its nonce. Carries `message` and `requestedSchema` (a JSON Schema object with typed properties). | `elicitation:<uuid>` — minted fresh per request, same lifecycle as `Approval`. |
| `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. |
| `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. Maestro also uses a tagged `maestroTurnMeta` notice as the durable footer for a completed, cancelled, or failed turn. | `notice:<seq>` for ACP updates; `turn_meta:<uuid>` for a turn footer. |
@@ -139,7 +142,7 @@ crate.
`agent-client-protocol` dependency's schema already deserializes (it
currently folds `available_commands_update` and `session_info_update`
there) but the projection's `match` doesn't fold into one of the other
seven kinds. It is not a wire-level forward-compatibility seam:
eight kinds. It is not a wire-level forward-compatibility seam:
`SessionUpdate` is an internally-tagged enum with no unknown-variant
fallback, so a `session/update` whose `sessionUpdate` tag this build's
dependency has never heard of — including a variant gated behind an
@@ -174,6 +177,8 @@ choices, not daemon ones.
- Approval nonce minting, secrecy, and the single-active-approval rule (see
below) — a client never sees an ACP permission request before the daemon
has already turned it into an `Approval` row and a nonce.
- Elicitation nonce minting and the single-active-elicitation rule (see
[Elicitations](#elicitations)) — same lifecycle as approvals.
- What happens to an ACP update variant the dependency decodes but nobody has
taught the projection to special-case (falls back to `Notice` rather than
erroring). A variant the dependency itself can't decode is a deserialization
@@ -195,7 +200,8 @@ choices, not daemon ones.
text → group → text → group rather than lumping every tool in a turn into
one box. Text and thought rows stay outside groups as standalone transcript
items — the operator sees all reasoning without expanding. Resolved
approval rows between tools within a run are absorbed into the group. The
approval and elicitation rows between tools within a run are absorbed into
the group. The
collapsed header reads "N tool calls · Label 'arg'" where the label and
argument come from the last tool row's `splitToolTitle` — the operator
sees which command is current at a glance. Opening the group reveals tool
@@ -213,6 +219,9 @@ choices, not daemon ones.
- How the operator is asked to resolve an `Approval` row — the daemon only
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
never the UI that solicits it.
- How the operator is asked to resolve an `Elicitation` row — the daemon
exposes the three actions (`Accept` with content / `Decline` / `Cancel`);
the client decides the form layout and button treatment.
- Scrolling, collapsing, and every other presentation-only behaviour.
## The two projections
@@ -225,8 +234,8 @@ exposes two independent views, both defined in `maestro-protocol`:
It is intentionally always the *whole* transcript, not a windowed one: a
tool row patched much later must still find its original entry.
- **`ReducedState`** — one whole-session snapshot folded over every event
seen: `turn` (the per-turn indicators above), `pending_approval`, `plan`,
`usage`/`context`, `model`, and `mode`. Like `TranscriptSnapshot`, it is
seen: `turn` (the per-turn indicators above), `pending_approval`,
`pending_elicitation`, `plan`, `usage`/`context`, `model`, and `mode`. Like `TranscriptSnapshot`, it is
always full-history, never a delta — a client that only wants "is a turn
active right now" never has to replay anything to answer that.
@@ -323,6 +332,37 @@ pending decision, entirely on the daemon side:
permanent one (`RejectAlways`) only when that is the sole rejection option
offered; if neither is present, it answers `Cancelled`.
## Elicitations
An ACP `elicitation/create` callback becomes an `Elicitation` row plus a
pending decision, mirroring the approval lifecycle:
- **Generation.** The daemon mints a nonce per request (`Uuid::new_v4()`),
same as approvals.
- **Live-only placement.** The nonce lives in `pending_elicitations` and in
`ReducedState.pending_elicitation.nonce`, never in the row's `data`.
- **One live elicitation at a time.** A new elicitation cancels any
prior pending one before installing itself.
- **Resolution.** `POST /api/sessions/:id/structured/elicitations/:nonce`
accepts `{ "action": "accept", "content": { ... } }`,
`{ "action": "decline" }`, or `{ "action": "cancel" }`. The endpoint
always answers `204` for a known, live structured session, same as
approvals. The three actions map to ACP's `ElicitationAction::Accept`
(with the filled-in values as `ElicitationContentValue` entries),
`Decline`, and `Cancel`.
- **Cancellation.** Turn completion, session cancel, and fatal errors
cancel pending elicitations as `Cancel`, same as approvals.
- **Schema.** The row's `data.requestedSchema` is a JSON Schema object
whose properties describe the fields the agent wants filled:
`StringPropertySchema` (with optional `enum`/`oneOf` for multiple-choice),
`BooleanPropertySchema`, `NumberPropertySchema`, `IntegerPropertySchema`.
Claude Code's common case is a single-property string enum — a
multiple-choice question with titled options.
- **Rendering.** Clients render the `message` as the question and the schema
properties as form fields or button groups. The web client detects the
single-property string enum fast path and renders titled buttons. Resolved
elicitations collapse to a compact one-liner.
## Prompt and cancel: bounded liveness
The web composer accepts up to ten files through its attachment picker, file
@@ -364,8 +404,8 @@ allowed to claim the daemon's idea of "the active turn."
- A submitted prompt is appended as a durable `User` row before it claims the turn and
dispatches to ACP, so the transcript never depends on an adapter echoing the
operator's input. If that append fails, the prompt is not dispatched.
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
and ends the turn's client-visible lifecycle immediately. The prompt queue
- Cancel sends `session/cancel`, clears any pending approval and elicitation
as `Cancelled`, and ends the turn's client-visible lifecycle immediately. The prompt queue
is preserved: after the cancelled turn settles, the queued prompts drain as
the next turn. If the session dies instead (settlement timeout), the queue
is cleared. If the cancelled turn was still *dispatched* to ACP (waiting on
@@ -390,8 +430,8 @@ directory: the sidecar itself (`<id>.json`), plus two next to it:
- **`<id>.events.jsonl`** — the unified event log. Each line is either a raw
ACP `SessionNotification` (kept in its original JSON so old logs stay
readable) or a `{"maestroEvent": "user"|"approval", "row": …}` envelope
for a daemon-generated row. Both share the same 1-based line-position cursor. A
readable) or a `{"maestroEvent": "user"|"approval"|"elicitation", "row": …}`
envelope for a daemon-generated row. Both share the same 1-based line-position cursor. A
line a hard kill left half-written is terminated before the next append, so
it keeps its position and is skipped as malformed rather than corrupting
what follows. An append that fails *while the daemon is running* — a
@@ -453,12 +493,13 @@ itself failing) leaves it `Dead` with the failure reason as
`activity_detail` — the same "tell the operator what to fix" pattern as a
missing adapter binary.
Pending approvals and their nonces never survive any of this. They live only
in the in-memory `pending_approvals` map and the live `ReducedState`, both
gone the instant the daemon that held them exits. Whatever ACP permission
request was mid-flight when the old daemon died was already answered
`Cancelled` on that daemon's own shutdown path, so a resumed session always
starts with no pending approval.
Pending approvals and elicitations and their nonces never survive any of
this. They live only in the in-memory `pending_approvals` /
`pending_elicitations` maps and the live `ReducedState`, both gone the
instant the daemon that held them exits. Whatever ACP permission or
elicitation request was mid-flight when the old daemon died was already
answered `Cancelled` on that daemon's own shutdown path, so a resumed
session always starts with no pending approval or elicitation.
See `docs/RESTART.md` for what a *client* observes across a daemon restart,
now that structured sessions exist alongside terminal ones.
+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 {