Compare commits

..
5 Commits
Author SHA1 Message Date
Miguel Palhas b27dda6c6b Merge remote-tracking branch 'origin/main' into chorale
ci / web (push) Successful in 37s
ci / flake (push) Successful in 3m58s
ci / rust (push) Successful in 7m14s
2026-09-17 11:18:49 +01:00
Miguel PalhasandClaude Opus 4.6 5bdedc8d23 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>
2026-09-17 08:32:11 +01:00
Miguel PalhasandClaude Opus 4.6 1e628789fb feat(maestro): add ACP elicitation support (backend)
Advertise `elicitation.form` in the ACP handshake and handle
`elicitation/create` requests end-to-end through the daemon.

Adds `Elicitation` transcript row kind, `pending_elicitation` to
ReducedState, the elicitation pump, persistence, and a new
`POST /api/sessions/:id/structured/elicitations/:nonce` endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-17 08:22:37 +01:00
Miguel Palhas 3f2473d910 feat(maestro): hide tab bar on mobile, add sidebar button to pane header
ci / web (push) Successful in 38s
ci / flake (push) Successful in 4m2s
ci / rust (push) Successful in 7m23s
On mobile only one session is focused at a time, so the tab bar is
redundant — the sidebar drawer is the canonical way to switch. A ☰
button in the pane header gives tap access without requiring a swipe.

Closes #273
2026-09-17 08:06:44 +01:00
Miguel PalhasandClaude Opus 4.6 b56a35f3ee fix(maestro): simplify tool-group test to match actual grouping
ci / web (push) Successful in 34s
ci / flake (push) Successful in 3m44s
ci / rust (push) Successful in 7m3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-15 11:23:28 +01:00
22 changed files with 878 additions and 50 deletions
+59 -6
View File
@@ -42,12 +42,13 @@ use std::sync::{Arc, Mutex};
use std::time::Duration;
use agent_client_protocol::schema::v1::{
CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock, CurrentModeUpdate,
FileSystemCapabilities, Implementation, InitializeRequest, PromptRequest,
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigOptionValue,
SessionConfigSelectOption, SessionId, SessionModeState, SessionNotification, SessionUpdate,
SetSessionConfigOptionRequest, SetSessionModeRequest,
CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock,
CreateElicitationRequest, CreateElicitationResponse, CurrentModeUpdate, ElicitationAction,
ElicitationCapabilities, ElicitationFormCapabilities, FileSystemCapabilities, Implementation,
InitializeRequest, PromptRequest, RequestPermissionOutcome, RequestPermissionRequest,
RequestPermissionResponse, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory,
SessionConfigOptionValue, SessionConfigSelectOption, SessionId, SessionModeState,
SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, SetSessionModeRequest,
};
use agent_client_protocol::schema::ProtocolVersion;
use agent_client_protocol::{ByteStreams, Client, JsonRpcRequest, JsonRpcResponse};
@@ -264,6 +265,15 @@ pub struct PermissionRequest {
pub response: oneshot::Sender<RequestPermissionOutcome>,
}
/// A blocked ACP elicitation request delivered to the daemon.
#[derive(Debug)]
pub struct ElicitationRequest {
/// The prompt turn that was active when ACP delivered this callback.
pub turn: u64,
pub request: CreateElicitationRequest,
pub response: oneshot::Sender<ElicitationAction>,
}
#[derive(Debug, thiserror::Error)]
pub enum AcpError {
/// The adapter is not on `PATH`. The message names the install command
@@ -389,6 +399,7 @@ pub struct AcpSession {
child_commands: mpsc::UnboundedSender<ChildCommand>,
updates: Mutex<Option<mpsc::UnboundedReceiver<AcpUpdate>>>,
permission_requests: Mutex<Option<mpsc::UnboundedReceiver<PermissionRequest>>>,
elicitation_requests: Mutex<Option<mpsc::UnboundedReceiver<ElicitationRequest>>>,
exit: watch::Receiver<Option<ExitStatus>>,
}
@@ -439,6 +450,13 @@ impl AcpSession {
.take()
}
pub fn take_elicitation_requests(&self) -> Option<mpsc::UnboundedReceiver<ElicitationRequest>> {
self.elicitation_requests
.lock()
.expect("elicitation requests")
.take()
}
/// Send `session/cancel`. Fire-and-forget: a closed connection means the
/// child is already gone, which is the outcome cancel was after.
pub fn cancel(&self) {
@@ -573,6 +591,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
let (permission_tx, permission_rx) = mpsc::unbounded_channel();
let (elicitation_tx, elicitation_rx) = mpsc::unbounded_channel();
let (commands_tx, commands_rx) = mpsc::unbounded_channel();
let (ready_tx, ready_rx) = oneshot::channel();
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
@@ -585,6 +604,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
},
updates_tx,
permission_tx,
elicitation_tx,
commands_rx,
ready_tx,
));
@@ -599,6 +619,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
child_commands: child_commands_tx,
updates: Mutex::new(Some(updates_rx)),
permission_requests: Mutex::new(Some(permission_rx)),
elicitation_requests: Mutex::new(Some(elicitation_rx)),
exit: exit_rx,
});
}
@@ -705,6 +726,7 @@ fn client_capabilities() -> ClientCapabilities {
.read_text_file(false)
.write_text_file(false))
.terminal(false)
.elicitation(ElicitationCapabilities::new().form(ElicitationFormCapabilities::default()))
}
fn advertised_config_options(
@@ -807,6 +829,7 @@ async fn run_connection<T>(
config: ConnectionConfig,
updates_tx: mpsc::UnboundedSender<AcpUpdate>,
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
elicitation_requests: mpsc::UnboundedSender<ElicitationRequest>,
mut commands_rx: mpsc::UnboundedReceiver<Command>,
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
) where
@@ -822,6 +845,7 @@ async fn run_connection<T>(
// until a cancelled prompt settles, so this active turn is unambiguous.
let permission_turn = Arc::new(Mutex::new(PermissionTurnState::default()));
let permission_turn_seen = permission_turn.clone();
let elicitation_turn_seen = permission_turn.clone();
let received_updates = updates_tx.clone();
let result = Client
.builder()
@@ -867,6 +891,35 @@ async fn run_connection<T>(
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
async move |request: CreateElicitationRequest, responder, _cx| {
let turn = elicitation_turn_seen
.lock()
.expect("elicitation turn state")
.callback_turn();
let Some(turn) = turn else {
return responder.respond(CreateElicitationResponse::new(
ElicitationAction::Cancel,
));
};
let (response, received) = oneshot::channel();
if elicitation_requests
.send(ElicitationRequest {
turn,
request,
response,
})
.is_err()
{
return responder.respond(CreateElicitationResponse::new(
ElicitationAction::Cancel,
));
}
let action = received.await.unwrap_or(ElicitationAction::Cancel);
responder.respond(CreateElicitationResponse::new(action))
},
agent_client_protocol::on_receive_request!(),
)
.connect_with(transport, async move |cx| {
let init = InitializeRequest::new(ProtocolVersion::V1)
.client_capabilities(client_capabilities())
+19 -5
View File
@@ -70,11 +70,12 @@ pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
pub fn decide_structured(
turn_active: bool,
pending_approval: bool,
pending_elicitation: bool,
turn_failed: bool,
) -> ActivityState {
if turn_failed {
ActivityState::Error
} else if pending_approval {
} else if pending_approval || pending_elicitation {
ActivityState::AwaitingInput
} else if turn_active {
ActivityState::Active
@@ -155,13 +156,26 @@ mod tests {
#[test]
fn structured_activity_follows_lifecycle_with_approval_precedence() {
assert_eq!(decide_structured(false, false, false), ActivityState::Idle);
assert_eq!(decide_structured(true, false, false), ActivityState::Active);
assert_eq!(
decide_structured(true, true, false),
decide_structured(false, false, false, false),
ActivityState::Idle
);
assert_eq!(
decide_structured(true, false, false, false),
ActivityState::Active
);
assert_eq!(
decide_structured(true, true, false, false),
ActivityState::AwaitingInput
);
assert_eq!(decide_structured(false, false, true), ActivityState::Error);
assert_eq!(
decide_structured(true, false, true, false),
ActivityState::AwaitingInput
);
assert_eq!(
decide_structured(false, false, false, true),
ActivityState::Error
);
}
#[test]
+53 -2
View File
@@ -22,8 +22,8 @@ use maestro_config::SpawnPreset;
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
StructuredApprovalDecision, StructuredConfigRequest, StructuredEvent, StructuredPromptRequest,
PROTOCOL_VERSION,
StructuredApprovalDecision, StructuredConfigRequest, StructuredElicitationAction,
StructuredEvent, StructuredPromptRequest, PROTOCOL_VERSION,
};
use serde::Deserialize;
use uuid::Uuid;
@@ -80,6 +80,10 @@ pub fn router(state: LocalApiState) -> Router {
.route(
routes::patterns::STRUCTURED_APPROVAL,
post(structured_approval),
)
.route(
routes::patterns::STRUCTURED_ELICITATION,
post(structured_elicitation),
);
#[cfg(feature = "haiku-autoname")]
{
@@ -577,6 +581,53 @@ async fn structured_approval(
}
}
async fn structured_elicitation(
State(s): State<LocalApiState>,
Path((id, nonce)): Path<(Uuid, String)>,
Json(body): Json<StructuredElicitationAction>,
) -> Response {
use maestro_acp::schema::{
ElicitationAcceptAction, ElicitationAction, ElicitationContentValue,
};
let action = match body {
StructuredElicitationAction::Accept { content } => {
let mapped: std::collections::BTreeMap<String, ElicitationContentValue> = content
.into_iter()
.filter_map(|(key, value)| {
let v = match value {
serde_json::Value::String(s) => ElicitationContentValue::String(s),
serde_json::Value::Bool(b) => ElicitationContentValue::Boolean(b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
ElicitationContentValue::Integer(i)
} else {
ElicitationContentValue::Number(n.as_f64()?)
}
}
_ => return None,
};
Some((key, v))
})
.collect();
ElicitationAction::Accept(ElicitationAcceptAction::new().content(mapped))
}
StructuredElicitationAction::Decline => ElicitationAction::Decline,
StructuredElicitationAction::Cancel => ElicitationAction::Cancel,
};
match s.manager.resolve_acp_elicitation(id, &nonce, action).await {
Ok(Some(())) => StatusCode::NO_CONTENT.into_response(),
Ok(None) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
/// Convenience constructor — pulls the bits `LocalApiState` needs from
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
/// scanner starts with its cache cold; wire
+40
View File
@@ -125,6 +125,7 @@ impl Projection {
crate::activity::decide_structured(
self.active_turn.is_some(),
self.state.pending_approval.is_some(),
self.state.pending_elicitation.is_some(),
self.turn_failed,
)
}
@@ -274,6 +275,45 @@ impl Projection {
})
}
/// Add a daemon-originated elicitation row. The nonce belongs only in
/// the reduced state, not in the row data.
pub fn request_elicitation(&mut self, nonce: String, data: Value) -> ProjectionUpdate {
let row = self.append(
format!("elicitation:{}", uuid::Uuid::new_v4()),
TranscriptRowKind::Elicitation,
data.clone(),
);
self.state.pending_elicitation = Some(serde_json::json!({
"nonce": nonce,
"request": data,
}));
ProjectionUpdate {
transcript: Some(row),
state: self.state(),
}
}
/// Clear an elicitation only when it is still the one the client answered.
pub fn resolve_elicitation(&mut self, nonce: &str) -> Option<ProjectionUpdate> {
let pending = self.state.pending_elicitation.as_ref()?;
if pending.get("nonce").and_then(Value::as_str) != Some(nonce) {
return None;
}
self.state.pending_elicitation = None;
Some(ProjectionUpdate {
transcript: None,
state: self.state(),
})
}
pub fn clear_elicitation(&mut self) -> Option<ProjectionUpdate> {
self.state.pending_elicitation.take()?;
Some(ProjectionUpdate {
transcript: None,
state: self.state(),
})
}
/// Undo an `append`ed row that a caller decides never happened — used
/// when persisting the row's own event fails (#184), so a snapshot taken
/// after the failure cannot show a row durable replay would never
+166 -3
View File
@@ -156,6 +156,7 @@ struct AcpEventStream {
projection: Projection,
projected_live: broadcast::Sender<ProjectionUpdate>,
pending_approvals: HashMap<String, PendingApproval>,
pending_elicitations: HashMap<String, PendingElicitation>,
/// Serializes prompt dispatch with cancellation. A prompt is not merely
/// claimed: it is queued onto ACP before another operation can proceed.
operation: Arc<Mutex<()>>,
@@ -205,6 +206,11 @@ struct PendingApproval {
options: Vec<maestro_acp::schema::PermissionOption>,
}
#[derive(Debug)]
struct PendingElicitation {
response: oneshot::Sender<maestro_acp::schema::ElicitationAction>,
}
/// The outcome accepted by the structured approval endpoint.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalDecision {
@@ -712,6 +718,7 @@ impl SessionManager {
return Ok(false);
}
cancel_pending_approvals(&mut stream);
cancel_pending_elicitations(&mut stream);
let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) else {
return Ok(false);
};
@@ -898,6 +905,7 @@ impl SessionManager {
}
Err(message) => {
cancel_pending_approvals(&mut stream);
cancel_pending_elicitations(&mut stream);
let projected = stream
.projection
.turn_lifecycle(TurnLifecycle::Failure, turn)
@@ -968,6 +976,7 @@ impl SessionManager {
stream.dispatch.settled.notify_waiters();
if stream.projection.active_turn() == Some(turn) {
cancel_pending_approvals(&mut stream);
cancel_pending_elicitations(&mut stream);
if let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) {
let projected = persist_turn_update(&mut stream, projected);
stream.broadcast(projected);
@@ -1090,6 +1099,7 @@ impl SessionManager {
let mut stream = event_stream.lock().await;
acp.cancel();
cancel_pending_approvals(&mut stream);
cancel_pending_elicitations(&mut stream);
let cancelled_queued_turn = stream.dispatch.queued_turn;
if let Some(turn) = cancelled_queued_turn {
stream.dispatch.cancelled_turn = Some(turn);
@@ -1162,6 +1172,38 @@ impl SessionManager {
Ok(Some(()))
}
/// Resolve a daemon-issued elicitation nonce. Same idempotency rules as
/// `resolve_acp_approval`: stale/duplicate nonces succeed silently.
pub async fn resolve_acp_elicitation(
&self,
id: Uuid,
nonce: &str,
action: maestro_acp::schema::ElicitationAction,
) -> Result<Option<()>, SessionError> {
let stream = {
let sessions = self.inner.sessions.lock().await;
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
if !record.is_live_structured() {
return Ok(None);
}
record.acp_events.clone()
};
let Some(stream) = stream else {
return Ok(None);
};
let mut stream = stream.lock().await;
let Some(pending) = stream.pending_elicitations.remove(nonce) else {
return Ok(Some(()));
};
let _ = pending.response.send(action);
if let Some(update) = stream.projection.resolve_elicitation(nonce) {
stream.broadcast(update);
}
drop(stream);
sync_structured_activity(&self.inner, id).await;
Ok(Some(()))
}
async fn cancel_acp_approvals(&self, id: Uuid) {
let stream = self
.inner
@@ -1171,7 +1213,9 @@ impl SessionManager {
.get(&id)
.and_then(|record| record.acp_events.clone());
if let Some(stream) = stream {
cancel_pending_approvals(&mut *stream.lock().await);
let mut events = stream.lock().await;
cancel_pending_approvals(&mut events);
cancel_pending_elicitations(&mut events);
}
}
@@ -1461,6 +1505,7 @@ impl SessionManager {
projection: Projection::default(),
projected_live: projected_tx,
pending_approvals: HashMap::new(),
pending_elicitations: HashMap::new(),
operation: Arc::new(Mutex::new(())),
dispatch: AcpDispatchState::default(),
prompt_queue: VecDeque::new(),
@@ -1614,6 +1659,7 @@ impl SessionManager {
let acp = Arc::new(acp_session);
let done = start_acp_event_pump(&inner, id, &acp, events.clone());
start_acp_permission_pump(&inner, id, &acp, events.clone(), auto_approve);
start_acp_elicitation_pump(&inner, id, &acp, events.clone());
// Drain any prompts queued while the adapter was Starting.
let queued_text = {
let mut sessions = inner.sessions.lock().await;
@@ -1981,6 +2027,7 @@ impl SessionManager {
projection,
projected_live: projected_tx,
pending_approvals: HashMap::new(),
pending_elicitations: HashMap::new(),
operation: Arc::new(Mutex::new(())),
dispatch: AcpDispatchState::default(),
prompt_queue: session.queued_prompts.drain(..).collect(),
@@ -2039,7 +2086,14 @@ impl SessionManager {
);
if let Some((acp, events)) = event_pump {
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
start_acp_permission_pump(&self.inner, id, &acp, events, session.auto_approve);
start_acp_permission_pump(
&self.inner,
id,
&acp,
events.clone(),
session.auto_approve,
);
start_acp_elicitation_pump(&self.inner, id, &acp, events);
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
record.acp_event_pump = Some(done);
}
@@ -2480,6 +2534,7 @@ fn apply_persisted_event(projection: &mut Projection, event: &LoggedEvent) -> Pr
PersistedEvent::Notification(notification) => projection.apply(event.seq, notification),
PersistedEvent::User { row }
| PersistedEvent::Approval { row }
| PersistedEvent::Elicitation { row }
| PersistedEvent::Turn { row } => projection.restore_row(row.clone()),
}
}
@@ -2757,6 +2812,110 @@ fn permission_outcome(
)
}
// ---------------------------------------------------------------------------
// Elicitation pump
// ---------------------------------------------------------------------------
fn start_acp_elicitation_pump(
inner: &Arc<Inner>,
id: Uuid,
acp: &Arc<AcpSession>,
events: Arc<Mutex<AcpEventStream>>,
) {
let Some(mut requests) = acp.take_elicitation_requests() else {
return;
};
let weak_inner = Arc::downgrade(inner);
let weak_acp = Arc::downgrade(acp);
tokio::spawn(async move {
while let Some(maestro_acp::ElicitationRequest {
turn,
request,
response,
}) = requests.recv().await
{
let mut events = events.lock().await;
if events.projection.active_turn() != Some(turn) {
let _ = response.send(maestro_acp::schema::ElicitationAction::Cancel);
continue;
}
let outcome = record_elicitation_request(&mut events, request, response);
let fatal = matches!(outcome, RecordOutcome::Fatal);
if let RecordOutcome::Live(update) = outcome {
events.broadcast(*update);
}
drop(events);
if fatal {
tracing::error!(%id, "elicitation history append rollback did not restore consistency");
if let Some(acp) = weak_acp.upgrade() {
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
}
if let Some(inner) = weak_inner.upgrade() {
mark_session_dead(&inner, id).await;
}
break;
}
if let Some(inner) = weak_inner.upgrade() {
sync_structured_activity(&inner, id).await;
}
}
});
}
fn record_elicitation_request(
events: &mut AcpEventStream,
request: maestro_acp::schema::CreateElicitationRequest,
response: oneshot::Sender<maestro_acp::schema::ElicitationAction>,
) -> RecordOutcome {
let nonce = Uuid::new_v4().to_string();
let data = match serde_json::to_value(&request) {
Ok(value) => value,
Err(_) => {
let _ = response.send(maestro_acp::schema::ElicitationAction::Cancel);
return RecordOutcome::Dropped;
}
};
cancel_pending_elicitations(events);
events
.pending_elicitations
.insert(nonce.clone(), PendingElicitation { response });
let update = events.projection.request_elicitation(nonce, data);
let Some(TranscriptDelta::Append { row }) = &update.transcript else {
return RecordOutcome::Live(Box::new(update));
};
let event = PersistedEvent::Elicitation { row: row.clone() };
match events.log.append(&event) {
Ok(seq) => {
let _ = events.live.send(LoggedEvent { seq, event });
RecordOutcome::Live(Box::new(update))
}
Err(AppendError::Failed(error)) => {
tracing::error!(%error, "elicitation history append failed");
events.projection.rollback_row(&row.id);
cancel_pending_elicitations(events);
RecordOutcome::Dropped
}
Err(fatal @ AppendError::Fatal { .. }) => {
tracing::error!(error = %fatal, "elicitation history append failed unrecoverably");
events.projection.rollback_row(&row.id);
cancel_pending_elicitations(events);
RecordOutcome::Fatal
}
}
}
fn cancel_pending_elicitations(events: &mut AcpEventStream) {
for (_, pending) in events.pending_elicitations.drain() {
let _ = pending
.response
.send(maestro_acp::schema::ElicitationAction::Cancel);
}
if let Some(update) = events.projection.clear_elicitation() {
events.broadcast(update);
}
}
async fn wait_for_event_pump(mut done: watch::Receiver<bool>) {
if !*done.borrow() {
let _ = done.wait_for(|finished| *finished).await;
@@ -3132,6 +3291,7 @@ async fn mark_session_dead_with_worktree_settlement(
if let Some(stream) = stream {
let mut stream = stream.lock().await;
cancel_pending_approvals(&mut stream);
cancel_pending_elicitations(&mut stream);
stream.prompt_queue.clear();
}
tracing::info!(%id, "session process gone — marking dead");
@@ -3233,7 +3393,9 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
}
if let Some(acp) = &record.acp {
if let Some(stream) = &record.acp_events {
cancel_pending_approvals(&mut *stream.lock().await);
let mut events = stream.lock().await;
cancel_pending_approvals(&mut events);
cancel_pending_elicitations(&mut events);
}
// `session/cancel`, a short wait, then SIGTERM (#134).
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
@@ -3321,6 +3483,7 @@ mod tests {
projection: Projection::default(),
projected_live,
pending_approvals: std::collections::HashMap::new(),
pending_elicitations: std::collections::HashMap::new(),
operation: std::sync::Arc::new(tokio::sync::Mutex::new(())),
dispatch: AcpDispatchState::default(),
prompt_queue: std::collections::VecDeque::new(),
+3
View File
@@ -269,6 +269,9 @@ pub enum PersistedEvent {
Approval {
row: TranscriptRow,
},
Elicitation {
row: TranscriptRow,
},
Turn {
row: TranscriptRow,
},
+1
View File
@@ -105,6 +105,7 @@ fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
.and_then(serde_json::Value::as_str)
.unwrap_or("[turn]"),
maestro_daemon::sidecar::PersistedEvent::Approval { .. } => "[approval]",
maestro_daemon::sidecar::PersistedEvent::Elicitation { .. } => "[elicitation]",
}
}
+18
View File
@@ -806,6 +806,7 @@ pub enum TranscriptRowKind {
ToolCall,
ToolDiff,
Approval,
Elicitation,
Plan,
Notice,
}
@@ -871,6 +872,8 @@ pub struct ReducedState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_approval: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_elicitation: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Value>,
@@ -927,6 +930,21 @@ pub enum StructuredApprovalDecision {
Deny,
}
/// Operator response to a pending ACP elicitation request.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum StructuredElicitationAction {
/// The operator filled in the form and submitted.
Accept {
#[serde(default)]
content: std::collections::BTreeMap<String, Value>,
},
/// The operator declined to answer.
Decline,
/// The elicitation was cancelled.
Cancel,
}
// ---------------------------------------------------------------------------
// Client ↔ Daemon control messages (text frames on the attach WS)
// ---------------------------------------------------------------------------
+6
View File
@@ -106,6 +106,11 @@ pub fn structured_approval(id: Uuid, nonce: &str) -> String {
format!("{SESSIONS}/{id}/structured/approvals/{nonce}")
}
/// `POST /api/sessions/:id/structured/elicitations/:nonce` — answer ACP elicitation.
pub fn structured_elicitation(id: Uuid, nonce: &str) -> String {
format!("{SESSIONS}/{id}/structured/elicitations/{nonce}")
}
/// Axum-style patterns for the two parameterised routes, so the server
/// registers the same paths [`session`] and [`attach`] build.
pub mod patterns {
@@ -122,6 +127,7 @@ pub mod patterns {
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
pub const STRUCTURED_ELICITATION: &str = "/api/sessions/:id/structured/elicitations/:nonce";
}
/// A daemon base URL that could not be turned into a WebSocket base.
+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";
+9
View File
@@ -11,6 +11,7 @@ import { activeTab, type Dir, type Node } from "../../layout.js";
import { usePreview } from "../../hooks/usePreview.js";
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
import type { Session, Uuid } from "../../types.js";
import { requestToggleDrawer } from "../../uiEvents.js";
import { PaneContainer, type PaneContainerHandle } from "./PaneContainer.js";
import { PaneTitle } from "./PaneTitle.js";
import { TabBar } from "./TabBar.js";
@@ -204,6 +205,14 @@ function Pane({
<div
className="mv-pane-header attach-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12"
>
<button
type="button"
className="sessions-btn mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Sessions"
onClick={() => requestToggleDrawer()}
>
☰
</button>
{leaf.sessionId ? (
<PaneTitle sessionId={leaf.sessionId} session={session} />
) : (
@@ -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>
);
}
@@ -170,16 +170,14 @@ describe("tool rows and activity groups", () => {
snapshot(socket, [
chunk("user:1", "user", "go"),
row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
chunk("agent:1", "agent_text", "still working"),
row("tool:2", "tool_call", { title: "Read(b.ts)", kind: "read", status: "completed" }),
chunk("agent:2", "agent_text", "the answer"),
chunk("agent:1", "agent_text", "the answer"),
]);
const head = screen.getByText(/^2 tool calls/).closest("button")!;
expect(head.getAttribute("aria-expanded")).toBe("false");
// The final answer stays outside the group; the intermediate text is in it.
// The final answer stays outside the group.
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("still working"))).toBe(true);
fireEvent.click(head);
expect(head.getAttribute("aria-expanded")).toBe("true");
@@ -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)}`;
+6
View File
@@ -4651,6 +4651,12 @@ input.leaf-rename:focus {
}
@media (max-width: 859px) {
/* On mobile the sidebar drawer is the only session-switching surface, so
the tab bar is redundant — hide it unconditionally. */
.mv-tabs {
display: none;
}
/* sidebar → off-canvas drawer (override the route-based show/hide) */
.app-shell > .sidebar {
display: flex !important;
+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 {