diff --git a/nori-rs/acp/docs.md b/nori-rs/acp/docs.md index 518190989..9613bbb49 100644 --- a/nori-rs/acp/docs.md +++ b/nori-rs/acp/docs.md @@ -262,6 +262,16 @@ The model is only applied if: Failures to apply the default model (e.g., model unavailable, API error) produce warnings but do not block session startup. When users switch models via `/model` command, the TUI persists the selection by calling `ConfigEditsBuilder::set_default_model()` (see `@/nori-rs/core/docs.md`). +**Live Session Configuration** (`connection/sacp_connection.rs`, `backend/submit_and_ops.rs`): + +ACP agents can expose runtime session configuration through `NewSessionResponse.config_options`, `LoadSessionResponse.config_options`, idle `SessionUpdate::ConfigOptionUpdate` notifications, and the `session/set_config_option` RPC. `SacpConnection` owns the latest live config snapshot in `AcpSessionConfigState`, updates it when a session is created/loaded or when config-option notifications arrive, and replaces it with the full response snapshot after `set_config_option()`. + +This first implementation is deliberately live-session only: +- `AcpBackend::config_options()` returns the current in-memory ACP config snapshot for TUI pickers. +- `AcpBackend::set_config_option()` sends `session/set_config_option` for the current session and updates in-memory state from the response. +- No config form is shown during `/agent` switching yet. +- No ACP session config selections are persisted to `config.toml` yet. + **Hooks System** (`config/types/mod.rs`, `hooks.rs`, `backend/mod.rs`): Hooks allow users to run custom scripts at lifecycle boundaries. There are two flavors: **synchronous** hooks (blocking, executed sequentially) and **async** hooks (fire-and-forget, spawned via `tokio::spawn`). Both are configured under `[hooks]` in `config.toml`, are **fail-open** (failures produce warnings but do not halt operations), and share the same execution engine (`execute_hooks_with_env()` in `hooks.rs`) and interpreter detection. Synchronous hooks support output routing and context injection; async hooks route all output exclusively to tracing. diff --git a/nori-rs/acp/src/backend/submit_and_ops.rs b/nori-rs/acp/src/backend/submit_and_ops.rs index 007745375..bec4941a0 100644 --- a/nori-rs/acp/src/backend/submit_and_ops.rs +++ b/nori-rs/acp/src/backend/submit_and_ops.rs @@ -304,6 +304,11 @@ impl AcpBackend { self.connection.model_state() } + /// Get the current ACP session config snapshot from the connection. + pub fn config_options(&self) -> Vec { + self.connection.config_options() + } + /// Get the current session ID. /// /// Note: This clones the session ID since it may be replaced during /compact. @@ -313,11 +318,23 @@ impl AcpBackend { /// Get a reference to the underlying ACP connection. /// - /// This provides access to low-level ACP operations like model switching. + /// This provides access to low-level ACP operations like session controls. pub fn connection(&self) -> &Arc { &self.connection } + /// Set an ACP session config option for the current session. + pub async fn set_config_option( + &self, + config_id: impl Into, + value: impl Into, + ) -> Result<()> { + let session_id = self.session_id.read().await; + self.connection + .set_config_option(&session_id, config_id, value) + .await + } + /// Switch to a different model for the current session. /// /// This sends a `session/set_model` request to the ACP agent and updates diff --git a/nori-rs/acp/src/connection/mod.rs b/nori-rs/acp/src/connection/mod.rs index 6a75b3544..959f14ec4 100644 --- a/nori-rs/acp/src/connection/mod.rs +++ b/nori-rs/acp/src/connection/mod.rs @@ -93,6 +93,21 @@ pub struct AcpModelState { pub available_models: Vec, } +/// Session config state captured from ACP session setup and updates. +/// +/// This stores the complete current `configOptions` snapshot for the active +/// session. ACP responses and notifications replace the full list. +#[derive(Debug, Clone, Default)] +pub struct AcpSessionConfigState { + pub config_options: Vec, +} + +impl AcpSessionConfigState { + pub fn new() -> Self { + Self::default() + } +} + impl AcpModelState { /// Create a new empty model state pub fn new() -> Self { diff --git a/nori-rs/acp/src/connection/sacp_connection.rs b/nori-rs/acp/src/connection/sacp_connection.rs index 9c75fb294..ac4e9a9ce 100644 --- a/nori-rs/acp/src/connection/sacp_connection.rs +++ b/nori-rs/acp/src/connection/sacp_connection.rs @@ -29,6 +29,7 @@ use tracing::debug; use tracing::warn; use super::AcpModelState; +use super::AcpSessionConfigState; use super::ApprovalEventType; use super::ApprovalRequest; use super::ConnectionEvent; @@ -75,6 +76,9 @@ pub struct SacpConnection { /// Thread-safe model state, updated on session creation and model switch. model_state: std::sync::Arc>, + /// Thread-safe session config state, updated from complete ACP snapshots. + session_config_state: std::sync::Arc>, + /// Handle to the background task driving the SACP connection. connection_task: tokio::task::JoinHandle<()>, @@ -166,6 +170,9 @@ impl SacpConnection { let prompt_state = std::sync::Arc::new(Mutex::new(HashMap::::new())); let prompt_state_for_notifications = prompt_state.clone(); + let session_config_state = + std::sync::Arc::new(std::sync::RwLock::new(AcpSessionConfigState::new())); + let session_config_state_for_notifications = session_config_state.clone(); let approval_cwd = cwd.to_path_buf(); let write_cwd = cwd.to_path_buf(); let read_cwd = cwd.to_path_buf(); @@ -183,6 +190,7 @@ impl SacpConnection { { let event_tx = event_tx_for_notifications; let prompt_state = prompt_state_for_notifications; + let session_config_state = session_config_state_for_notifications; async move |notification: acp::SessionNotification, _connection| { let session_id = notification.session_id.to_string(); { @@ -198,6 +206,12 @@ impl SacpConnection { update_kind = super::session_update_kind(¬ification.update), "Transport received ACP session/update notification" ); + if let acp::SessionUpdate::ConfigOptionUpdate(update) = + ¬ification.update + && let Ok(mut state) = session_config_state.write() + { + state.config_options = update.config_options.clone(); + } if event_tx .send(ConnectionEvent::SessionUpdate(notification.update)) .await @@ -484,6 +498,7 @@ impl SacpConnection { event_rx, prompt_state, model_state: std::sync::Arc::new(std::sync::RwLock::new(AcpModelState::new())), + session_config_state, connection_task, child, stderr_task, @@ -518,6 +533,12 @@ impl SacpConnection { ); } + if let Some(config_options) = response.config_options + && let Ok(mut state) = self.session_config_state.write() + { + state.config_options = config_options; + } + Ok(response.session_id) } @@ -541,6 +562,12 @@ impl SacpConnection { *state = AcpModelState::from_session_model_state(models); } + if let Some(config_options) = response.config_options + && let Ok(mut state) = self.session_config_state.write() + { + state.config_options = config_options; + } + // The session ID from the request is reused since the response // doesn't contain one. Ok(acp::SessionId::from(session_id.to_string())) @@ -661,6 +688,45 @@ impl SacpConnection { .clone() } + /// Get the current ACP session config snapshot. + pub fn config_options(&self) -> Vec { + #[expect( + clippy::expect_used, + reason = "RwLock poisoning indicates a bug elsewhere" + )] + self.session_config_state + .read() + .expect("Session config state lock poisoned") + .config_options + .clone() + } + + /// Set an ACP session config option and replace state from the full response snapshot. + pub async fn set_config_option( + &self, + session_id: &acp::SessionId, + config_id: impl Into, + value: impl Into, + ) -> Result<()> { + let value = acp::SessionConfigOptionValue::value_id(value.into()); + let response = self + .cx + .send_request(acp::SetSessionConfigOptionRequest::new( + session_id.clone(), + config_id, + value, + )) + .block_task() + .await + .context("Failed to set ACP session config option")?; + + if let Ok(mut state) = self.session_config_state.write() { + state.config_options = response.config_options; + } + + Ok(()) + } + /// Explicitly tear down the ACP subprocess and background tasks. /// /// Unlike `Drop`, this async path can wait for process termination so the diff --git a/nori-rs/acp/src/connection/sacp_connection_tests.rs b/nori-rs/acp/src/connection/sacp_connection_tests.rs index 716a41f24..9836ed1f0 100644 --- a/nori-rs/acp/src/connection/sacp_connection_tests.rs +++ b/nori-rs/acp/src/connection/sacp_connection_tests.rs @@ -245,6 +245,62 @@ async fn test_model_state_after_session_creation() { ); } +#[tokio::test] +#[serial] +async fn test_session_config_options_after_session_creation() { + let Some(config) = mock_agent_config() else { + return; + }; + let temp_dir = tempdir().expect("temp dir"); + + let conn = SacpConnection::spawn(&config, temp_dir.path()) + .await + .expect("spawn"); + + let _session_id = conn + .create_session(temp_dir.path(), vec![]) + .await + .expect("create session"); + + let config_options = conn.config_options(); + let option_names = config_options + .iter() + .map(|option| option.name.as_str()) + .collect::>(); + + assert_eq!(option_names, vec!["Model", "Thought Level"]); +} + +#[tokio::test] +#[serial] +async fn test_set_session_config_option_replaces_connection_state() { + let Some(config) = mock_agent_config() else { + return; + }; + let temp_dir = tempdir().expect("temp dir"); + + let conn = SacpConnection::spawn(&config, temp_dir.path()) + .await + .expect("spawn"); + + let session_id = conn + .create_session(temp_dir.path(), vec![]) + .await + .expect("create session"); + + conn.set_config_option(&session_id, "model", "mock-model-fast") + .await + .expect("set config option"); + + let config_options = conn.config_options(); + let option_names = config_options + .iter() + .map(|option| option.name.as_str()) + .collect::>(); + + assert_eq!(option_names, vec!["Model", "Speed"]); +} + /// Test that approval requests flow through the ordered event inbox and the /// prompt completes after the approval response is sent back. #[tokio::test] diff --git a/nori-rs/acp/src/lib.rs b/nori-rs/acp/src/lib.rs index d822d584f..95be5e42e 100644 --- a/nori-rs/acp/src/lib.rs +++ b/nori-rs/acp/src/lib.rs @@ -41,6 +41,7 @@ pub use backend::AcpBackend; pub use backend::AcpBackendConfig; pub use backend::BackendEvent; pub use connection::AcpModelState; +pub use connection::AcpSessionConfigState; pub use connection::ApprovalRequest; pub use connection::sacp_connection::SacpConnection; pub use registry::AcpAgentConfig; @@ -86,8 +87,18 @@ pub use agent_client_protocol_schema::NewSessionRequest; pub use agent_client_protocol_schema::NewSessionResponse; pub use agent_client_protocol_schema::PromptRequest; pub use agent_client_protocol_schema::PromptResponse; +pub use agent_client_protocol_schema::SessionConfigKind; +pub use agent_client_protocol_schema::SessionConfigOption; +pub use agent_client_protocol_schema::SessionConfigOptionCategory; +pub use agent_client_protocol_schema::SessionConfigOptionValue; +pub use agent_client_protocol_schema::SessionConfigSelectGroup; +pub use agent_client_protocol_schema::SessionConfigSelectOption; +pub use agent_client_protocol_schema::SessionConfigSelectOptions; +pub use agent_client_protocol_schema::SessionConfigValueId; pub use agent_client_protocol_schema::SessionNotification; pub use agent_client_protocol_schema::SessionUpdate; +pub use agent_client_protocol_schema::SetSessionConfigOptionRequest; +pub use agent_client_protocol_schema::SetSessionConfigOptionResponse; // Re-export model-related types (unstable feature) #[cfg(feature = "unstable")] diff --git a/nori-rs/mock-acp-agent/docs.md b/nori-rs/mock-acp-agent/docs.md index c0cb9e446..74841a88d 100644 --- a/nori-rs/mock-acp-agent/docs.md +++ b/nori-rs/mock-acp-agent/docs.md @@ -30,6 +30,8 @@ Used by `@/nori-rs/tui-pty-e2e/` for end-to-end integration testing. The mock ag **Prompt Echo**: The `MOCK_AGENT_ECHO_PROMPT` env var causes the mock agent's `prompt()` handler to echo back the full prompt text it received. Used by session context tests in `@/nori-rs/acp/src/backend/tests/part5.rs` to verify that `AcpBackendConfig.session_context` is correctly prepended to the first user prompt and consumed after that. +**Session Config Options**: The mock agent advertises live ACP session config options on `session/new` and `session/load`, and supports `session/set_config_option` for connection/TUI tests. The default config exposes `Model` plus `Thought Level`. Switching the model to `mock-model-fast` replaces `Thought Level` with a `Speed` selector, which lets tests verify that Nori replaces the full live config snapshot after a config mutation. + **Cancel Tail Ordering**: The `MOCK_AGENT_CANCEL_TAIL_EMPTY_END_TURNS` env var reproduces the Claude-style cancel tail that motivated the ACP cancellation-ordering fix. When a streaming prompt is cancelled, the mock agent queues N immediate empty `end_turn` responses for the next prompt attempts before finally allowing the real follow-up prompt to complete. `MOCK_AGENT_CANCEL_TAIL_FOLLOW_UP_RESPONSE` overrides the text returned by that eventual real follow-up turn. These knobs are used by `@/nori-rs/acp/src/connection/sacp_connection_tests.rs` and `@/nori-rs/tui-pty-e2e/tests/streaming.rs` to verify that Nori absorbs repeated stale terminal responses without admitting a new logical prompt turn too early. **Stuck Tool Calls (No Completion)**: The `MOCK_AGENT_STUCK_TOOL_CALLS` env var triggers a scenario where 3 Read tool calls are sent with `Pending` status but never receive completion updates. After a short delay the agent sends its final text response and ends the turn. This reproduces the frozen-display bug where incomplete ExecCells fill the viewport and block `insert_history_lines()` from rendering the agent's text. The fix under test is `finalize_active_cell_as_failed()` in `@/nori-rs/tui/src/chatwidget.rs`. diff --git a/nori-rs/mock-acp-agent/src/main.rs b/nori-rs/mock-acp-agent/src/main.rs index bd5ec3525..bcecb55bb 100644 --- a/nori-rs/mock-acp-agent/src/main.rs +++ b/nori-rs/mock-acp-agent/src/main.rs @@ -3,6 +3,8 @@ mod runaway_search; use std::cell::Cell; +use std::cell::RefCell; +use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -44,6 +46,14 @@ struct MockAgent { cancel_requested: Cell, pending_cancel_tail_empty_end_turns: Cell, follow_up_after_cancel_tail: Cell, + session_configs: RefCell>, +} + +#[derive(Clone)] +struct MockSessionConfig { + model_id: String, + thought_level: Option, + speed: Option, } impl MockAgent { @@ -58,9 +68,90 @@ impl MockAgent { cancel_requested: Cell::new(false), pending_cancel_tail_empty_end_turns: Cell::new(0), follow_up_after_cancel_tail: Cell::new(false), + session_configs: RefCell::new(HashMap::new()), + } + } + + fn default_session_config() -> MockSessionConfig { + MockSessionConfig { + model_id: "mock-model-default".to_string(), + thought_level: Some("medium".to_string()), + speed: None, } } + fn session_model_state() -> acp::SessionModelState { + acp::SessionModelState::new( + acp::ModelId::new("mock-model-default"), + vec![ + acp::ModelInfo::new( + acp::ModelId::new("mock-model-default"), + "Mock Default Model", + ) + .description("The default mock model"), + acp::ModelInfo::new(acp::ModelId::new("mock-model-fast"), "Mock Fast Model") + .description("A faster mock model variant"), + acp::ModelInfo::new( + acp::ModelId::new("mock-model-powerful"), + "Mock Powerful Model", + ) + .description("A more powerful mock model variant"), + ], + ) + } + + fn config_options_for_state(config: &MockSessionConfig) -> Vec { + let mut options = vec![ + acp::SessionConfigOption::select( + "model", + "Model", + config.model_id.clone(), + vec![ + acp::SessionConfigSelectOption::new("mock-model-default", "Mock Default Model") + .description("The default mock model"), + acp::SessionConfigSelectOption::new("mock-model-fast", "Mock Fast Model") + .description("A faster mock model variant"), + ], + ) + .category(acp::SessionConfigOptionCategory::Model), + ]; + + if config.model_id == "mock-model-fast" { + options.push( + acp::SessionConfigOption::select( + "speed", + "Speed", + config.speed.clone().unwrap_or_else(|| "fast".to_string()), + vec![ + acp::SessionConfigSelectOption::new("fast", "Fast"), + acp::SessionConfigSelectOption::new("balanced", "Balanced"), + ], + ) + .description("Latency profile for the active model"), + ); + } else { + options.push( + acp::SessionConfigOption::select( + "thought_level", + "Thought Level", + config + .thought_level + .clone() + .unwrap_or_else(|| "medium".to_string()), + vec![ + acp::SessionConfigSelectOption::new("low", "Low"), + acp::SessionConfigSelectOption::new("medium", "Medium"), + acp::SessionConfigSelectOption::new("high", "High"), + ], + ) + .description("Reasoning depth for the active model") + .category(acp::SessionConfigOptionCategory::ThoughtLevel), + ); + } + + options + } + async fn send_update( &self, session_id: acp::SessionId, @@ -228,28 +319,16 @@ impl acp::Agent for MockAgent { self.next_session_id.set(session_id + 1); eprintln!("Mock agent: new_session id={}", session_id); - // Include model state with available models for testing model switching - let session_model_state = acp::SessionModelState::new( - acp::ModelId::new("mock-model-default"), - vec![ - acp::ModelInfo::new( - acp::ModelId::new("mock-model-default"), - "Mock Default Model", - ) - .description("The default mock model"), - acp::ModelInfo::new(acp::ModelId::new("mock-model-fast"), "Mock Fast Model") - .description("A faster mock model variant"), - acp::ModelInfo::new( - acp::ModelId::new("mock-model-powerful"), - "Mock Powerful Model", - ) - .description("A more powerful mock model variant"), - ], - ); + let session_key = session_id.to_string(); + let session_config = Self::default_session_config(); + self.session_configs + .borrow_mut() + .insert(session_key.clone(), session_config.clone()); Ok( - acp::NewSessionResponse::new(acp::SessionId::new(session_id.to_string())) - .models(session_model_state), + acp::NewSessionResponse::new(acp::SessionId::new(session_key)) + .models(Self::session_model_state()) + .config_options(Self::config_options_for_state(&session_config)), ) } @@ -265,6 +344,13 @@ impl acp::Agent for MockAgent { )); } + let session_config = self + .session_configs + .borrow_mut() + .entry(arguments.session_id.to_string()) + .or_insert_with(Self::default_session_config) + .clone(); + // Send configurable number of notifications during load_session // to simulate history replay. Uses the session_id from the request // so notifications are routed to the correct update channel. @@ -279,7 +365,9 @@ impl acp::Agent for MockAgent { } } - Ok(acp::LoadSessionResponse::new()) + Ok(acp::LoadSessionResponse::new() + .models(Self::session_model_state()) + .config_options(Self::config_options_for_state(&session_config))) } async fn prompt( @@ -1194,6 +1282,44 @@ impl acp::Agent for MockAgent { Ok(acp::SetSessionModelResponse::default()) } + async fn set_session_config_option( + &self, + args: acp::SetSessionConfigOptionRequest, + ) -> Result { + let response_options = { + let mut sessions = self.session_configs.borrow_mut(); + let state = sessions + .entry(args.session_id.to_string()) + .or_insert_with(Self::default_session_config); + + match args.config_id.to_string().as_str() { + "model" => { + state.model_id = args.value.to_string(); + if state.model_id == "mock-model-fast" { + state.thought_level = None; + state.speed = Some("fast".to_string()); + } else { + state.speed = None; + if state.thought_level.is_none() { + state.thought_level = Some("medium".to_string()); + } + } + } + "thought_level" => { + state.thought_level = Some(args.value.to_string()); + } + "speed" => { + state.speed = Some(args.value.to_string()); + } + _ => return Err(acp::Error::invalid_params()), + } + + Self::config_options_for_state(state) + }; + + Ok(acp::SetSessionConfigOptionResponse::new(response_options)) + } + async fn ext_method(&self, _args: acp::ExtRequest) -> Result { Ok(acp::ExtResponse::new(Arc::from( serde_json::value::to_raw_value(&json!({}))?, diff --git a/nori-rs/tui/docs.md b/nori-rs/tui/docs.md index db9f5bcfe..3eebdf21c 100644 --- a/nori-rs/tui/docs.md +++ b/nori-rs/tui/docs.md @@ -259,6 +259,7 @@ During background system info collection on unix, `check_worktree_cleanup()` run |---------|-------------| | `/agent` | Switch between available ACP agents (dynamically shows current agent name) | | `/model` | Choose model (dynamically shows current agent/model name) | +| `/session-config` | Configure live ACP session settings exposed by the current agent | | `/approvals` | Choose what Nori can do without approval (dynamically shows current approval mode) | | `/config` | Toggle TUI settings (pinned plan drawer, vertical footer, terminal notifications, OS notifications, vim mode with enter behavior sub-picker, auto worktree, per session skillsets, notify after idle, hotkeys, script timeout, loop count, footer segments, file manager) | | `/browse` | Open a terminal file manager to browse and edit files | @@ -341,6 +342,12 @@ Agent commands appear in the slash command popup alongside builtins and user pro `/agent`, `/model`, and `/approvals` show the current runtime value in parentheses in the slash command popup (e.g., `(current: Mock ACP)`). This is implemented via a `command_description_overrides: HashMap` that flows through `BottomPane` -> `ChatComposer` -> `CommandPopup`. `BottomPane::set_agent_display_name()` sets overrides for both `/agent` and `/model`; `BottomPane::set_approval_mode_label()` sets the override for `/approvals`. The agent override is populated at startup in `BottomPane::new()` and updated on agent switches. The approval override is set whenever the approval mode changes. +**Live ACP Session Config Picker** (`chatwidget/pickers.rs`, `nori/session_config_picker.rs`): + +`/session-config` opens a two-step picker for the current ACP session. `ChatWidget::open_session_config_popup()` asks the `AcpAgentHandle` for the live `AcpBackend::config_options()` snapshot, renders supported `select` options, then opens a value picker for the selected option. Selecting a value sends `session/set_config_option` through `AcpBackend::set_config_option()` and shows an info or error message when the RPC finishes. + +The picker intentionally only edits the active session. It does not run during `/agent` switching and it does not persist selected values. Unsupported ACP config kinds and future non-exhaustive select layouts are treated as unavailable rather than guessed. + **Selection Popup Row Layout (`bottom_pane/selection_popup_common.rs`):** `render_rows()` and `measure_rows_height()` are the shared rendering functions used by all selection popups (`ListSelectionView`, `CommandPopup`, `FileSearchPopup`). Each popup item has an optional description that appears alongside the item name. The layout engine chooses between two modes per-row via `wrap_row()`: diff --git a/nori-rs/tui/src/app/event_handling.rs b/nori-rs/tui/src/app/event_handling.rs index 936e5d07b..6686ad017 100644 --- a/nori-rs/tui/src/app/event_handling.rs +++ b/nori-rs/tui/src/app/event_handling.rs @@ -705,6 +705,44 @@ impl App { .add_info_message(format!("Failed to switch model: {error_msg}"), None); } } + AppEvent::OpenAcpSessionConfigPicker { config_options } => { + self.chat_widget + .open_acp_session_config_picker(config_options); + } + AppEvent::OpenAcpSessionConfigValuePicker { option } => { + self.chat_widget + .open_acp_session_config_value_picker(option); + } + AppEvent::SetAcpSessionConfigOption { + config_id, + value, + option_name, + value_name, + } => { + self.chat_widget.set_acp_session_config_option( + config_id, + value, + option_name, + value_name, + ); + } + AppEvent::AcpSessionConfigSetResult { + success, + option_name, + value_name, + error, + } => { + if success { + self.chat_widget + .add_info_message(format!("{option_name} set to: {value_name}"), None); + } else { + let error_msg = error.unwrap_or_else(|| "Unknown error".to_string()); + self.chat_widget.add_info_message( + format!("Failed to set {option_name}: {error_msg}"), + None, + ); + } + } AppEvent::LoginComplete { success } => { self.chat_widget.handle_login_complete(success); } diff --git a/nori-rs/tui/src/app_event.rs b/nori-rs/tui/src/app_event.rs index a006c6ce5..7030b52dc 100644 --- a/nori-rs/tui/src/app_event.rs +++ b/nori-rs/tui/src/app_event.rs @@ -5,6 +5,7 @@ use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::Event; use codex_core::protocol::RateLimitSnapshot; use codex_file_search::FileMatch; +use nori_acp::SessionConfigOption; use crate::bottom_pane::ApprovalRequest; use crate::history_cell::HistoryCell; @@ -235,6 +236,32 @@ pub(crate) enum AppEvent { error: Option, }, + /// Open the generic ACP session config picker. + OpenAcpSessionConfigPicker { + config_options: Vec, + }, + + /// Open the value picker for a specific ACP session config option. + OpenAcpSessionConfigValuePicker { + option: SessionConfigOption, + }, + + /// Set an ACP session config option value. + SetAcpSessionConfigOption { + config_id: String, + value: String, + option_name: String, + value_name: String, + }, + + /// Result of setting an ACP session config option. + AcpSessionConfigSetResult { + success: bool, + option_name: String, + value_name: String, + error: Option, + }, + /// Result of OAuth login flow completion. LoginComplete { /// Whether the login was successful diff --git a/nori-rs/tui/src/chatwidget/agent.rs b/nori-rs/tui/src/chatwidget/agent.rs index 9b0da36d2..2a55e567e 100644 --- a/nori-rs/tui/src/chatwidget/agent.rs +++ b/nori-rs/tui/src/chatwidget/agent.rs @@ -8,6 +8,7 @@ use nori_acp::AcpBackendConfig; #[cfg(feature = "unstable")] use nori_acp::AcpModelState; use nori_acp::HistoryPersistence; +use nori_acp::SessionConfigOption; use nori_acp::find_nori_home; use nori_acp::get_agent_config; use nori_acp::get_agent_display_name; @@ -52,38 +53,48 @@ async fn spawn_timeout_sequence(app_event_tx: &AppEventSender) { tokio::time::sleep(Duration::from_secs(CONNECT_ABORT_SECS)).await; } -/// Command for controlling the ACP agent. -#[cfg(feature = "unstable")] -pub(crate) enum AcpModelCommand { +/// Command for controlling ACP session state exposed by the agent. +pub(crate) enum AcpAgentCommand { /// Get the current model state (available models and current selection) + #[cfg(feature = "unstable")] GetModelState { response_tx: oneshot::Sender, }, /// Set the active model + #[cfg(feature = "unstable")] SetModel { model_id: String, response_tx: oneshot::Sender>, }, + /// Get the current ACP session config snapshot. + GetSessionConfig { + response_tx: oneshot::Sender>, + }, + /// Set an ACP session config option. + SetSessionConfigOption { + config_id: String, + value: String, + response_tx: oneshot::Sender>, + }, } /// Handle for communicating with an ACP agent. /// -/// This handle provides access to model switching operations in addition +/// This handle provides access to ACP session control operations in addition /// to the standard Op channel. -#[cfg(feature = "unstable")] #[derive(Clone)] pub(crate) struct AcpAgentHandle { - model_cmd_tx: mpsc::UnboundedSender, + command_tx: mpsc::UnboundedSender, } -#[cfg(feature = "unstable")] impl AcpAgentHandle { /// Get the current model state from the ACP agent. + #[cfg(feature = "unstable")] pub async fn get_model_state(&self) -> Option { let (response_tx, response_rx) = oneshot::channel(); if self - .model_cmd_tx - .send(AcpModelCommand::GetModelState { response_tx }) + .command_tx + .send(AcpAgentCommand::GetModelState { response_tx }) .is_err() { return None; @@ -92,10 +103,11 @@ impl AcpAgentHandle { } /// Set the active model in the ACP agent. + #[cfg(feature = "unstable")] pub async fn set_model(&self, model_id: String) -> anyhow::Result<()> { let (response_tx, response_rx) = oneshot::channel(); - self.model_cmd_tx - .send(AcpModelCommand::SetModel { + self.command_tx + .send(AcpAgentCommand::SetModel { model_id, response_tx, }) @@ -104,14 +116,45 @@ impl AcpAgentHandle { .await .map_err(|_| anyhow::anyhow!("ACP agent did not respond"))? } + + /// Get the current ACP session config snapshot from the agent. + pub async fn get_session_config(&self) -> Option> { + let (response_tx, response_rx) = oneshot::channel(); + if self + .command_tx + .send(AcpAgentCommand::GetSessionConfig { response_tx }) + .is_err() + { + return None; + } + response_rx.await.ok() + } + + /// Set an ACP session config option value. + pub async fn set_session_config_option( + &self, + config_id: String, + value: String, + ) -> anyhow::Result<()> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(AcpAgentCommand::SetSessionConfigOption { + config_id, + value, + response_tx, + }) + .map_err(|_| anyhow::anyhow!("ACP agent command channel closed"))?; + response_rx + .await + .map_err(|_| anyhow::anyhow!("ACP agent did not respond"))? + } } /// Result of spawning an agent, which may include an ACP handle for model control. pub(crate) struct SpawnAgentResult { /// The Op sender for submitting operations to the agent. pub op_tx: UnboundedSender, - /// Optional ACP handle for model control (only present in ACP mode). - #[cfg(feature = "unstable")] + /// Optional ACP handle for session control (only present in ACP mode). pub acp_handle: Option, } @@ -141,7 +184,6 @@ pub(crate) fn spawn_agent( let op_tx = spawn_error_agent(agent_name, error_msg, app_event_tx); SpawnAgentResult { op_tx, - #[cfg(feature = "unstable")] acp_handle: None, } } @@ -181,12 +223,12 @@ fn spawn_acp_agent( ) -> SpawnAgentResult { let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); - // Create the model command channel for model switching operations - #[cfg(feature = "unstable")] - let (model_cmd_tx, mut model_cmd_rx) = unbounded_channel::(); + // Create the ACP command channel for model and session-config operations. + let (agent_cmd_tx, mut agent_cmd_rx) = unbounded_channel::(); - #[cfg(feature = "unstable")] - let acp_handle = Some(AcpAgentHandle { model_cmd_tx }); + let acp_handle = Some(AcpAgentHandle { + command_tx: agent_cmd_tx, + }); // Emit "Connecting" status before spawning the backend let display_name = get_agent_display_name(&config.model); @@ -303,31 +345,41 @@ fn spawn_acp_agent( } }); - // Handle model commands in a separate task - #[cfg(feature = "unstable")] - { - let backend_for_model = Arc::clone(&backend); - tokio::spawn(async move { - while let Some(cmd) = model_cmd_rx.recv().await { - match cmd { - AcpModelCommand::GetModelState { response_tx } => { - let state = backend_for_model.model_state(); - let _ = response_tx.send(state); - } - AcpModelCommand::SetModel { - model_id, - response_tx, - } => { - let model_id = nori_acp::ModelId::from(model_id); - let result = backend_for_model.set_model(&model_id).await; - let _ = response_tx.send(result); - } + let backend_for_agent = Arc::clone(&backend); + tokio::spawn(async move { + while let Some(cmd) = agent_cmd_rx.recv().await { + match cmd { + #[cfg(feature = "unstable")] + AcpAgentCommand::GetModelState { response_tx } => { + let state = backend_for_agent.model_state(); + let _ = response_tx.send(state); + } + #[cfg(feature = "unstable")] + AcpAgentCommand::SetModel { + model_id, + response_tx, + } => { + let model_id = nori_acp::ModelId::from(model_id); + let result = backend_for_agent.set_model(&model_id).await; + let _ = response_tx.send(result); + } + AcpAgentCommand::GetSessionConfig { response_tx } => { + let state = backend_for_agent.config_options(); + let _ = response_tx.send(state); + } + AcpAgentCommand::SetSessionConfigOption { + config_id, + value, + response_tx, + } => { + let result = backend_for_agent.set_config_option(config_id, value).await; + let _ = response_tx.send(result); } } - }); - } + } + }); - // Drop our Arc reference - the op and model tasks have their own. + // Drop our Arc reference - the op and agent-control tasks have their own. // This is necessary so that when these tasks exit, the backend is fully dropped, // which drops event_tx, allowing event_rx to return None and this task to exit. drop(backend); @@ -346,7 +398,6 @@ fn spawn_acp_agent( SpawnAgentResult { op_tx: codex_op_tx, - #[cfg(feature = "unstable")] acp_handle, } } @@ -365,11 +416,11 @@ pub(crate) fn spawn_acp_agent_resume( ) -> SpawnAgentResult { let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); - #[cfg(feature = "unstable")] - let (model_cmd_tx, mut model_cmd_rx) = unbounded_channel::(); + let (agent_cmd_tx, mut agent_cmd_rx) = unbounded_channel::(); - #[cfg(feature = "unstable")] - let acp_handle = Some(AcpAgentHandle { model_cmd_tx }); + let acp_handle = Some(AcpAgentHandle { + command_tx: agent_cmd_tx, + }); let display_name = get_agent_display_name(&config.model); app_event_tx.send(AppEvent::AgentConnecting { display_name }); @@ -481,28 +532,39 @@ pub(crate) fn spawn_acp_agent_resume( } }); - #[cfg(feature = "unstable")] - { - let backend_for_model = Arc::clone(&backend); - tokio::spawn(async move { - while let Some(cmd) = model_cmd_rx.recv().await { - match cmd { - AcpModelCommand::GetModelState { response_tx } => { - let state = backend_for_model.model_state(); - let _ = response_tx.send(state); - } - AcpModelCommand::SetModel { - model_id, - response_tx, - } => { - let model_id = nori_acp::ModelId::from(model_id); - let result = backend_for_model.set_model(&model_id).await; - let _ = response_tx.send(result); - } + let backend_for_agent = Arc::clone(&backend); + tokio::spawn(async move { + while let Some(cmd) = agent_cmd_rx.recv().await { + match cmd { + #[cfg(feature = "unstable")] + AcpAgentCommand::GetModelState { response_tx } => { + let state = backend_for_agent.model_state(); + let _ = response_tx.send(state); + } + #[cfg(feature = "unstable")] + AcpAgentCommand::SetModel { + model_id, + response_tx, + } => { + let model_id = nori_acp::ModelId::from(model_id); + let result = backend_for_agent.set_model(&model_id).await; + let _ = response_tx.send(result); + } + AcpAgentCommand::GetSessionConfig { response_tx } => { + let state = backend_for_agent.config_options(); + let _ = response_tx.send(state); + } + AcpAgentCommand::SetSessionConfigOption { + config_id, + value, + response_tx, + } => { + let result = backend_for_agent.set_config_option(config_id, value).await; + let _ = response_tx.send(result); } } - }); - } + } + }); drop(backend); @@ -520,7 +582,6 @@ pub(crate) fn spawn_acp_agent_resume( SpawnAgentResult { op_tx: codex_op_tx, - #[cfg(feature = "unstable")] acp_handle, } } diff --git a/nori-rs/tui/src/chatwidget/constructors.rs b/nori-rs/tui/src/chatwidget/constructors.rs index d22046e13..513682422 100644 --- a/nori-rs/tui/src/chatwidget/constructors.rs +++ b/nori-rs/tui/src/chatwidget/constructors.rs @@ -25,7 +25,6 @@ impl ChatWidget { let (op_tx, _) = tokio::sync::mpsc::unbounded_channel(); SpawnAgentResult { op_tx, - #[cfg(feature = "unstable")] acp_handle: None, } } else { @@ -90,7 +89,6 @@ impl ChatWidget { pending_agent: None, expected_agent, session_configured_received: false, - #[cfg(feature = "unstable")] acp_handle: spawn_result.acp_handle, session_stats: SessionStats::new(), login_handler: None, @@ -200,7 +198,6 @@ impl ChatWidget { pending_agent: None, expected_agent, session_configured_received: false, - #[cfg(feature = "unstable")] acp_handle: spawn_result.acp_handle, session_stats: SessionStats::new(), login_handler: None, @@ -241,9 +238,6 @@ impl ChatWidget { pub(crate) fn spawn_deferred_agent(&mut self, config: Config, app_event_tx: AppEventSender) { let spawn_result = spawn_agent(config, app_event_tx, None); self.codex_op_tx = spawn_result.op_tx; - #[cfg(feature = "unstable")] - { - self.acp_handle = spawn_result.acp_handle; - } + self.acp_handle = spawn_result.acp_handle; } } diff --git a/nori-rs/tui/src/chatwidget/key_handling.rs b/nori-rs/tui/src/chatwidget/key_handling.rs index da186c61e..fa004d35c 100644 --- a/nori-rs/tui/src/chatwidget/key_handling.rs +++ b/nori-rs/tui/src/chatwidget/key_handling.rs @@ -114,6 +114,9 @@ impl ChatWidget { SlashCommand::Model => { self.open_model_popup(); } + SlashCommand::SessionConfig => { + self.open_session_config_popup(); + } SlashCommand::Approvals => { self.open_approvals_popup(); } diff --git a/nori-rs/tui/src/chatwidget/mod.rs b/nori-rs/tui/src/chatwidget/mod.rs index d5dc0c086..951b0b935 100644 --- a/nori-rs/tui/src/chatwidget/mod.rs +++ b/nori-rs/tui/src/chatwidget/mod.rs @@ -118,7 +118,6 @@ use self::interrupts::InterruptManager; mod pending_exec_cells; use self::pending_exec_cells::PendingExecCellTracker; mod agent; -#[cfg(feature = "unstable")] pub(crate) use self::agent::AcpAgentHandle; use self::agent::spawn_acp_agent_resume; use self::agent::spawn_agent; @@ -402,8 +401,7 @@ pub(crate) struct ChatWidget { // Whether SessionConfigured has been received for this widget. // Used with expected_agent to filter events from previous agents. session_configured_received: bool, - // ACP agent handle for model switching (only present in ACP mode) - #[cfg(feature = "unstable")] + // ACP agent handle for session config and model switching (only present in ACP mode) acp_handle: Option, // Session statistics tracking session_stats: SessionStats, diff --git a/nori-rs/tui/src/chatwidget/pickers.rs b/nori-rs/tui/src/chatwidget/pickers.rs index af3dc7045..192d1a227 100644 --- a/nori-rs/tui/src/chatwidget/pickers.rs +++ b/nori-rs/tui/src/chatwidget/pickers.rs @@ -630,6 +630,21 @@ impl ChatWidget { self.bottom_pane.show_selection_view(params); } + /// Open the generic ACP session-config picker. + pub(crate) fn open_session_config_popup(&mut self) { + if let Some(handle) = self.acp_handle.clone() { + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let config_options = handle.get_session_config().await.unwrap_or_default(); + app_event_tx.send(AppEvent::OpenAcpSessionConfigPicker { config_options }); + }); + return; + } + + let params = crate::nori::session_config_picker::acp_session_config_picker_params(&[]); + self.bottom_pane.show_selection_view(params); + } + /// Open the ACP model picker with fetched models. #[cfg(feature = "unstable")] pub(crate) fn open_acp_model_picker( @@ -644,6 +659,26 @@ impl ChatWidget { self.bottom_pane.show_selection_view(params); } + /// Open the top-level ACP session-config picker with the current config snapshot. + pub(crate) fn open_acp_session_config_picker( + &mut self, + config_options: Vec, + ) { + let params = + crate::nori::session_config_picker::acp_session_config_picker_params(&config_options); + self.bottom_pane.show_selection_view(params); + } + + /// Open the value picker for one ACP session config option. + pub(crate) fn open_acp_session_config_value_picker( + &mut self, + option: nori_acp::SessionConfigOption, + ) { + let params = + crate::nori::session_config_picker::acp_session_config_value_picker_params(&option); + self.bottom_pane.show_selection_view(params); + } + /// Set the ACP model via the agent handle. #[cfg(feature = "unstable")] pub(crate) fn set_acp_model(&mut self, model_id: String, display_name: String) { @@ -679,6 +714,47 @@ impl ChatWidget { ); } } + + /// Set an ACP session config option via the agent handle. + pub(crate) fn set_acp_session_config_option( + &mut self, + config_id: String, + value: String, + option_name: String, + value_name: String, + ) { + if let Some(handle) = self.acp_handle.clone() { + let app_event_tx = self.app_event_tx.clone(); + let option_name_for_result = option_name.clone(); + let value_name_for_result = value_name.clone(); + tokio::spawn(async move { + match handle.set_session_config_option(config_id, value).await { + Ok(()) => { + app_event_tx.send(AppEvent::AcpSessionConfigSetResult { + success: true, + option_name: option_name_for_result, + value_name: value_name_for_result, + error: None, + }); + } + Err(err) => { + app_event_tx.send(AppEvent::AcpSessionConfigSetResult { + success: false, + option_name: option_name_for_result, + value_name: value_name_for_result, + error: Some(err.to_string()), + }); + } + } + }); + self.add_info_message(format!("Updating {option_name} to: {value_name}..."), None); + } else { + self.add_info_message( + "No ACP agent handle available for session config".to_string(), + None, + ); + } + } } fn spawn_resume_summary_task( diff --git a/nori-rs/tui/src/nori/mod.rs b/nori-rs/tui/src/nori/mod.rs index ff1afad32..642a54903 100644 --- a/nori-rs/tui/src/nori/mod.rs +++ b/nori-rs/tui/src/nori/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod exit_message; pub(crate) mod fork_picker; pub(crate) mod onboarding; pub(crate) mod resume_session_picker; +pub(crate) mod session_config_picker; pub(crate) mod session_header; pub(crate) mod skillset_picker; pub(crate) mod token_count; diff --git a/nori-rs/tui/src/nori/session_config_picker.rs b/nori-rs/tui/src/nori/session_config_picker.rs new file mode 100644 index 000000000..ffd4c9b0b --- /dev/null +++ b/nori-rs/tui/src/nori/session_config_picker.rs @@ -0,0 +1,304 @@ +//! Generic picker helpers for ACP session config options. + +use nori_acp as acp; +use ratatui::text::Line; + +use crate::app_event::AppEvent; +use crate::bottom_pane::SelectionAction; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; + +pub(crate) fn acp_session_config_picker_params( + config_options: &[acp::SessionConfigOption], +) -> SelectionViewParams { + let supported = config_options + .iter() + .filter(|option| matches!(option.kind, acp::SessionConfigKind::Select(_))) + .cloned() + .collect::>(); + + if supported.is_empty() { + return SelectionViewParams { + title: Some("Session Config".to_string()), + subtitle: Some("No ACP session settings available".to_string()), + footer_hint: Some(Line::from("Press esc to dismiss.")), + items: vec![SelectionItem { + name: "No editable ACP session config options".to_string(), + description: Some( + "This agent did not expose any supported select-style session settings." + .to_string(), + ), + dismiss_on_select: true, + ..Default::default() + }], + ..Default::default() + }; + } + + let items = supported + .iter() + .map(|option| { + let current_value = + current_value_label(option).unwrap_or_else(|| "unknown".to_string()); + let option_for_action = option.clone(); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenAcpSessionConfigValuePicker { + option: option_for_action.clone(), + }); + })]; + + SelectionItem { + name: format!("{} ({current_value})", option.name), + description: option.description.clone(), + actions, + dismiss_on_select: true, + search_value: Some(format!("{} {current_value}", option.name)), + ..Default::default() + } + }) + .collect(); + + SelectionViewParams { + title: Some("Session Config".to_string()), + subtitle: Some("Select an ACP session setting to change".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx: Some(0), + ..Default::default() + } +} + +pub(crate) fn acp_session_config_value_picker_params( + option: &acp::SessionConfigOption, +) -> SelectionViewParams { + let acp::SessionConfigKind::Select(select) = &option.kind else { + return SelectionViewParams { + title: Some(option.name.clone()), + subtitle: Some("Unsupported ACP config type".to_string()), + footer_hint: Some(Line::from("Press esc to dismiss.")), + items: vec![SelectionItem { + name: "This ACP config type is not supported yet".to_string(), + description: option.description.clone(), + dismiss_on_select: true, + ..Default::default() + }], + ..Default::default() + }; + }; + + let mut items = Vec::new(); + let mut initial_selected_idx = None; + + match &select.options { + acp::SessionConfigSelectOptions::Ungrouped(options) => { + for option_value in options { + let idx = items.len(); + if option_value.value == select.current_value && initial_selected_idx.is_none() { + initial_selected_idx = Some(idx); + } + items.push(value_item( + option, + option_value, + None, + option_value.value == select.current_value, + )); + } + } + acp::SessionConfigSelectOptions::Grouped(groups) => { + for group in groups { + items.push(group_header_item(group)); + for option_value in &group.options { + let idx = items.len(); + if option_value.value == select.current_value && initial_selected_idx.is_none() + { + initial_selected_idx = Some(idx); + } + items.push(value_item( + option, + option_value, + Some(&group.name), + option_value.value == select.current_value, + )); + } + } + } + _ => {} + } + + if initial_selected_idx.is_none() { + initial_selected_idx = items.iter().position(|item| !item.actions.is_empty()); + } + + SelectionViewParams { + title: Some(option.name.clone()), + subtitle: Some( + option + .description + .clone() + .unwrap_or_else(|| "Select a value for this ACP session setting".to_string()), + ), + footer_hint: Some(standard_popup_hint_line()), + is_searchable: count_select_values(&select.options) >= 6, + search_placeholder: Some("Filter values".to_string()), + items, + initial_selected_idx, + ..Default::default() + } +} + +fn current_value_label(option: &acp::SessionConfigOption) -> Option { + let acp::SessionConfigKind::Select(select) = &option.kind else { + return None; + }; + + match &select.options { + acp::SessionConfigSelectOptions::Ungrouped(options) => options + .iter() + .find(|value| value.value == select.current_value) + .map(|value| value.name.clone()), + acp::SessionConfigSelectOptions::Grouped(groups) => groups + .iter() + .flat_map(|group| group.options.iter()) + .find(|value| value.value == select.current_value) + .map(|value| value.name.clone()), + _ => None, + } +} + +fn group_header_item(group: &acp::SessionConfigSelectGroup) -> SelectionItem { + SelectionItem { + name: format!("[{}]", group.name), + dismiss_on_select: false, + search_value: Some(group.name.clone()), + ..Default::default() + } +} + +fn value_item( + option: &acp::SessionConfigOption, + option_value: &acp::SessionConfigSelectOption, + group_name: Option<&str>, + is_current: bool, +) -> SelectionItem { + let config_id = option.id.to_string(); + let value = option_value.value.to_string(); + let option_name = option.name.clone(); + let value_name = option_value.name.clone(); + let group_name = group_name.map(str::to_string); + + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::SetAcpSessionConfigOption { + config_id: config_id.clone(), + value: value.clone(), + option_name: option_name.clone(), + value_name: value_name.clone(), + }); + })]; + + let description = match (&option_value.description, group_name) { + (Some(description), Some(group_name)) => Some(format!("[{group_name}] {description}")), + (Some(description), None) => Some(description.clone()), + (None, Some(group_name)) => Some(format!("Group: {group_name}")), + (None, None) => None, + }; + + SelectionItem { + name: option_value.name.clone(), + description, + is_current, + actions, + dismiss_on_select: true, + search_value: Some(format!( + "{} {}", + option_value.name, + option_value.description.clone().unwrap_or_default() + )), + ..Default::default() + } +} + +fn count_select_values(options: &acp::SessionConfigSelectOptions) -> usize { + match options { + acp::SessionConfigSelectOptions::Ungrouped(options) => options.len(), + acp::SessionConfigSelectOptions::Grouped(groups) => { + groups.iter().map(|group| group.options.len()).sum() + } + _ => 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn model_option() -> acp::SessionConfigOption { + acp::SessionConfigOption::select( + "model", + "Model", + "mock-model-default", + vec![ + acp::SessionConfigSelectOption::new("mock-model-default", "Mock Default Model"), + acp::SessionConfigSelectOption::new("mock-model-fast", "Mock Fast Model"), + ], + ) + .category(acp::SessionConfigOptionCategory::Model) + } + + fn grouped_mode_option() -> acp::SessionConfigOption { + acp::SessionConfigOption::select( + "mode", + "Mode", + "plan", + vec![ + acp::SessionConfigSelectGroup::new( + "safe", + "Safe", + vec![acp::SessionConfigSelectOption::new("ask", "Ask")], + ), + acp::SessionConfigSelectGroup::new( + "active", + "Active", + vec![ + acp::SessionConfigSelectOption::new("plan", "Plan"), + acp::SessionConfigSelectOption::new("build", "Build"), + ], + ), + ], + ) + .category(acp::SessionConfigOptionCategory::Mode) + } + + #[test] + fn top_level_picker_shows_select_options_with_current_value() { + let params = super::acp_session_config_picker_params(&[model_option()]); + + assert_eq!(params.title.as_deref(), Some("Session Config")); + assert_eq!(params.items.len(), 1); + assert_eq!(params.items[0].name, "Model (Mock Default Model)"); + assert_eq!(params.initial_selected_idx, Some(0)); + } + + #[test] + fn value_picker_preserves_group_order_and_current_selection() { + let params = super::acp_session_config_value_picker_params(&grouped_mode_option()); + let names = params + .items + .iter() + .map(|item| item.name.as_str()) + .collect::>(); + + assert_eq!(names, vec!["[Safe]", "Ask", "[Active]", "Plan", "Build"]); + assert!(params.items[3].is_current); + assert_eq!(params.initial_selected_idx, Some(3)); + } + + #[test] + fn empty_picker_explains_when_agent_exposes_no_supported_options() { + let params = super::acp_session_config_picker_params(&[]); + + assert_eq!(params.title.as_deref(), Some("Session Config")); + assert_eq!(params.items.len(), 1); + assert!(params.items[0].actions.is_empty()); + } +} diff --git a/nori-rs/tui/src/slash_command.rs b/nori-rs/tui/src/slash_command.rs index beb9634fb..a34ff5735 100644 --- a/nori-rs/tui/src/slash_command.rs +++ b/nori-rs/tui/src/slash_command.rs @@ -14,6 +14,7 @@ pub enum SlashCommand { // more frequently used commands should be listed first. Agent, Model, + SessionConfig, Approvals, Config, New, @@ -56,6 +57,7 @@ impl SlashCommand { SlashCommand::Memory => "show the contents of all active instruction files", SlashCommand::FirstPrompt => "show the first prompt from this session", SlashCommand::Model => "choose what model and reasoning effort to use", + SlashCommand::SessionConfig => "configure ACP session settings exposed by the agent", SlashCommand::Approvals => "choose what Nori can do without approval", SlashCommand::Config => "toggle config settings", SlashCommand::Mcp => "manage MCP server connections", @@ -83,6 +85,7 @@ impl SlashCommand { | SlashCommand::Compact | SlashCommand::Undo | SlashCommand::Model + | SlashCommand::SessionConfig | SlashCommand::Approvals | SlashCommand::Config | SlashCommand::Mcp