From ba7fd680049169690d2015bd7324fb2f42c64879 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 6 Sep 2026 16:18:33 +0100 Subject: [PATCH] feat: add structured session picker --- Cargo.lock | 1 + crates/maestro-acp/Cargo.toml | 1 + crates/maestro-acp/src/lib.rs | 316 ++++++++++++++++++++- crates/maestro-acp/tests/fake_acp_agent.py | 50 +++- crates/maestro-acp/tests/spawn.rs | 46 +++ crates/maestro-daemon/src/local_api.rs | 37 ++- crates/maestro-daemon/src/projection.rs | 5 + crates/maestro-daemon/src/session.rs | 51 ++++ crates/maestro-protocol/src/lib.rs | 9 + crates/maestro-protocol/src/routes.rs | 6 + web/src/api.ts | 16 ++ web/src/routes.ts | 5 + web/src/structured.test.ts | 55 +++- web/src/structured.ts | 128 ++++++++- web/src/styles.css | 105 ++++++- web/src/types.ts | 28 ++ 16 files changed, 829 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 65847bd..8e73d88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1477,6 +1477,7 @@ version = "0.1.0" dependencies = [ "agent-client-protocol", "libc", + "serde", "serde_json", "tempfile", "thiserror 1.0.69", diff --git a/crates/maestro-acp/Cargo.toml b/crates/maestro-acp/Cargo.toml index 8c33de0..cab090d 100644 --- a/crates/maestro-acp/Cargo.toml +++ b/crates/maestro-acp/Cargo.toml @@ -10,6 +10,7 @@ description = "ACP client role: one claude-code-acp child per structured session [dependencies] agent-client-protocol.workspace = true libc.workspace = true +serde.workspace = true serde_json.workspace = true thiserror.workspace = true tokio.workspace = true diff --git a/crates/maestro-acp/src/lib.rs b/crates/maestro-acp/src/lib.rs index aba9b1c..d02be53 100644 --- a/crates/maestro-acp/src/lib.rs +++ b/crates/maestro-acp/src/lib.rs @@ -42,13 +42,16 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use agent_client_protocol::schema::v1::{ - CancelNotification, ClientCapabilities, ContentBlock, FileSystemCapabilities, Implementation, - InitializeRequest, LoadSessionRequest, NewSessionRequest, PromptRequest, - RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SessionId, - SessionNotification, + CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock, CurrentModeUpdate, + 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}; +use agent_client_protocol::{ByteStreams, Client, JsonRpcRequest, JsonRpcResponse}; +use serde::{Deserialize, Serialize}; use tokio::io::AsyncBufReadExt; use tokio::sync::{mpsc, oneshot, watch}; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; @@ -78,6 +81,75 @@ pub const TERM_GRACE: Duration = Duration::from_secs(2); /// Bytes of the child's stderr kept for the failure message. const STDERR_TAIL_BYTES: usize = 4096; +const LEGACY_MODEL_CONFIG_ID: &str = "__maestro_legacy_model"; +const LEGACY_MODE_CONFIG_ID: &str = "__maestro_legacy_mode"; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/new", response = ExtendedNewSessionResponse)] +#[serde(rename_all = "camelCase")] +struct ExtendedNewSessionRequest { + cwd: PathBuf, + mcp_servers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +struct ExtendedNewSessionResponse { + session_id: SessionId, + #[serde(default)] + modes: Option, + #[serde(default)] + config_options: Option>, + #[serde(default)] + models: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/load", response = ExtendedLoadSessionResponse)] +#[serde(rename_all = "camelCase")] +struct ExtendedLoadSessionRequest { + session_id: SessionId, + cwd: PathBuf, + mcp_servers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +struct ExtendedLoadSessionResponse { + #[serde(default)] + modes: Option, + #[serde(default)] + config_options: Option>, + #[serde(default)] + models: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyModelState { + current_model_id: String, + available_models: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyModel { + model_id: String, + name: String, + #[serde(default)] + description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/set_model", response = LegacySetModelResponse)] +#[serde(rename_all = "camelCase")] +struct LegacySetModelRequest { + session_id: SessionId, + model_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +struct LegacySetModelResponse {} /// What [`spawn`] needs to start one adapter. #[derive(Debug, Clone)] @@ -168,6 +240,11 @@ enum Command { turn: u64, done: oneshot::Sender>, }, + SetConfig { + config_id: String, + value: SessionConfigOptionValue, + done: oneshot::Sender>, + }, Close, } @@ -301,6 +378,23 @@ impl AcpSession { Ok(done_rx) } + /// Change one option advertised by the ACP agent. + pub fn set_config( + &self, + config_id: String, + value: SessionConfigOptionValue, + ) -> Result>, String> { + let (done_tx, done_rx) = oneshot::channel(); + self.commands + .send(Command::SetConfig { + config_id, + value, + done: done_tx, + }) + .map_err(|_| "ACP connection is closed".to_string())?; + Ok(done_rx) + } + /// End the child: `session/cancel`, wait `cancel_grace`, `SIGTERM`, wait /// `term_grace`, `SIGKILL`. Returns once the child has been reaped. pub async fn terminate(&self, cancel_grace: Duration, term_grace: Duration) { @@ -513,6 +607,91 @@ fn client_capabilities() -> ClientCapabilities { .terminal(false) } +fn advertised_config_options( + mut options: Vec, + modes: Option<&SessionModeState>, + models: Option<&LegacyModelState>, +) -> Vec { + let has_model = options + .iter() + .any(|option| matches!(option.category, Some(SessionConfigOptionCategory::Model))); + let has_mode = options + .iter() + .any(|option| matches!(option.category, Some(SessionConfigOptionCategory::Mode))); + if let Some(models) = models.filter(|_| !has_model) { + let choices = models + .available_models + .iter() + .map(|model| { + let mut option = + SessionConfigSelectOption::new(model.model_id.clone(), &model.name); + option.description.clone_from(&model.description); + option + }) + .collect::>(); + options.push( + SessionConfigOption::select( + LEGACY_MODEL_CONFIG_ID, + "Model", + models.current_model_id.clone(), + choices, + ) + .category(SessionConfigOptionCategory::Model), + ); + } + if let Some(modes) = modes.filter(|_| !has_mode) { + let choices = modes + .available_modes + .iter() + .map(|mode| { + let mut option = SessionConfigSelectOption::new(mode.id.0.clone(), &mode.name); + option.description.clone_from(&mode.description); + option + }) + .collect::>(); + options.push( + SessionConfigOption::select( + LEGACY_MODE_CONFIG_ID, + "Mode", + modes.current_mode_id.0.clone(), + choices, + ) + .category(SessionConfigOptionCategory::Mode), + ); + } + options +} + +fn replace_config_value( + options: &Arc>>, + config_id: &str, + value: &SessionConfigOptionValue, +) -> Vec { + let mut options = options.lock().expect("config options"); + if let Some(option) = options + .iter_mut() + .find(|option| option.id.0.as_ref() == config_id) + { + match (&mut option.kind, value) { + (SessionConfigKind::Select(select), SessionConfigOptionValue::ValueId { value }) => { + select.current_value = value.clone(); + } + (SessionConfigKind::Boolean(boolean), SessionConfigOptionValue::Boolean { value }) => { + boolean.current_value = *value; + } + _ => {} + } + } + options.clone() +} + +fn config_notification(session_id: &str, options: Vec) -> SessionNotification { + SessionNotification::new( + SessionId::new(session_id), + SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(options)), + ) +} + async fn run_connection( transport: T, cwd: PathBuf, @@ -534,6 +713,7 @@ async fn run_connection( // 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 received_updates = updates_tx.clone(); let result = Client .builder() .name("maestro") @@ -544,7 +724,7 @@ async fn run_connection( return Ok(()); } // A dropped receiver only means nobody wants updates any more. - let _ = updates_tx.send(notification); + let _ = received_updates.send(notification); Ok(()) }, agent_client_protocol::on_receive_notification!(), @@ -589,7 +769,7 @@ async fn run_connection( return Ok(()); } }; - let session_id = match load_session { + let (session_id, modes, initial_options, models) = match load_session { Some(existing) => { if !initialized.agent_capabilities.load_session { let _ = ready_tx.send(Err(ReadyError::Handshake(format!( @@ -601,7 +781,11 @@ async fn run_connection( let (loaded_tx, loaded_rx) = oneshot::channel(); let replaying_done = replaying.clone(); if let Err(e) = cx - .send_request(LoadSessionRequest::new(existing.clone(), cwd)) + .send_request(ExtendedLoadSessionRequest { + session_id: SessionId::new(existing.as_str()), + cwd, + mcp_servers: vec![], + }) .on_receiving_result(move |result| { replaying_done.store(false, Ordering::SeqCst); async move { @@ -613,8 +797,8 @@ async fn run_connection( let _ = ready_tx.send(Err(ready_error(e))); return Ok(()); } - match loaded_rx.await { - Ok(Ok(_)) => {} + let loaded = match loaded_rx.await { + Ok(Ok(loaded)) => loaded, Ok(Err(e)) => { let _ = ready_tx.send(Err(ready_error(e))); return Ok(()); @@ -625,21 +809,48 @@ async fn run_connection( ))); return Ok(()); } - } - existing + }; + (existing, loaded.modes, loaded.config_options.unwrap_or_default(), loaded.models) } None => match cx - .send_request(NewSessionRequest::new(cwd)) + .send_request(ExtendedNewSessionRequest { + cwd, + mcp_servers: vec![], + }) .block_task() .await { - Ok(created) => created.session_id.0.to_string(), + Ok(created) => ( + created.session_id.0.to_string(), + created.modes, + created.config_options.unwrap_or_default(), + created.models, + ), Err(e) => { let _ = ready_tx.send(Err(ready_error(e))); return Ok(()); } }, }; + let config_options = Arc::new(Mutex::new(advertised_config_options( + initial_options, + modes.as_ref(), + models.as_ref(), + ))); + if let Some(modes) = &modes { + let _ = updates_tx.send(SessionNotification::new( + SessionId::new(session_id.as_str()), + SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new( + modes.current_mode_id.clone(), + )), + )); + } + if !config_options.lock().expect("config options").is_empty() { + let _ = updates_tx.send(config_notification( + &session_id, + config_options.lock().expect("config options").clone(), + )); + } let _ = ready_tx.send(Ok(session_id.clone())); loop { @@ -689,6 +900,83 @@ async fn run_connection( } } } + Some(Command::SetConfig { config_id, value, done }) => { + let done = Arc::new(Mutex::new(Some(done))); + let callback_done = done.clone(); + let callback_options = config_options.clone(); + let callback_updates = updates_tx.clone(); + let callback_session = session_id.clone(); + let callback_config_id = config_id.clone(); + let callback_value = value.clone(); + let sent = if config_id == LEGACY_MODEL_CONFIG_ID { + let Some(model_id) = value.as_value_id() else { + let _ = done.lock().expect("config sender").take() + .map(|done| done.send(Err("model must be a string value".into()))); + continue; + }; + cx.send_request(LegacySetModelRequest { + session_id: SessionId::new(session_id.as_str()), + model_id: model_id.0.to_string(), + }).on_receiving_result(move |result| async move { + if result.is_ok() { + let options = replace_config_value(&callback_options, &callback_config_id, &callback_value); + let _ = callback_updates.send(config_notification(&callback_session, options)); + } + if let Some(done) = callback_done.lock().expect("config sender").take() { + let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string())); + } + Ok(()) + }) + } else if config_id == LEGACY_MODE_CONFIG_ID { + let Some(mode_id) = value.as_value_id() else { + let _ = done.lock().expect("config sender").take() + .map(|done| done.send(Err("mode must be a string value".into()))); + continue; + }; + cx.send_request(SetSessionModeRequest::new( + SessionId::new(session_id.as_str()), + mode_id.0.to_string(), + )).on_receiving_result(move |result| async move { + if result.is_ok() { + let options = replace_config_value(&callback_options, &callback_config_id, &callback_value); + let _ = callback_updates.send(config_notification(&callback_session, options)); + let _ = callback_updates.send(SessionNotification::new( + SessionId::new(callback_session.as_str()), + SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new( + callback_value.as_value_id().expect("checked mode value").0.clone(), + )), + )); + } + if let Some(done) = callback_done.lock().expect("config sender").take() { + let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string())); + } + Ok(()) + }) + } else { + cx.send_request(SetSessionConfigOptionRequest::new( + SessionId::new(session_id.as_str()), + config_id, + value, + )) + .on_receiving_result(move |result| async move { + if let Ok(response) = &result { + let mut current = callback_options.lock().expect("config options"); + current.retain(|option| option.id.0.as_ref().starts_with("__maestro_legacy_")); + current.extend(response.config_options.clone()); + let _ = callback_updates.send(config_notification(&callback_session, current.clone())); + } + if let Some(done) = callback_done.lock().expect("config sender").take() { + let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string())); + } + Ok(()) + }) + }; + if let Err(error) = sent { + if let Some(done) = done.lock().expect("config sender").take() { + let _ = done.send(Err(error.to_string())); + } + } + } Some(Command::Close) | None => break, }, _ = cx.incoming_closed() => break, diff --git a/crates/maestro-acp/tests/fake_acp_agent.py b/crates/maestro-acp/tests/fake_acp_agent.py index 6730a9c..01a73a8 100755 --- a/crates/maestro-acp/tests/fake_acp_agent.py +++ b/crates/maestro-acp/tests/fake_acp_agent.py @@ -36,6 +36,7 @@ Environment knobs, all optional: FAKE_ACP_PROMPT_FAIL=1 answer every `session/prompt` with a JSON-RPC error FAKE_ACP_PROMPT_FAIL_ONCE=1 answer only the first `session/prompt` with a JSON-RPC error + FAKE_ACP_SETTINGS=1 advertise legacy model/mode and modern effort options FAKE_ACP_PERMISSION_AFTER_CANCEL=1 defer the first prompt's permission callback until a later prompt @@ -78,6 +79,41 @@ prompt_failures = 0 pending_prompt_id = None pending_permission_id = None delayed_permission = False +current_model = "opus" +current_mode = "default" +current_effort = "high" + + +def settings_response(include_session=False): + result = {} + if include_session: + result["sessionId"] = session_id + if os.environ.get("FAKE_ACP_SETTINGS") == "1": + result.update({ + "models": { + "currentModelId": current_model, + "availableModels": [ + {"modelId": "opus", "name": "Opus"}, + {"modelId": "sonnet", "name": "Sonnet"}, + ], + }, + "modes": { + "currentModeId": current_mode, + "availableModes": [ + {"id": "default", "name": "Default"}, + {"id": "plan", "name": "Plan"}, + ], + }, + "configOptions": [{ + "id": "effort", "name": "Effort", "category": "thought_level", + "type": "select", "currentValue": current_effort, + "options": [ + {"value": "low", "name": "Low"}, + {"value": "high", "name": "High"}, + ], + }], + }) + return result def chunk(text): @@ -139,7 +175,7 @@ for raw in sys.stdin: while gate and not os.path.exists(gate): time.sleep(0.01) time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000) - send({"jsonrpc": "2.0", "id": rid, "result": {"sessionId": session_id}}) + send({"jsonrpc": "2.0", "id": rid, "result": settings_response(True)}) send(chunk("hello from fake")) if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1": sys.exit(0) @@ -152,8 +188,18 @@ for raw in sys.stdin: continue for i in range(int(os.environ.get("FAKE_ACP_LOAD_REPLAY", "0"))): send(chunk(f"replayed {i + 1}")) - send({"jsonrpc": "2.0", "id": rid, "result": {}}) + send({"jsonrpc": "2.0", "id": rid, "result": settings_response()}) send(chunk("hello after load")) + elif method == "session/set_model": + current_model = params.get("modelId") + send({"jsonrpc": "2.0", "id": rid, "result": {}}) + elif method == "session/set_mode": + current_mode = params.get("modeId") + send({"jsonrpc": "2.0", "id": rid, "result": {}}) + elif method == "session/set_config_option": + current_effort = params.get("value") + send({"jsonrpc": "2.0", "id": rid, "result": { + "configOptions": settings_response()["configOptions"]}}) elif method == "session/cancel": if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1": send(chunk("final on cancel")) diff --git a/crates/maestro-acp/tests/spawn.rs b/crates/maestro-acp/tests/spawn.rs index 36a8e35..730276f 100644 --- a/crates/maestro-acp/tests/spawn.rs +++ b/crates/maestro-acp/tests/spawn.rs @@ -180,6 +180,52 @@ async fn session_updates_reach_the_taken_receiver() { .await; } +#[tokio::test] +async fn advertised_settings_are_normalized_and_mutable() { + let fake = Fake::new(); + let cwd = TempDir::new().unwrap(); + let mut config = fake.config(cwd.path()); + config.env.insert("FAKE_ACP_SETTINGS".into(), "1".into()); + let session = maestro_acp::spawn(config).await.expect("spawn"); + let mut updates = session.take_updates().expect("updates"); + + let initial = loop { + let update = tokio::time::timeout(Duration::from_secs(5), updates.recv()) + .await + .expect("settings update") + .expect("channel open"); + if let SessionUpdate::ConfigOptionUpdate(options) = update.update { + break options.config_options; + } + }; + assert_eq!(initial.len(), 3); + assert!(initial + .iter() + .any(|option| option.id.0.as_ref() == "effort")); + assert!(initial + .iter() + .any(|option| option.id.0.as_ref() == "__maestro_legacy_model")); + assert!(initial + .iter() + .any(|option| option.id.0.as_ref() == "__maestro_legacy_mode")); + + let changed = session + .set_config( + "__maestro_legacy_model".into(), + maestro_acp::schema::SessionConfigOptionValue::value_id("sonnet"), + ) + .expect("queue model change"); + changed + .await + .expect("response delivered") + .expect("model changed"); + fake.wait_for_log("recv session/set_model").await; + + session + .terminate(Duration::from_millis(100), Duration::from_secs(2)) + .await; +} + #[tokio::test] async fn terminate_sends_cancel_then_sigterm() { let fake = Fake::new(); diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index e5ceee4..ab9e5d4 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -21,12 +21,13 @@ use maestro_config::SpawnPreset; use maestro_protocol::{ routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest, - StructuredApprovalDecision, StructuredEvent, StructuredPromptRequest, PROTOCOL_VERSION, + StructuredApprovalDecision, StructuredConfigRequest, StructuredEvent, StructuredPromptRequest, + PROTOCOL_VERSION, }; use serde::Deserialize; use uuid::Uuid; -use crate::session::{QueueAcpPrompt, SessionError, SessionManager}; +use crate::session::{QueueAcpPrompt, SessionError, SessionManager, SetAcpConfig}; use crate::sidecar::EventCursor; #[derive(Debug, Clone)] @@ -70,6 +71,7 @@ pub fn router(state: LocalApiState) -> Router { .route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay)) .route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt)) .route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel)) + .route(routes::patterns::STRUCTURED_CONFIG, post(structured_config)) .route( routes::patterns::STRUCTURED_APPROVAL, post(structured_approval), @@ -472,6 +474,37 @@ async fn structured_cancel(State(s): State, Path(id): Path) } } +async fn structured_config( + State(s): State, + Path(id): Path, + Json(body): Json, +) -> Response { + match s + .manager + .set_acp_config(id, body.config_id, body.value) + .await + { + Ok(SetAcpConfig::Updated) => StatusCode::NO_CONTENT.into_response(), + Ok(SetAcpConfig::InvalidValue) => error_response( + StatusCode::BAD_REQUEST, + "invalid_config_value", + "configuration values must be strings or booleans", + ), + Ok(SetAcpConfig::Unavailable) => error_response( + StatusCode::CONFLICT, + "not_structured", + "session is not a live structured session", + ), + Ok(SetAcpConfig::Failed(message)) => { + error_response(StatusCode::BAD_GATEWAY, "acp_config_failed", &message) + } + Err(e) => { + let info = e.to_error_info(); + error_response(session_error_status(&info.code), &info.code, &info.message) + } + } +} + async fn structured_approval( State(s): State, Path((id, nonce)): Path<(Uuid, String)>, diff --git a/crates/maestro-daemon/src/projection.rs b/crates/maestro-daemon/src/projection.rs index 576764f..1f31897 100644 --- a/crates/maestro-daemon/src/projection.rs +++ b/crates/maestro-daemon/src/projection.rs @@ -79,6 +79,7 @@ impl Projection { SessionUpdate::ConfigOptionUpdate(options) => { let value = to_value(options); self.state.model = model_from_options(&value).or_else(|| self.state.model.clone()); + self.state.config_options = Some(value); None } // ACP deliberately adds notification variants over time. Known but @@ -721,6 +722,10 @@ mod tests { assert_eq!(state.context.as_ref().unwrap()["size"], 200); assert_eq!(state.mode.as_deref(), Some("plan")); assert_eq!(state.model.as_deref(), Some("opus")); + assert_eq!( + state.config_options.as_ref().unwrap()["configOptions"][0]["currentValue"], + "opus" + ); assert_eq!( state.plan.as_ref().unwrap()["entries"], serde_json::json!([]) diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 8227b10..99635bd 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -218,6 +218,15 @@ pub enum QueueAcpPrompt { Failed(String), } +/// Result of changing a live structured session option. +#[derive(Debug)] +pub enum SetAcpConfig { + Updated, + InvalidValue, + Unavailable, + Failed(String), +} + impl SessionError { pub fn to_error_info(&self) -> ErrorInfo { let code = match self { @@ -850,6 +859,48 @@ impl SessionManager { } } + /// Change one option on a live ACP session and wait for the agent to + /// acknowledge it. The adapter's returned option set is projected by the + /// normal event pump before this request completes. + pub async fn set_acp_config( + &self, + id: Uuid, + config_id: String, + value: serde_json::Value, + ) -> Result { + let (acp, 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(SetAcpConfig::Unavailable); + } + (record.acp.clone(), record.acp_events.clone()) + }; + let (Some(acp), Some(stream)) = (acp, stream) else { + return Ok(SetAcpConfig::Unavailable); + }; + let value = match value { + serde_json::Value::String(value) => { + maestro_acp::schema::SessionConfigOptionValue::value_id(value) + } + serde_json::Value::Bool(value) => { + maestro_acp::schema::SessionConfigOptionValue::boolean(value) + } + _ => return Ok(SetAcpConfig::InvalidValue), + }; + let operation = stream.lock().await.operation.clone(); + let _operation = operation.lock().await; + let done = match acp.set_config(config_id, value) { + Ok(done) => done, + Err(message) => return Ok(SetAcpConfig::Failed(message)), + }; + match done.await { + Ok(Ok(())) => Ok(SetAcpConfig::Updated), + Ok(Err(message)) => Ok(SetAcpConfig::Failed(message)), + Err(_) => Ok(SetAcpConfig::Failed("ACP connection is closed".into())), + } + } + /// Cancel an ACP turn and immediately clear its client-facing lifecycle. pub async fn cancel_acp_turn(&self, id: Uuid) -> Result { let (acp, stream) = { diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 39536c6..340f345 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -846,6 +846,8 @@ pub struct ReducedState { pub model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_options: Option, } /// Server messages sent over a structured-session WebSocket. @@ -871,6 +873,13 @@ pub struct StructuredPromptRequest { pub prompt: String, } +/// HTTP body for `POST /api/sessions/:id/structured/config`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StructuredConfigRequest { + pub config_id: String, + pub value: Value, +} + /// Operator decision for a pending ACP permission request. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/crates/maestro-protocol/src/routes.rs b/crates/maestro-protocol/src/routes.rs index 1f6e728..886fee6 100644 --- a/crates/maestro-protocol/src/routes.rs +++ b/crates/maestro-protocol/src/routes.rs @@ -91,6 +91,11 @@ pub fn structured_cancel(id: Uuid) -> String { format!("{SESSIONS}/{id}/structured/cancel") } +/// `POST /api/sessions/:id/structured/config` — change an ACP session option. +pub fn structured_config(id: Uuid) -> String { + format!("{SESSIONS}/{id}/structured/config") +} + /// `POST /api/sessions/:id/structured/approvals/:nonce` — answer ACP permission. pub fn structured_approval(id: Uuid, nonce: &str) -> String { format!("{SESSIONS}/{id}/structured/approvals/{nonce}") @@ -109,6 +114,7 @@ pub mod patterns { pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay"; pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt"; 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"; } diff --git a/web/src/api.ts b/web/src/api.ts index 64c9bca..a7f98de 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -12,6 +12,7 @@ import type { RecentDirs, Session, StructuredApprovalDecision, + StructuredConfigRequest, StructuredPromptRequest, Uuid, } from "./types.js"; @@ -93,6 +94,21 @@ export async function killSession(id: Uuid, force = false): Promise { ); } +/** Change one option advertised by a live ACP session. */ +export async function setStructuredConfig( + id: Uuid, + req: StructuredConfigRequest, +): Promise { + await jsonOrError( + await fetch(routes.structuredConfig(id), { + method: "POST", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify(req), + }), + ); +} + /** Report whether this client's pane is the one currently showing `id` — not * merely highlighted in a sidebar. The daemon uses this to clear an * `AwaitingInput` glow once the pane has been looked at long enough diff --git a/web/src/routes.ts b/web/src/routes.ts index ec567b7..2227ae4 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -52,6 +52,11 @@ export function structuredCancel(id: Uuid): string { return `${session(id)}/structured/cancel`; } +/** `POST /api/sessions/:id/structured/config` — change an ACP session option. */ +export function structuredConfig(id: Uuid): string { + return `${session(id)}/structured/config`; +} + /** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a permission * request. */ export function structuredApproval(id: Uuid, nonce: string): string { diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index bcf58f4..1b663bd 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -8,10 +8,16 @@ vi.mock("./api.js", async () => { sendStructuredPrompt: vi.fn().mockResolvedValue(undefined), cancelStructuredTurn: vi.fn().mockResolvedValue(undefined), resolveStructuredApproval: vi.fn().mockResolvedValue(undefined), + setStructuredConfig: vi.fn().mockResolvedValue(undefined), }; }); -import { cancelStructuredTurn, resolveStructuredApproval, sendStructuredPrompt } from "./api.js"; +import { + cancelStructuredTurn, + resolveStructuredApproval, + sendStructuredPrompt, + setStructuredConfig, +} from "./api.js"; import { TOGGLE_DRAWER_EVENT } from "./drawer.js"; import type { CatalogStore } from "./events.js"; import { createStructuredConnection, type StructuredSocketFactory } from "./structured.js"; @@ -142,6 +148,53 @@ describe("structured connection: data flow", () => { conn.dispose(); }); + it("renders advertised session settings and sends changed values", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ + type: "reduced_state", + state: { + ...idleState, + config_options: { + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "opus", + options: [ + { value: "opus", name: "Opus" }, + { value: "sonnet", name: "Sonnet" }, + ], + }, + { + id: "effort", + name: "Effort", + category: "thought_level", + type: "select", + currentValue: "high", + options: [{ value: "high", name: "High" }], + }, + ], + }, + }, + }); + + expect(conn.el.querySelector(".structured-config-summary")?.textContent).toBe("Opus · High"); + (conn.el.querySelector(".structured-config-trigger") as HTMLButtonElement).click(); + const selects = conn.el.querySelectorAll(".structured-config-select"); + expect(selects).toHaveLength(2); + selects[0].value = "sonnet"; + selects[0].dispatchEvent(new Event("change")); + expect(setStructuredConfig).toHaveBeenCalledWith("s1", { + config_id: "model", + value: "sonnet", + }); + conn.dispose(); + }); + it("applies plan append, patch, and remove without disturbing surrounding rows", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); diff --git a/web/src/structured.ts b/web/src/structured.ts index f6e6881..b2dc7e6 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -10,6 +10,7 @@ import { cancelStructuredTurn, resolveStructuredApproval, sendStructuredPrompt, + setStructuredConfig, structuredWsUrl, } from "./api.js"; import { requestToggleDrawer } from "./drawer.js"; @@ -19,6 +20,8 @@ import type { PaneConnection } from "./pane.js"; import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js"; import { buildToolGroup, buildTranscriptRow } from "./structuredRender.js"; import type { + AcpSessionConfigChoice, + AcpSessionConfigOption, ReducedState, StructuredApprovalDecision, StructuredEvent, @@ -69,7 +72,11 @@ export function createStructuredConnection(
${paneTitleHtml(sessionId, info)} - + connecting…
@@ -94,7 +101,10 @@ export function createStructuredConnection( `; const stateEl = el.querySelector(".attach-state") as HTMLSpanElement; - const pillsEl = el.querySelector(".st-pills") as HTMLSpanElement; + const configEl = el.querySelector(".structured-config") as HTMLDivElement; + const configTrigger = el.querySelector(".structured-config-trigger") as HTMLButtonElement; + const configSummary = el.querySelector(".structured-config-summary") as HTMLSpanElement; + const configMenu = el.querySelector(".structured-config-menu") as HTMLDivElement; const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement; const scrollEl = el.querySelector(".structured-scroll") as HTMLDivElement; const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement; @@ -128,6 +138,7 @@ export function createStructuredConnection( let state: ReducedState = { turn: idleTurn() }; let pendingApprovalRowId: string | null = null; let availableCommands: AvailableCommand[] = []; + let configBusy = false; let filteredCommands: AvailableCommand[] = []; let activeCommandIndex = -1; let submissionGeneration = 0; @@ -364,11 +375,90 @@ export function createStructuredConnection( }); } - function renderPills(): void { - const parts: string[] = []; - if (state.model) parts.push(state.model); - if (state.mode) parts.push(state.mode); - pillsEl.textContent = parts.join(" · "); + function sessionConfigOptions(): AcpSessionConfigOption[] { + return state.config_options?.configOptions ?? []; + } + + function configChoices(option: AcpSessionConfigOption): AcpSessionConfigChoice[] { + return (option.options ?? []).flatMap((choice) => + "options" in choice ? choice.options : [choice], + ); + } + + function currentConfigLabel(option: AcpSessionConfigOption): string { + if (typeof option.currentValue === "boolean") return option.currentValue ? "On" : "Off"; + return configChoices(option).find((choice) => choice.value === option.currentValue)?.name + ?? option.currentValue; + } + + async function changeConfig(option: AcpSessionConfigOption, value: string | boolean): Promise { + configBusy = true; + renderConfig(); + clearError(); + try { + await setStructuredConfig(sessionId, { config_id: option.id, value }); + } catch (error) { + showError(error instanceof Error ? error.message : "Could not change the session setting"); + } finally { + configBusy = false; + renderConfig(); + } + } + + function renderConfig(): void { + const options = sessionConfigOptions(); + configEl.hidden = options.length === 0; + if (options.length === 0) { + configMenu.hidden = true; + configTrigger.setAttribute("aria-expanded", "false"); + return; + } + const ordered = [...options].sort((a, b) => { + const rank = (category?: string | null) => + ["model", "thought_level", "mode", "model_config"].indexOf(category ?? ""); + const aRank = rank(a.category); + const bRank = rank(b.category); + return (aRank < 0 ? 99 : aRank) - (bRank < 0 ? 99 : bRank); + }); + configSummary.textContent = ordered.map(currentConfigLabel).join(" · "); + configTrigger.title = ordered.map((option) => `${option.name}: ${currentConfigLabel(option)}`).join("\n"); + configTrigger.disabled = configBusy; + configMenu.replaceChildren(); + for (const option of ordered) { + const row = document.createElement("label"); + row.className = "structured-config-row"; + const name = document.createElement("span"); + name.className = "structured-config-name"; + name.textContent = option.name; + row.append(name); + if (option.type === "select") { + const select = document.createElement("select"); + select.className = "structured-config-select"; + select.disabled = configBusy; + for (const choice of configChoices(option)) { + const item = document.createElement("option"); + item.value = choice.value; + item.textContent = choice.name; + item.title = choice.description ?? ""; + select.append(item); + } + select.value = String(option.currentValue); + select.addEventListener("change", () => void changeConfig(option, select.value)); + row.append(select); + } else if (option.type === "boolean") { + const toggle = document.createElement("input"); + toggle.type = "checkbox"; + toggle.className = "structured-config-toggle"; + toggle.checked = option.currentValue === true; + toggle.disabled = configBusy; + toggle.addEventListener("change", () => void changeConfig(option, toggle.checked)); + row.append(toggle); + } else { + continue; + } + if (option.description) row.title = option.description; + configMenu.append(row); + } } function renderComposerState(): void { @@ -403,10 +493,31 @@ export function createStructuredConnection( if (prev !== null || newPendingId !== null) rebuildTranscript(); }); } - renderPills(); + renderConfig(); renderComposerState(); } + function closeConfigMenu(): void { + configMenu.hidden = true; + configTrigger.setAttribute("aria-expanded", "false"); + } + + configTrigger.addEventListener("click", () => { + const opening = configMenu.hidden; + configMenu.hidden = !opening; + configTrigger.setAttribute("aria-expanded", String(opening)); + }); + const closeConfigOnOutsidePress = (event: PointerEvent) => { + if (!configEl.contains(event.target as Node)) closeConfigMenu(); + }; + document.addEventListener("pointerdown", closeConfigOnOutsidePress); + configEl.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closeConfigMenu(); + configTrigger.focus(); + } + }); + // ---- error banner ---- let errorEl: HTMLElement | null = null; function showError(message: string): void { @@ -783,6 +894,7 @@ export function createStructuredConnection( expandedGroupIds.clear(); scrollEl.removeEventListener("scroll", onTranscriptScroll); scrollEl.removeEventListener("click", onTranscriptClick, true); + document.removeEventListener("pointerdown", closeConfigOnOutsidePress); el.remove(); }, }; diff --git a/web/src/styles.css b/web/src/styles.css index de1b908..90722a6 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2173,12 +2173,111 @@ input.leaf-rename:focus { only lifted, card-like element on the page; everything else is either a tinted block (user), plain text (agent), or a thin bordered row (tool). */ -.st-pills { - font-size: var(--fs-11); +.structured-config { + position: relative; + flex: 0 1 300px; + min-width: 0; +} + +.structured-config-trigger { + width: 100%; + height: 26px; + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 0 var(--sp-2); + border: 1px solid var(--border); + border-radius: var(--r-2); + background: var(--surface-2); color: var(--muted); - white-space: nowrap; + font: 500 var(--fs-11) / 1 var(--font-sans); + cursor: pointer; +} + +.structured-config-trigger:hover, +.structured-config-trigger[aria-expanded="true"] { + color: var(--ink); + border-color: var(--border-strong); + background: var(--surface-3); +} + +.structured-config-trigger:focus-visible, +.structured-config-select:focus-visible, +.structured-config-toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.structured-config-summary { + min-width: 0; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; +} + +.structured-config-caret { + width: 6px; + height: 6px; + flex: 0 0 auto; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: translateY(-1px) rotate(45deg); +} + +.structured-config-trigger[aria-expanded="true"] .structured-config-caret { + transform: translateY(2px) rotate(225deg); +} + +.structured-config-menu { + position: absolute; + z-index: 20; + top: calc(100% + var(--sp-2)); + right: 0; + width: min(320px, 86vw); + padding: var(--sp-2); + border: 1px solid var(--border-strong); + border-radius: var(--r-2); + background: var(--surface); + box-shadow: var(--shadow-2); +} + +.structured-config-row { + display: grid; + grid-template-columns: minmax(80px, 1fr) minmax(0, 1.6fr); + align-items: center; + gap: var(--sp-3); + min-height: 34px; + padding: var(--sp-1) var(--sp-2); + color: var(--ink); + font-size: var(--fs-12); +} + +.structured-config-row + .structured-config-row { + border-top: 1px solid var(--border); +} + +.structured-config-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.structured-config-select { + min-width: 0; + height: 28px; + padding: 0 var(--sp-2); + border: 1px solid var(--border); + border-radius: var(--r-1); + background: var(--surface-2); + color: var(--ink); + font: inherit; +} + +.structured-config-toggle { + justify-self: end; + width: 17px; + height: 17px; + accent-color: var(--accent); } .structured-body { diff --git a/web/src/types.ts b/web/src/types.ts index 894ae7c..4acbdc6 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -436,6 +436,7 @@ export interface ReducedState { context?: AcpUsage | null; model?: string | null; mode?: string | null; + config_options?: { configOptions: AcpSessionConfigOption[] } | null; } /** Server messages on `WS /api/structured/:id`. */ @@ -449,6 +450,33 @@ export interface StructuredPromptRequest { prompt: string; } +export interface StructuredConfigRequest { + config_id: string; + value: string | boolean; +} + +export interface AcpSessionConfigChoice { + value: string; + name: string; + description?: string | null; +} + +export interface AcpSessionConfigGroup { + group: string; + name: string; + options: AcpSessionConfigChoice[]; +} + +export interface AcpSessionConfigOption { + id: string; + name: string; + description?: string | null; + category?: string | null; + type: "select" | "boolean" | string; + currentValue: string | boolean; + options?: Array; +} + /** `POST /api/sessions/:id/structured/approvals/:nonce` body — a bare JSON * string (a bracketless Rust enum), not `{decision: ...}`. */ export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny";