diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index bc49a90fa6..62a4989b77 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -198,7 +198,7 @@ jobs: shell: bash run: | set -euo pipefail - git clone --depth 1 https://github.com/bminor/bash /tmp/bash + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash cd /tmp/bash git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b @@ -240,7 +240,7 @@ jobs: shell: bash run: | set -euo pipefail - git clone --depth 1 https://github.com/bminor/bash /tmp/bash + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash cd /tmp/bash git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b diff --git a/AGENTS.md b/AGENTS.md index 9c14089e5f..427bf92fef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,7 @@ In the codex-rs folder where the rust code lives: Run `just fmt` (in `codex-rs` directory) automatically after making Rust code changes; do not ask for approval to run it. Before finalizing a change to `codex-rs`, run `just fix -p ` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Additionally, run the tests: 1. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `cargo test -p codex-tui`. -2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test --all-features`. - When running interactively, ask the user before running `just fix` to finalize. `just fmt` does not require approval. project-specific or individual tests can be run without asking the user, but do ask the user before running the complete test suite. +2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test --all-features`. project-specific or individual tests can be run without asking the user, but do ask the user before running the complete test suite. ## TUI style conventions diff --git a/codex-rs/app-server/tests/suite/v2/request_user_input.rs b/codex-rs/app-server/tests/suite/v2/request_user_input.rs index 37122e08a2..8b1f1aedde 100644 --- a/codex-rs/app-server/tests/suite/v2/request_user_input.rs +++ b/codex-rs/app-server/tests/suite/v2/request_user_input.rs @@ -12,6 +12,8 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; use tokio::time::timeout; @@ -52,6 +54,11 @@ async fn request_user_input_round_trip() -> Result<()> { }], model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), + collaboration_mode: Some(CollaborationMode::Plan(Settings { + model: "mock-model".to_string(), + reasoning_effort: Some(ReasoningEffort::Medium), + developer_instructions: None, + })), ..Default::default() }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 89b127d399..d2fa72ef14 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -62,7 +62,9 @@ async fn thread_resume_returns_original_thread() -> Result<()> { let ThreadResumeResponse { thread: resumed, .. } = to_response::(resume_resp)?; - assert_eq!(resumed, thread); + let mut expected = thread; + expected.updated_at = resumed.updated_at; + assert_eq!(resumed, expected); Ok(()) } @@ -179,7 +181,9 @@ async fn thread_resume_prefers_path_over_thread_id() -> Result<()> { let ThreadResumeResponse { thread: resumed, .. } = to_response::(resume_resp)?; - assert_eq!(resumed, thread); + let mut expected = thread; + expected.updated_at = resumed.updated_at; + assert_eq!(resumed, expected); Ok(()) } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 25c1161c8d..dc4f648a4f 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -28,6 +28,7 @@ use codex_tui::ExitReason; use codex_tui::update_action::UpdateAction; use codex_tui2 as tui2; use owo_colors::OwoColorize; +use std::io::IsTerminal; use std::path::PathBuf; use supports_color::Stream; @@ -45,6 +46,7 @@ use codex_core::features::Feature; use codex_core::features::FeatureOverrides; use codex_core::features::Features; use codex_core::features::is_known_feature_key; +use codex_core::terminal::TerminalName; use codex_utils_absolute_path::AbsolutePathBuf; /// Codex CLI @@ -735,6 +737,25 @@ async fn run_interactive_tui( // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state. interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n")); } + + let terminal_info = codex_core::terminal::terminal_info(); + if terminal_info.name == TerminalName::Dumb { + if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) { + return Ok(AppExitInfo::fatal( + "TERM is set to \"dumb\". Refusing to start the interactive TUI because no terminal is available for a confirmation prompt (stdin/stderr is not a TTY). Run in a supported terminal or unset TERM.", + )); + } + + eprintln!( + "WARNING: TERM is set to \"dumb\". Codex's interactive TUI may not work in this terminal." + ); + if !confirm("Continue anyway? [y/N]: ")? { + return Ok(AppExitInfo::fatal( + "Refusing to start the interactive TUI because TERM is set to \"dumb\". Run in a supported terminal or unset TERM.", + )); + } + } + if is_tui2_enabled(&interactive).await? { let result = tui2::run_main(interactive.into(), codex_linux_sandbox_exe).await?; Ok(result.into()) @@ -743,6 +764,15 @@ async fn run_interactive_tui( } } +fn confirm(prompt: &str) -> std::io::Result { + eprintln!("{prompt}"); + + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let answer = input.trim(); + Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes")) +} + /// Returns `Ok(true)` when the resolved configuration enables the `tui2` feature flag. /// /// This performs a lightweight config load (honoring the same precedence as the lower-level TUI diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index d65caad869..50b3f40c53 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -113,7 +113,9 @@ impl ResponsesWebsocketClient { extra_headers: HeaderMap, turn_state: Option>>, ) -> Result { - let ws_url = Url::parse(&self.provider.url_for_path("responses")) + let ws_url = self + .provider + .websocket_url_for_path("responses") .map_err(|err| ApiError::Stream(format!("failed to build websocket URL: {err}")))?; let mut headers = self.provider.headers.clone(); diff --git a/codex-rs/codex-api/src/provider.rs b/codex-rs/codex-api/src/provider.rs index 846a25bf5e..8fba2905bf 100644 --- a/codex-rs/codex-api/src/provider.rs +++ b/codex-rs/codex-api/src/provider.rs @@ -6,6 +6,7 @@ use http::Method; use http::header::HeaderMap; use std::collections::HashMap; use std::time::Duration; +use url::Url; /// Wire-level APIs supported by a `Provider`. #[derive(Debug, Clone, PartialEq, Eq)] @@ -105,6 +106,19 @@ impl Provider { self.base_url.to_ascii_lowercase().contains("openai.azure.") || matches_azure_responses_base_url(&self.base_url) } + + pub fn websocket_url_for_path(&self, path: &str) -> Result { + let mut url = Url::parse(&self.url_for_path(path))?; + + let scheme = match url.scheme() { + "http" => "ws", + "https" => "wss", + "ws" | "wss" => return Ok(url), + _ => return Ok(url), + }; + let _ = url.set_scheme(scheme); + Ok(url) + } } fn matches_azure_responses_base_url(base_url: &str) -> bool { diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 32318cd62d..e9ec0d7e65 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4,6 +4,14 @@ "description": "Base config deserialized from ~/.codex/config.toml.", "type": "object", "properties": { + "agents": { + "description": "Agent-related settings (thread limits, etc.).", + "allOf": [ + { + "$ref": "#/definitions/AgentsToml" + } + ] + }, "analytics": { "description": "When `false`, disables analytics across Codex product surfaces in this machine. Defaults to `true`.", "allOf": [ @@ -53,14 +61,6 @@ "experimental_compact_prompt_file": { "$ref": "#/definitions/AbsolutePathBuf" }, - "experimental_instructions_file": { - "description": "Legacy, now use features", - "allOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - } - ] - }, "experimental_use_freeform_apply_patch": { "type": "boolean" }, @@ -117,6 +117,9 @@ "remote_models": { "type": "boolean" }, + "responses_websockets": { + "type": "boolean" + }, "shell_snapshot": { "type": "boolean" }, @@ -240,6 +243,14 @@ "type": "integer", "format": "int64" }, + "model_instructions_file": { + "description": "Optional path to a file containing model instructions that will override the built-in instructions for the selected model. Users are STRONGLY DISCOURAGED from using this field, as deviating from the instructions sanctioned by Codex will likely degrade model performance.", + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ] + }, "model_personality": { "description": "EXPERIMENTAL Optionally specify a personality for the model", "allOf": [ @@ -433,6 +444,18 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AgentsToml": { + "type": "object", + "properties": { + "max_threads": { + "description": "Maximum number of agent threads that can be open concurrently. When unset, no limit is enforced.", + "type": "integer", + "format": "uint", + "minimum": 1.0 + } + }, + "additionalProperties": false + }, "AltScreenMode": { "description": "Controls whether the TUI uses the terminal's alternate screen buffer.\n\n**Background:** The alternate screen buffer provides a cleaner fullscreen experience without polluting the terminal's scrollback history. However, it conflicts with terminal multiplexers like Zellij that strictly follow the xterm specification, which defines that alternate screen buffers should not have scrollback.\n\n**Zellij's behavior:** Zellij intentionally disables scrollback in alternate screen mode (see https://github.com/zellij-org/zellij/pull/1032) to comply with the xterm spec. This is by design and not configurable in Zellij—there is no option to enable scrollback in alternate screen mode.\n\n**Solution:** This setting provides a pragmatic workaround: - `auto` (default): Automatically detect the terminal multiplexer. If running in Zellij, disable alternate screen to preserve scrollback. Enable it everywhere else. - `always`: Always use alternate screen mode (original behavior before this fix). - `never`: Never use alternate screen mode. Runs in inline mode, preserving scrollback in all multiplexers.\n\nThe CLI flag `--no-alt-screen` can override this setting at runtime.", "oneOf": [ @@ -545,9 +568,6 @@ "experimental_compact_prompt_file": { "$ref": "#/definitions/AbsolutePathBuf" }, - "experimental_instructions_file": { - "$ref": "#/definitions/AbsolutePathBuf" - }, "experimental_use_freeform_apply_patch": { "type": "boolean" }, @@ -604,6 +624,9 @@ "remote_models": { "type": "boolean" }, + "responses_websockets": { + "type": "boolean" + }, "shell_snapshot": { "type": "boolean" }, @@ -640,6 +663,14 @@ "model": { "type": "string" }, + "model_instructions_file": { + "description": "Optional path to a file containing model instructions.", + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ] + }, "model_personality": { "$ref": "#/definitions/Personality" }, diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 4467494fcf..6c5ebf9f02 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -1,4 +1,5 @@ use crate::agent::AgentStatus; +use crate::agent::guards::Guards; use crate::error::CodexErr; use crate::error::Result as CodexResult; use crate::thread_manager::ThreadManagerState; @@ -12,18 +13,25 @@ use tokio::sync::watch; /// Control-plane handle for multi-agent operations. /// `AgentControl` is held by each session (via `SessionServices`). It provides capability to /// spawn new agents and the inter-agent communication layer. +/// An `AgentControl` instance is shared per "user session" which means the same `AgentControl` +/// is used for every sub-agent spawned by Codex. By doing so, we make sure the guards are +/// scoped to a user session. #[derive(Clone, Default)] pub(crate) struct AgentControl { /// Weak handle back to the global thread registry/state. /// This is `Weak` to avoid reference cycles and shadow persistence of the form /// `ThreadManagerState -> CodexThread -> Session -> SessionServices -> ThreadManagerState`. manager: Weak, + state: Arc, } impl AgentControl { /// Construct a new `AgentControl` that can spawn/message agents via the given manager state. pub(crate) fn new(manager: Weak) -> Self { - Self { manager } + Self { + manager, + ..Default::default() + } } /// Spawn a new agent thread and submit the initial prompt. @@ -33,7 +41,11 @@ impl AgentControl { prompt: String, ) -> CodexResult { let state = self.upgrade()?; + let reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; + + // The same `AgentControl` is sent to spawn the thread. let new_thread = state.spawn_new_thread(config, self.clone()).await?; + reservation.commit(new_thread.thread_id); // Notify a new thread has been created. This notification will be processed by clients // to subscribe or drain this newly created thread. @@ -67,6 +79,7 @@ impl AgentControl { .await; if matches!(result, Err(CodexErr::InternalAgentDied)) { let _ = state.remove_thread(&agent_id).await; + self.state.release_spawned_thread(agent_id); } result } @@ -82,6 +95,7 @@ impl AgentControl { let state = self.upgrade()?; let result = state.send_op(agent_id, Op::Shutdown {}).await; let _ = state.remove_thread(&agent_id).await; + self.state.release_spawned_thread(agent_id); result } @@ -132,17 +146,25 @@ mod tests { use codex_protocol::protocol::TurnStartedEvent; use pretty_assertions::assert_eq; use tempfile::TempDir; + use toml::Value as TomlValue; - async fn test_config() -> (TempDir, Config) { + async fn test_config_with_cli_overrides( + cli_overrides: Vec<(String, TomlValue)>, + ) -> (TempDir, Config) { let home = TempDir::new().expect("create temp dir"); let config = ConfigBuilder::default() .codex_home(home.path().to_path_buf()) + .cli_overrides(cli_overrides) .build() .await .expect("load default test config"); (home, config) } + async fn test_config() -> (TempDir, Config) { + test_config_with_cli_overrides(Vec::new()).await + } + struct AgentControlHarness { _home: TempDir, config: Config, @@ -373,4 +395,117 @@ mod tests { .find(|entry| *entry == expected); assert_eq!(captured, Some(expected)); } + + #[tokio::test] + async fn spawn_agent_respects_max_threads_limit() { + let max_threads = 1usize; + let (_home, config) = test_config_with_cli_overrides(vec![( + "agents.max_threads".to_string(), + TomlValue::Integer(max_threads as i64), + )]) + .await; + let manager = ThreadManager::with_models_provider_and_home( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.clone(), + ); + let control = manager.agent_control(); + + let _ = manager + .start_thread(config.clone()) + .await + .expect("start thread"); + + let first_agent_id = control + .spawn_agent(config.clone(), "hello".to_string()) + .await + .expect("spawn_agent should succeed"); + + let err = control + .spawn_agent(config, "hello again".to_string()) + .await + .expect_err("spawn_agent should respect max threads"); + let CodexErr::AgentLimitReached { + max_threads: seen_max_threads, + } = err + else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(seen_max_threads, max_threads); + + let _ = control + .shutdown_agent(first_agent_id) + .await + .expect("shutdown agent"); + } + + #[tokio::test] + async fn spawn_agent_releases_slot_after_shutdown() { + let max_threads = 1usize; + let (_home, config) = test_config_with_cli_overrides(vec![( + "agents.max_threads".to_string(), + TomlValue::Integer(max_threads as i64), + )]) + .await; + let manager = ThreadManager::with_models_provider_and_home( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.clone(), + ); + let control = manager.agent_control(); + + let first_agent_id = control + .spawn_agent(config.clone(), "hello".to_string()) + .await + .expect("spawn_agent should succeed"); + let _ = control + .shutdown_agent(first_agent_id) + .await + .expect("shutdown agent"); + + let second_agent_id = control + .spawn_agent(config.clone(), "hello again".to_string()) + .await + .expect("spawn_agent should succeed after shutdown"); + let _ = control + .shutdown_agent(second_agent_id) + .await + .expect("shutdown agent"); + } + + #[tokio::test] + async fn spawn_agent_limit_shared_across_clones() { + let max_threads = 1usize; + let (_home, config) = test_config_with_cli_overrides(vec![( + "agents.max_threads".to_string(), + TomlValue::Integer(max_threads as i64), + )]) + .await; + let manager = ThreadManager::with_models_provider_and_home( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.clone(), + ); + let control = manager.agent_control(); + let cloned = control.clone(); + + let first_agent_id = cloned + .spawn_agent(config.clone(), "hello".to_string()) + .await + .expect("spawn_agent should succeed"); + + let err = control + .spawn_agent(config, "hello again".to_string()) + .await + .expect_err("spawn_agent should respect shared guard"); + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + let _ = control + .shutdown_agent(first_agent_id) + .await + .expect("shutdown agent"); + } } diff --git a/codex-rs/core/src/agent/guards.rs b/codex-rs/core/src/agent/guards.rs new file mode 100644 index 0000000000..c384ed7cd8 --- /dev/null +++ b/codex-rs/core/src/agent/guards.rs @@ -0,0 +1,193 @@ +use crate::error::CodexErr; +use crate::error::Result; +use codex_protocol::ThreadId; +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +/// This structure is used to add some limits on the multi-agent capabilities for Codex. In +/// the current implementation, it limits: +/// * Total number of sub-agents (i.e. threads) per user session +/// +/// This structure is shared by all agents in the same user session (because the `AgentControl` +/// is). +#[derive(Default)] +pub(crate) struct Guards { + threads_set: Mutex>, + total_count: AtomicUsize, +} + +impl Guards { + pub(crate) fn reserve_spawn_slot( + self: &Arc, + max_threads: Option, + ) -> Result { + if let Some(max_threads) = max_threads { + if !self.try_increment_spawned(max_threads) { + return Err(CodexErr::AgentLimitReached { max_threads }); + } + } else { + self.total_count.fetch_add(1, Ordering::AcqRel); + } + Ok(SpawnReservation { + state: Arc::clone(self), + active: true, + }) + } + + pub(crate) fn release_spawned_thread(&self, thread_id: ThreadId) { + let removed = { + let mut threads = self + .threads_set + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + threads.remove(&thread_id) + }; + if removed { + self.total_count.fetch_sub(1, Ordering::AcqRel); + } + } + + fn register_spawned_thread(&self, thread_id: ThreadId) { + let mut threads = self + .threads_set + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + threads.insert(thread_id); + } + + fn try_increment_spawned(&self, max_threads: usize) -> bool { + let mut current = self.total_count.load(Ordering::Acquire); + loop { + if current >= max_threads { + return false; + } + match self.total_count.compare_exchange_weak( + current, + current + 1, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return true, + Err(updated) => current = updated, + } + } + } +} + +pub(crate) struct SpawnReservation { + state: Arc, + active: bool, +} + +impl SpawnReservation { + pub(crate) fn commit(mut self, thread_id: ThreadId) { + self.state.register_spawned_thread(thread_id); + self.active = false; + } +} + +impl Drop for SpawnReservation { + fn drop(&mut self) { + if self.active { + self.state.total_count.fetch_sub(1, Ordering::AcqRel); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn reservation_drop_releases_slot() { + let guards = Arc::new(Guards::default()); + let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); + drop(reservation); + + let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot released"); + drop(reservation); + } + + #[test] + fn commit_holds_slot_until_release() { + let guards = Arc::new(Guards::default()); + let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let thread_id = ThreadId::new(); + reservation.commit(thread_id); + + let err = match guards.reserve_spawn_slot(Some(1)) { + Ok(_) => panic!("limit should be enforced"), + Err(err) => err, + }; + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + guards.release_spawned_thread(thread_id); + let reservation = guards + .reserve_spawn_slot(Some(1)) + .expect("slot released after thread removal"); + drop(reservation); + } + + #[test] + fn release_ignores_unknown_thread_id() { + let guards = Arc::new(Guards::default()); + let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let thread_id = ThreadId::new(); + reservation.commit(thread_id); + + guards.release_spawned_thread(ThreadId::new()); + + let err = match guards.reserve_spawn_slot(Some(1)) { + Ok(_) => panic!("limit should still be enforced"), + Err(err) => err, + }; + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + guards.release_spawned_thread(thread_id); + let reservation = guards + .reserve_spawn_slot(Some(1)) + .expect("slot released after real thread removal"); + drop(reservation); + } + + #[test] + fn release_is_idempotent_for_registered_threads() { + let guards = Arc::new(Guards::default()); + let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let first_id = ThreadId::new(); + reservation.commit(first_id); + + guards.release_spawned_thread(first_id); + + let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot reused"); + let second_id = ThreadId::new(); + reservation.commit(second_id); + + guards.release_spawned_thread(first_id); + + let err = match guards.reserve_spawn_slot(Some(1)) { + Ok(_) => panic!("limit should still be enforced"), + Err(err) => err, + }; + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + guards.release_spawned_thread(second_id); + let reservation = guards + .reserve_spawn_slot(Some(1)) + .expect("slot released after second thread removal"); + drop(reservation); + } +} diff --git a/codex-rs/core/src/agent/mod.rs b/codex-rs/core/src/agent/mod.rs index 0387eda494..180f70dbe6 100644 --- a/codex-rs/core/src/agent/mod.rs +++ b/codex-rs/core/src/agent/mod.rs @@ -1,4 +1,6 @@ pub(crate) mod control; +// Do not put in `pub` or `pub(crate)`. This code should not be used somewhere else. +mod guards; pub(crate) mod role; pub(crate) mod status; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b7dbd07969..a05521e804 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -666,6 +666,19 @@ impl Session { msg: EventMsg::DeprecationNotice(DeprecationNoticeEvent { summary, details }), }); } + if crate::config::uses_deprecated_instructions_file(&config.config_layer_stack) { + post_session_configured_events.push(Event { + id: INITIAL_SUBMIT_ID.to_owned(), + msg: EventMsg::DeprecationNotice(DeprecationNoticeEvent { + summary: "`experimental_instructions_file` is deprecated and ignored. Use `model_instructions_file` instead." + .to_string(), + details: Some( + "Move the setting to `model_instructions_file` in config.toml (or under a profile) to load instructions from a file." + .to_string(), + ), + }), + }); + } maybe_push_chat_wire_api_deprecation(&config, &mut post_session_configured_events); let auth = auth_manager.auth().await; @@ -710,18 +723,12 @@ impl Session { let mut default_shell = shell::default_user_shell(); // Create the mutable state for the Session. if config.features.enabled(Feature::ShellSnapshot) { - let timer = otel_manager.start_timer("codex.shell_snapshot.duration_ms", &[]); - default_shell.shell_snapshot = - ShellSnapshot::try_new(&config.codex_home, conversation_id, &default_shell) - .await - .map(Arc::new); - let success = if default_shell.shell_snapshot.is_some() { - "true" - } else { - "false" - }; - let _ = timer.map(|timer| timer.record(&[("success", success)])); - otel_manager.counter("codex.shell_snapshot", 1, &[("success", success)]) + ShellSnapshot::start_snapshotting( + config.codex_home.clone(), + conversation_id, + &mut default_shell, + otel_manager.clone(), + ); } let state = SessionState::new(session_configuration.clone()); @@ -1518,6 +1525,11 @@ impl Session { self.features.clone() } + pub(crate) async fn collaboration_mode(&self) -> CollaborationMode { + let state = self.state.lock().await; + state.session_configuration.collaboration_mode.clone() + } + async fn send_raw_response_items(&self, turn_context: &TurnContext, items: &[ResponseItem]) { for item in items { self.send_event( @@ -2558,7 +2570,7 @@ mod handlers { .filter(|item| is_user_turn_boundary(item)) .count(); sess.services.otel_manager.counter( - "conversation.turn.count", + "codex.conversation.turn.count", i64::try_from(turn_count).unwrap_or(0), &[], ); @@ -2794,10 +2806,11 @@ pub(crate) async fn run_turn( .await, ); + let otel_manager = turn_context.client.get_otel_manager(); let SkillInjections { items: skill_items, warnings: skill_warnings, - } = build_skill_injections(&input, skills_outcome.as_ref()).await; + } = build_skill_injections(&input, skills_outcome.as_ref(), Some(&otel_manager)).await; for message in skill_warnings { sess.send_event(&turn_context, EventMsg::Warning(WarningEvent { message })) diff --git a/codex-rs/core/src/command_safety/windows_dangerous_commands.rs b/codex-rs/core/src/command_safety/windows_dangerous_commands.rs index d4b418d93a..c7f13adab0 100644 --- a/codex-rs/core/src/command_safety/windows_dangerous_commands.rs +++ b/codex-rs/core/src/command_safety/windows_dangerous_commands.rs @@ -82,6 +82,11 @@ fn is_dangerous_powershell(command: &[String]) -> bool { } } + // Check for force delete operations (e.g., Remove-Item -Force) + if has_force_delete_cmdlet(&tokens_lc) { + return true; + } + false } @@ -107,15 +112,49 @@ fn is_dangerous_cmd(command: &[String]) -> bool { } } - let Some(first_cmd) = iter.next() else { - return false; - }; - // Classic `cmd /c start https://...` ShellExecute path. - if !first_cmd.eq_ignore_ascii_case("start") { + let remaining: Vec = iter.cloned().collect(); + if remaining.is_empty() { return false; } - let remaining: Vec = iter.cloned().collect(); - args_have_url(&remaining) + + let cmd_tokens: Vec = match remaining.as_slice() { + [only] => shlex_split(only).unwrap_or_else(|| vec![only.clone()]), + _ => remaining, + }; + + // Refine tokens by splitting concatenated CMD operators (e.g. "echo hi&del") + let tokens: Vec = cmd_tokens + .into_iter() + .flat_map(|t| split_embedded_cmd_operators(&t)) + .collect(); + + const CMD_SEPARATORS: &[&str] = &["&", "&&", "|", "||"]; + tokens + .split(|t| CMD_SEPARATORS.contains(&t.as_str())) + .any(|segment| { + let Some(cmd) = segment.first() else { + return false; + }; + + // Classic `cmd /c ... start https://...` ShellExecute path. + if cmd.eq_ignore_ascii_case("start") && args_have_url(segment) { + return true; + } + // Force delete: del /f, erase /f + if (cmd.eq_ignore_ascii_case("del") || cmd.eq_ignore_ascii_case("erase")) + && has_force_flag_cmd(segment) + { + return true; + } + // Recursive directory removal: rd /s /q, rmdir /s /q + if (cmd.eq_ignore_ascii_case("rd") || cmd.eq_ignore_ascii_case("rmdir")) + && has_recursive_flag_cmd(segment) + && has_quiet_flag_cmd(segment) + { + return true; + } + false + }) } fn is_direct_gui_launch(command: &[String]) -> bool { @@ -149,6 +188,123 @@ fn is_direct_gui_launch(command: &[String]) -> bool { false } +fn split_embedded_cmd_operators(token: &str) -> Vec { + // Split concatenated CMD operators so `echo hi&del` becomes `["echo hi", "&", "del"]`. + // Handles `&`, `&&`, `|`, `||`. Best-effort (CMD escaping is weird by nature). + let mut parts = Vec::new(); + let mut start = 0; + let mut it = token.char_indices().peekable(); + + while let Some((i, ch)) = it.next() { + if ch == '&' || ch == '|' { + if i > start { + parts.push(token[start..i].to_string()); + } + + // Detect doubled operator: && or || + let op_len = match it.peek() { + Some(&(j, next)) if next == ch => { + it.next(); // consume second char + (j + next.len_utf8()) - i + } + _ => ch.len_utf8(), + }; + + parts.push(token[i..i + op_len].to_string()); + start = i + op_len; + } + } + + if start < token.len() { + parts.push(token[start..].to_string()); + } + + parts.retain(|s| !s.trim().is_empty()); + parts +} + +fn has_force_delete_cmdlet(tokens: &[String]) -> bool { + const DELETE_CMDLETS: &[&str] = &["remove-item", "ri", "rm", "del", "erase", "rd", "rmdir"]; + + // Hard separators that end a command segment (so -Force must be in same segment) + const SEG_SEPS: &[char] = &[';', '|', '&', '\n', '\r', '\t']; + + // Soft separators: punctuation that can stick to tokens (blocks, parens, brackets, commas, etc.) + const SOFT_SEPS: &[char] = &['{', '}', '(', ')', '[', ']', ',', ';']; + + // Build rough command segments first + let mut segments: Vec> = vec![Vec::new()]; + for tok in tokens { + // If token itself contains segment separators, split it (best-effort) + let mut cur = String::new(); + for ch in tok.chars() { + if SEG_SEPS.contains(&ch) { + let s = cur.trim(); + if let Some(msg) = segments.last_mut() + && !s.is_empty() + { + msg.push(s.to_string()); + } + cur.clear(); + if let Some(last) = segments.last() + && !last.is_empty() + { + segments.push(Vec::new()); + } + } else { + cur.push(ch); + } + } + let s = cur.trim(); + if let Some(segment) = segments.last_mut() + && !s.is_empty() + { + segment.push(s.to_string()); + } + } + + // Now, inside each segment, normalize tokens by splitting on soft punctuation + segments.into_iter().any(|seg| { + let atoms = seg + .iter() + .flat_map(|t| t.split(|c| SOFT_SEPS.contains(&c))) + .map(str::trim) + .filter(|s| !s.is_empty()); + + let mut has_delete = false; + let mut has_force = false; + + for a in atoms { + if DELETE_CMDLETS.iter().any(|cmd| a.eq_ignore_ascii_case(cmd)) { + has_delete = true; + } + if a.eq_ignore_ascii_case("-force") + || a.get(..7) + .is_some_and(|p| p.eq_ignore_ascii_case("-force:")) + { + has_force = true; + } + } + + has_delete && has_force + }) +} + +/// Check for /f or /F flag in CMD del/erase arguments. +fn has_force_flag_cmd(args: &[String]) -> bool { + args.iter().any(|a| a.eq_ignore_ascii_case("/f")) +} + +/// Check for /s or /S flag in CMD rd/rmdir arguments. +fn has_recursive_flag_cmd(args: &[String]) -> bool { + args.iter().any(|a| a.eq_ignore_ascii_case("/s")) +} + +/// Check for /q or /Q flag in CMD rd/rmdir arguments. +fn has_quiet_flag_cmd(args: &[String]) -> bool { + args.iter().any(|a| a.eq_ignore_ascii_case("/q")) +} + fn args_have_url(args: &[String]) -> bool { args.iter().any(|arg| looks_like_url(arg)) } @@ -313,4 +469,287 @@ mod tests { "." ]))); } + + // Force delete tests for PowerShell + + #[test] + fn powershell_remove_item_force_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "Remove-Item test -Force" + ]))); + } + + #[test] + fn powershell_remove_item_recurse_force_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "Remove-Item test -Recurse -Force" + ]))); + } + + #[test] + fn powershell_ri_alias_force_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "pwsh", + "-Command", + "ri test -Force" + ]))); + } + + #[test] + fn powershell_remove_item_without_force_is_not_flagged() { + assert!(!is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "Remove-Item test" + ]))); + } + + // Force delete tests for CMD + #[test] + fn cmd_del_force_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", "/c", "del", "/f", "test.txt" + ]))); + } + + #[test] + fn cmd_erase_force_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", "/c", "erase", "/f", "test.txt" + ]))); + } + + #[test] + fn cmd_del_without_force_is_not_flagged() { + assert!(!is_dangerous_command_windows(&vec_str(&[ + "cmd", "/c", "del", "test.txt" + ]))); + } + + #[test] + fn cmd_rd_recursive_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", "/c", "rd", "/s", "/q", "test" + ]))); + } + + #[test] + fn cmd_rd_without_quiet_is_not_flagged() { + assert!(!is_dangerous_command_windows(&vec_str(&[ + "cmd", "/c", "rd", "/s", "test" + ]))); + } + + #[test] + fn cmd_rmdir_recursive_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", "/c", "rmdir", "/s", "/q", "test" + ]))); + } + + // Test exact scenario from issue #8567 + #[test] + fn powershell_remove_item_path_recurse_force_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "Remove-Item -Path 'test' -Recurse -Force" + ]))); + } + + #[test] + fn powershell_remove_item_force_with_semicolon_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "Remove-Item test -Force; Write-Host done" + ]))); + } + + #[test] + fn powershell_remove_item_force_inside_block_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "if ($true) { Remove-Item test -Force}" + ]))); + } + + #[test] + fn powershell_remove_item_force_inside_brackets_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "[void]( Remove-Item test -Force)]" + ]))); + } + + #[test] + fn cmd_del_path_containing_f_is_not_flagged() { + assert!(!is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + "del", + "C:/foo/bar.txt" + ]))); + } + + #[test] + fn cmd_rd_path_containing_s_is_not_flagged() { + assert!(!is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + "rd", + "C:/source" + ]))); + } + + #[test] + fn cmd_bypass_chained_del_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", "/c", "echo", "hello", "&", "del", "/f", "file.txt" + ]))); + } + + #[test] + fn powershell_chained_no_space_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "Write-Host hi;Remove-Item -Force C:\\tmp" + ]))); + } + + #[test] + fn powershell_comma_separated_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "del,-Force,C:\\foo" + ]))); + } + + #[test] + fn cmd_echo_del_is_not_dangerous() { + assert!(!is_dangerous_command_windows(&vec_str(&[ + "cmd", "/c", "echo", "del", "/f" + ]))); + } + + #[test] + fn cmd_del_single_string_argument_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + "del /f file.txt" + ]))); + } + + #[test] + fn cmd_del_chained_single_string_argument_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + "echo hello & del /f file.txt" + ]))); + } + + #[test] + fn cmd_chained_no_space_del_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + "echo hi&del /f file.txt" + ]))); + } + + #[test] + fn cmd_chained_andand_no_space_del_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + "echo hi&&del /f file.txt" + ]))); + } + + #[test] + fn cmd_chained_oror_no_space_del_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + "echo hi||del /f file.txt" + ]))); + } + + #[test] + fn cmd_start_url_single_string_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + "start https://example.com" + ]))); + } + + #[test] + fn cmd_chained_no_space_rmdir_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + "echo hi&rmdir /s /q testdir" + ]))); + } + + #[test] + fn cmd_del_force_uppercase_flag_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", "/c", "DEL", "/F", "file.txt" + ]))); + } + + #[test] + fn cmdexe_r_del_force_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd.exe", "/r", "del", "/f", "file.txt" + ]))); + } + + #[test] + fn cmd_start_quoted_url_single_string_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + r#"start "https://example.com""# + ]))); + } + + #[test] + fn cmd_start_title_then_url_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + r#"start "" https://example.com"# + ]))); + } + + #[test] + fn powershell_rm_alias_force_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "rm test -Force" + ]))); + } + + #[test] + fn powershell_benign_force_separate_command_is_not_dangerous() { + assert!(!is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "Get-ChildItem -Force; Remove-Item test" + ]))); + } } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 7d1426e7a4..1e409559e6 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -89,6 +89,7 @@ pub use codex_git::GhostSnapshotConfig; /// files are *silently truncated* to this size so we do not take up too much of /// the context window. pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB +pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option = None; pub const CONFIG_TOML_FILE: &str = "config.toml"; @@ -299,6 +300,9 @@ pub struct Config { /// Token budget applied when storing tool/function outputs in the context manager. pub tool_output_token_limit: Option, + /// Maximum number of agent threads that can be open concurrently. + pub agent_max_threads: Option, + /// Directory containing all Codex state (defaults to `~/.codex` but can be /// overridden by the `CODEX_HOME` environment variable). pub codex_home: PathBuf, @@ -820,6 +824,12 @@ pub struct ConfigToml { #[serde(default)] pub developer_instructions: Option, + /// Optional path to a file containing model instructions that will override + /// the built-in instructions for the selected model. Users are STRONGLY + /// DISCOURAGED from using this field, as deviating from the instructions + /// sanctioned by Codex will likely degrade model performance. + pub model_instructions_file: Option, + /// Compact prompt used for history compaction. pub compact_prompt: Option, @@ -918,6 +928,9 @@ pub struct ConfigToml { /// Nested tools section for feature toggles pub tools: Option, + /// Agent-related settings (thread limits, etc.). + pub agents: Option, + /// User-level skill config entries keyed by SKILL.md path. pub skills: Option, @@ -965,6 +978,8 @@ pub struct ConfigToml { pub notice: Option, /// Legacy, now use features + /// Deprecated: ignored. Use `model_instructions_file`. + #[schemars(skip)] pub experimental_instructions_file: Option, pub experimental_compact_prompt_file: Option, pub experimental_use_unified_exec_tool: Option, @@ -1025,6 +1040,15 @@ pub struct ToolsToml { pub view_image: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AgentsToml { + /// Maximum number of agent threads that can be open concurrently. + /// When unset, no limit is enforced. + #[schemars(range(min = 1))] + pub max_threads: Option, +} + impl From for Tools { fn from(tools_toml: ToolsToml) -> Self { Self { @@ -1355,6 +1379,12 @@ impl Config { || cfg.sandbox_mode.is_some(); let mut model_providers = built_in_model_providers(); + if features.enabled(Feature::ResponsesWebsockets) + && let Some(provider) = model_providers.get_mut("openai") + && provider.is_openai() + { + provider.wire_api = crate::model_provider_info::WireApi::ResponsesWebsocket; + } // Merge user-defined providers into the built-in list. for (key, provider) in cfg.model_providers.into_iter() { model_providers.entry(key).or_insert(provider); @@ -1378,6 +1408,18 @@ impl Config { let history = cfg.history.unwrap_or_default(); + let agent_max_threads = cfg + .agents + .as_ref() + .and_then(|agents| agents.max_threads) + .or(DEFAULT_AGENT_MAX_THREADS); + if agent_max_threads == Some(0) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "agents.max_threads must be at least 1", + )); + } + let ghost_snapshot = { let mut config = GhostSnapshotConfig::default(); if let Some(ghost_snapshot) = cfg.ghost_snapshot.as_ref() @@ -1432,14 +1474,12 @@ impl Config { // Load base instructions override from a file if specified. If the // path is relative, resolve it against the effective cwd so the // behaviour matches other path-like config values. - let experimental_instructions_path = config_profile - .experimental_instructions_file + let model_instructions_path = config_profile + .model_instructions_file .as_ref() - .or(cfg.experimental_instructions_file.as_ref()); - let file_base_instructions = Self::try_read_non_empty_file( - experimental_instructions_path, - "experimental instructions file", - )?; + .or(cfg.model_instructions_file.as_ref()); + let file_base_instructions = + Self::try_read_non_empty_file(model_instructions_path, "model instructions file")?; let base_instructions = base_instructions.or(file_base_instructions); let developer_instructions = developer_instructions.or(cfg.developer_instructions); @@ -1518,6 +1558,7 @@ impl Config { }) .collect(), tool_output_token_limit: cfg.tool_output_token_limit, + agent_max_threads, codex_home, config_layer_stack, history, @@ -1682,6 +1723,30 @@ impl Config { } } +pub(crate) fn uses_deprecated_instructions_file(config_layer_stack: &ConfigLayerStack) -> bool { + config_layer_stack + .layers_high_to_low() + .into_iter() + .any(|layer| toml_uses_deprecated_instructions_file(&layer.config)) +} + +fn toml_uses_deprecated_instructions_file(value: &TomlValue) -> bool { + let Some(table) = value.as_table() else { + return false; + }; + if table.contains_key("experimental_instructions_file") { + return true; + } + let Some(profiles) = table.get("profiles").and_then(TomlValue::as_table) else { + return false; + }; + profiles.values().any(|profile| { + profile.as_table().is_some_and(|profile_table| { + profile_table.contains_key("experimental_instructions_file") + }) + }) +} + /// Returns the path to the Codex configuration directory, which can be /// specified by the `CODEX_HOME` environment variable. If not set, defaults to /// `~/.codex`. @@ -2481,6 +2546,30 @@ profile = "project" Ok(()) } + #[test] + fn responses_websockets_feature_updates_openai_provider() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut entries = BTreeMap::new(); + entries.insert("responses_websockets".to_string(), true); + let cfg = ConfigToml { + features: Some(crate::features::FeaturesToml { entries }), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.model_provider.wire_api, + crate::model_provider_info::WireApi::ResponsesWebsocket + ); + + Ok(()) + } + #[test] fn config_honors_explicit_file_oauth_store_mode() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -3658,6 +3747,7 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, + agent_max_threads: None, codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), @@ -3746,6 +3836,7 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, + agent_max_threads: None, codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), @@ -3849,6 +3940,7 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, + agent_max_threads: None, codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), @@ -3938,6 +4030,7 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, + agent_max_threads: None, codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index 4f1a964ebc..e78b02374f 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -27,6 +27,10 @@ pub struct ConfigProfile { pub model_verbosity: Option, pub model_personality: Option, pub chatgpt_base_url: Option, + /// Optional path to a file containing model instructions. + pub model_instructions_file: Option, + /// Deprecated: ignored. Use `model_instructions_file`. + #[schemars(skip)] pub experimental_instructions_file: Option, pub experimental_compact_prompt_file: Option, pub include_apply_patch_tool: Option, diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 0460e01572..ffde19f1ff 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -656,7 +656,7 @@ mod unit_tests { let contents = r#" # This is a field recognized by config.toml that is an AbsolutePathBuf in # the ConfigToml struct. -experimental_instructions_file = "./some_file.md" +model_instructions_file = "./some_file.md" # This is a field recognized by config.toml. model = "gpt-1000" @@ -669,7 +669,7 @@ foo = "xyzzy" let normalized_toml_value = resolve_relative_paths_in_config_toml(user_config, base_dir)?; let mut expected_toml_value = toml::map::Map::new(); expected_toml_value.insert( - "experimental_instructions_file".to_string(), + "model_instructions_file".to_string(), TomlValue::String( AbsolutePathBuf::resolve_path_against_base("./some_file.md", base_dir)? .as_path() diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 773d9951d1..365fcdc59a 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -438,10 +438,10 @@ async fn project_paths_resolve_relative_to_dot_codex_and_override_in_order() -> tokio::fs::write(project_root.join(".git"), "gitdir: here").await?; let root_cfg = r#" -experimental_instructions_file = "root.txt" +model_instructions_file = "root.txt" "#; let nested_cfg = r#" -experimental_instructions_file = "child.txt" +model_instructions_file = "child.txt" "#; tokio::fs::write(project_root.join(".codex").join(CONFIG_TOML_FILE), root_cfg).await?; tokio::fs::write(nested.join(".codex").join(CONFIG_TOML_FILE), nested_cfg).await?; @@ -591,7 +591,7 @@ async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; let cli_overrides = vec![( - "experimental_instructions_file".to_string(), + "model_instructions_file".to_string(), TomlValue::String("relative.md".to_string()), )]; diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index 3e340ebbd3..28b5032fac 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -95,7 +95,7 @@ mod tests { Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), - shell_snapshot: None, + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), } } @@ -189,7 +189,7 @@ mod tests { Shell { shell_type: ShellType::Bash, shell_path: "/bin/bash".into(), - shell_snapshot: None, + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }, ); let context2 = EnvironmentContext::new( @@ -197,7 +197,7 @@ mod tests { Shell { shell_type: ShellType::Zsh, shell_path: "/bin/zsh".into(), - shell_snapshot: None, + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }, ); diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 3b490436e4..e9830f5189 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -78,6 +78,9 @@ pub enum CodexErr { #[error("no thread with id: {0}")] ThreadNotFound(ThreadId), + #[error("agent thread limit reached (max {max_threads})")] + AgentLimitReached { max_threads: usize }, + #[error("session configured event was not the first event in the stream")] SessionConfiguredNotFirstEvent, @@ -199,6 +202,7 @@ impl CodexErr { | CodexErr::RetryLimit(_) | CodexErr::ContextWindowExceeded | CodexErr::ThreadNotFound(_) + | CodexErr::AgentLimitReached { .. } | CodexErr::Spawn | CodexErr::SessionConfiguredNotFirstEvent | CodexErr::UsageLimitReached(_) => false, @@ -497,9 +501,9 @@ impl CodexErr { CodexErr::SessionConfiguredNotFirstEvent | CodexErr::InternalServerError | CodexErr::InternalAgentDied => CodexErrorInfo::InternalServerError, - CodexErr::UnsupportedOperation(_) | CodexErr::ThreadNotFound(_) => { - CodexErrorInfo::BadRequest - } + CodexErr::UnsupportedOperation(_) + | CodexErr::ThreadNotFound(_) + | CodexErr::AgentLimitReached { .. } => CodexErrorInfo::BadRequest, CodexErr::Sandbox(_) => CodexErrorInfo::SandboxError, _ => CodexErrorInfo::Other, } diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 1a4f74aaa8..eb943ecf7f 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -107,6 +107,8 @@ pub enum Feature { Steer, /// Enable collaboration modes (Plan, Pair Programming, Execute). CollaborationModes, + /// Use the Responses API WebSocket transport for OpenAI by default. + ResponsesWebsockets, } impl Feature { @@ -401,7 +403,7 @@ pub const FEATURES: &[FeatureSpec] = &[ id: Feature::RemoteModels, key: "remote_models", stage: Stage::Beta, - default_enabled: false, + default_enabled: true, }, FeatureSpec { id: Feature::PowershellUtf8, @@ -457,4 +459,10 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Beta, default_enabled: false, }, + FeatureSpec { + id: Feature::ResponsesWebsockets, + key: "responses_websockets", + stage: Stage::Beta, + default_enabled: false, + }, ]; diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index b6adf861d8..c866afd3bd 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -1,9 +1,9 @@ +use crate::shell_snapshot::ShellSnapshot; use serde::Deserialize; use serde::Serialize; use std::path::PathBuf; use std::sync::Arc; - -use crate::shell_snapshot::ShellSnapshot; +use tokio::sync::watch; #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub enum ShellType { @@ -14,12 +14,16 @@ pub enum ShellType { Cmd, } -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Shell { pub(crate) shell_type: ShellType, pub(crate) shell_path: PathBuf, - #[serde(skip_serializing, skip_deserializing, default)] - pub(crate) shell_snapshot: Option>, + #[serde( + skip_serializing, + skip_deserializing, + default = "empty_shell_snapshot_receiver" + )] + pub(crate) shell_snapshot: watch::Receiver>>, } impl Shell { @@ -63,8 +67,26 @@ impl Shell { } } } + + /// Return the shell snapshot if existing. + pub fn shell_snapshot(&self) -> Option> { + self.shell_snapshot.borrow().clone() + } } +pub(crate) fn empty_shell_snapshot_receiver() -> watch::Receiver>> { + let (_tx, rx) = watch::channel(None); + rx +} + +impl PartialEq for Shell { + fn eq(&self, other: &Self) -> bool { + self.shell_type == other.shell_type && self.shell_path == other.shell_path + } +} + +impl Eq for Shell {} + #[cfg(unix)] fn get_user_shell_path() -> Option { use libc::getpwuid; @@ -139,7 +161,7 @@ fn get_zsh_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Zsh, shell_path, - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), }) } @@ -149,7 +171,7 @@ fn get_bash_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Bash, shell_path, - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), }) } @@ -159,7 +181,7 @@ fn get_sh_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Sh, shell_path, - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), }) } @@ -175,7 +197,7 @@ fn get_powershell_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::PowerShell, shell_path, - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), }) } @@ -185,7 +207,7 @@ fn get_cmd_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Cmd, shell_path, - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), }) } @@ -194,13 +216,13 @@ fn ultimate_fallback_shell() -> Shell { Shell { shell_type: ShellType::Cmd, shell_path: PathBuf::from("cmd.exe"), - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), } } else { Shell { shell_type: ShellType::Sh, shell_path: PathBuf::from("/bin/sh"), - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), } } } @@ -426,7 +448,7 @@ mod tests { let test_bash_shell = Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), }; assert_eq!( test_bash_shell.derive_exec_args("echo hello", false), @@ -440,7 +462,7 @@ mod tests { let test_zsh_shell = Shell { shell_type: ShellType::Zsh, shell_path: PathBuf::from("/bin/zsh"), - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), }; assert_eq!( test_zsh_shell.derive_exec_args("echo hello", false), @@ -454,7 +476,7 @@ mod tests { let test_powershell_shell = Shell { shell_type: ShellType::PowerShell, shell_path: PathBuf::from("pwsh.exe"), - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), }; assert_eq!( test_powershell_shell.derive_exec_args("echo hello", false), @@ -481,7 +503,7 @@ mod tests { Shell { shell_type: ShellType::Zsh, shell_path: PathBuf::from(shell_path), - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), } ); } diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index be316afa5b..81ac293b90 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -1,6 +1,7 @@ use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use std::time::SystemTime; @@ -12,9 +13,11 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use anyhow::bail; +use codex_otel::OtelManager; use codex_protocol::ThreadId; use tokio::fs; use tokio::process::Command; +use tokio::sync::watch; use tokio::time::timeout; #[derive(Clone, Debug, PartialEq, Eq)] @@ -27,7 +30,31 @@ const SNAPSHOT_RETENTION: Duration = Duration::from_secs(60 * 60 * 24 * 7); // 7 const SNAPSHOT_DIR: &str = "shell_snapshots"; impl ShellSnapshot { - pub async fn try_new(codex_home: &Path, session_id: ThreadId, shell: &Shell) -> Option { + pub fn start_snapshotting( + codex_home: PathBuf, + session_id: ThreadId, + shell: &mut Shell, + otel_manager: OtelManager, + ) { + let (shell_snapshot_tx, shell_snapshot_rx) = watch::channel(None); + shell.shell_snapshot = shell_snapshot_rx; + + let snapshot_shell = shell.clone(); + let snapshot_session_id = session_id; + tokio::spawn(async move { + let timer = otel_manager.start_timer("codex.shell_snapshot.duration_ms", &[]); + let snapshot = + ShellSnapshot::try_new(&codex_home, snapshot_session_id, &snapshot_shell) + .await + .map(Arc::new); + let success = if snapshot.is_some() { "true" } else { "false" }; + let _ = timer.map(|timer| timer.record(&[("success", success)])); + otel_manager.counter("codex.shell_snapshot", 1, &[("success", success)]); + let _ = shell_snapshot_tx.send(snapshot); + }); + } + + async fn try_new(codex_home: &Path, session_id: ThreadId, shell: &Shell) -> Option { // File to store the snapshot let extension = match shell.shell_type { ShellType::PowerShell => "ps1", @@ -74,7 +101,7 @@ impl Drop for ShellSnapshot { } } -pub async fn write_shell_snapshot(shell_type: ShellType, output_path: &Path) -> Result { +async fn write_shell_snapshot(shell_type: ShellType, output_path: &Path) -> Result { if shell_type == ShellType::PowerShell || shell_type == ShellType::Cmd { bail!("Shell snapshot not supported yet for {shell_type:?}"); } @@ -407,7 +434,7 @@ mod tests { let shell = Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), - shell_snapshot: None, + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }; let snapshot = ShellSnapshot::try_new(dir.path(), ThreadId::new(), &shell) @@ -449,7 +476,7 @@ mod tests { let shell = Shell { shell_type: ShellType::Sh, shell_path, - shell_snapshot: None, + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }; let err = run_shell_script_with_timeout(&shell, "ignored", Duration::from_millis(500)) diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs index ffe879bcd9..9aa12d775c 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core/src/skills/injection.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use crate::instructions::SkillInstructions; use crate::skills::SkillLoadOutcome; use crate::skills::SkillMetadata; +use codex_otel::OtelManager; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; use tokio::fs; @@ -17,6 +18,7 @@ pub(crate) struct SkillInjections { pub(crate) async fn build_skill_injections( inputs: &[UserInput], skills: Option<&SkillLoadOutcome>, + otel: Option<&OtelManager>, ) -> SkillInjections { if inputs.is_empty() { return SkillInjections::default(); @@ -40,6 +42,7 @@ pub(crate) async fn build_skill_injections( for skill in mentioned_skills { match fs::read_to_string(&skill.path).await { Ok(contents) => { + emit_skill_injected_metric(otel, &skill, "ok"); result.items.push(ResponseItem::from(SkillInstructions { name: skill.name, path: skill.path.to_string_lossy().into_owned(), @@ -47,6 +50,7 @@ pub(crate) async fn build_skill_injections( })); } Err(err) => { + emit_skill_injected_metric(otel, &skill, "error"); let message = format!( "Failed to load skill {name} at {path}: {err:#}", name = skill.name, @@ -60,6 +64,18 @@ pub(crate) async fn build_skill_injections( result } +fn emit_skill_injected_metric(otel: Option<&OtelManager>, skill: &SkillMetadata, status: &str) { + let Some(otel) = otel else { + return; + }; + + otel.counter( + "codex.skill.injected", + 1, + &[("status", status), ("skill", skill.name.as_str())], + ); +} + fn collect_explicit_skill_mentions( inputs: &[UserInput], skills: &[SkillMetadata], diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 508b16d484..1f1f1eb59e 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -76,14 +76,15 @@ impl SessionTask for UserShellCommandTask { // We do not source rc files or otherwise reformat the script. let use_login_shell = true; let session_shell = session.user_shell(); - let command = session_shell.derive_exec_args(&self.command, use_login_shell); - let command = maybe_wrap_shell_lc_with_snapshot(&command, session_shell.as_ref()); + let display_command = session_shell.derive_exec_args(&self.command, use_login_shell); + let exec_command = + maybe_wrap_shell_lc_with_snapshot(&display_command, session_shell.as_ref()); let call_id = Uuid::new_v4().to_string(); let raw_command = self.command.clone(); let cwd = turn_context.cwd.clone(); - let parsed_cmd = parse_command(&command); + let parsed_cmd = parse_command(&display_command); session .send_event( turn_context.as_ref(), @@ -91,7 +92,7 @@ impl SessionTask for UserShellCommandTask { call_id: call_id.clone(), process_id: None, turn_id: turn_context.sub_id.clone(), - command: command.clone(), + command: display_command.clone(), cwd: cwd.clone(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, @@ -101,7 +102,7 @@ impl SessionTask for UserShellCommandTask { .await; let exec_env = ExecEnv { - command: command.clone(), + command: exec_command.clone(), cwd: cwd.clone(), env: create_env(&turn_context.shell_environment_policy), // TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we @@ -150,7 +151,7 @@ impl SessionTask for UserShellCommandTask { call_id, process_id: None, turn_id: turn_context.sub_id.clone(), - command: command.clone(), + command: display_command.clone(), cwd: cwd.clone(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, @@ -173,7 +174,7 @@ impl SessionTask for UserShellCommandTask { call_id: call_id.clone(), process_id: None, turn_id: turn_context.sub_id.clone(), - command: command.clone(), + command: display_command.clone(), cwd: cwd.clone(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, @@ -218,7 +219,7 @@ impl SessionTask for UserShellCommandTask { call_id, process_id: None, turn_id: turn_context.sub_id.clone(), - command, + command: display_command, cwd, parsed_cmd, source: ExecCommandSource::UserShell, diff --git a/codex-rs/core/src/terminal.rs b/codex-rs/core/src/terminal.rs index 32421aef72..8437962f6c 100644 --- a/codex-rs/core/src/terminal.rs +++ b/codex-rs/core/src/terminal.rs @@ -47,6 +47,8 @@ pub enum TerminalName { Vte, /// Windows Terminal emulator. WindowsTerminal, + /// Dumb terminal (TERM=dumb). + Dumb, /// Unknown or missing terminal identification. Unknown, } @@ -131,7 +133,12 @@ impl TerminalInfo { /// Creates terminal metadata from a `TERM` capability value. fn from_term(term: String, multiplexer: Option) -> Self { - Self::new(TerminalName::Unknown, None, None, Some(term), multiplexer) + let name = if term == "dumb" { + TerminalName::Dumb + } else { + TerminalName::Unknown + }; + Self::new(name, None, None, Some(term), multiplexer) } /// Creates terminal metadata for unknown terminals. @@ -166,6 +173,7 @@ impl TerminalInfo { TerminalName::GnomeTerminal => "gnome-terminal".to_string(), TerminalName::Vte => format_terminal_version("VTE", &self.version), TerminalName::WindowsTerminal => "WindowsTerminal".to_string(), + TerminalName::Dumb => "dumb".to_string(), TerminalName::Unknown => "unknown".to_string(), } }; @@ -435,6 +443,7 @@ fn terminal_name_from_term_program(value: &str) -> Option { "gnometerminal" => Some(TerminalName::GnomeTerminal), "vte" => Some(TerminalName::Vte), "windowsterminal" => Some(TerminalName::WindowsTerminal), + "dumb" => Some(TerminalName::Dumb), _ => None, } } @@ -1136,6 +1145,15 @@ mod tests { "term_fallback_user_agent" ); + let env = FakeEnvironment::new().with_var("TERM", "dumb"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Dumb, None, None, Some("dumb"), None), + "dumb_term_info" + ); + assert_eq!(terminal.user_agent_token(), "dumb", "dumb_term_user_agent"); + let env = FakeEnvironment::new(); let terminal = detect_terminal_info_from_env(&env); assert_eq!( diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 01dc99d91a..6aa4d378d2 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -366,7 +366,8 @@ impl ThreadManagerState { codex, session_configured.rollout_path.clone(), )); - self.threads.write().await.insert(thread_id, thread.clone()); + let mut threads = self.threads.write().await; + threads.insert(thread_id, thread.clone()); Ok(NewThread { thread_id, diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index f63b11d235..aca43a1d12 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -7,6 +7,7 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::request_user_input::RequestUserInputArgs; pub struct RequestUserInputHandler; @@ -35,6 +36,17 @@ impl ToolHandler for RequestUserInputHandler { } }; + let disallowed_mode = match session.collaboration_mode().await { + CollaborationMode::Execute(_) => Some("Execute"), + CollaborationMode::Custom(_) => Some("Custom"), + _ => None, + }; + if let Some(mode_name) = disallowed_mode { + return Err(FunctionCallError::RespondToModel(format!( + "request_user_input is unavailable in {mode_name} mode" + ))); + } + let args: RequestUserInputArgs = parse_arguments(&arguments)?; let response = session .request_user_input(turn.as_ref(), call_id, args) diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 0e14da68f2..dc9f198cc0 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -305,6 +305,7 @@ mod tests { use crate::shell::ShellType; use crate::shell_snapshot::ShellSnapshot; use crate::tools::handlers::ShellCommandHandler; + use tokio::sync::watch; /// The logic for is_known_safe_command() has heuristics for known shells, /// so we must ensure the commands generated by [ShellCommandHandler] can be @@ -314,14 +315,14 @@ mod tests { let bash_shell = Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), - shell_snapshot: None, + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }; assert_safe(&bash_shell, "ls -la"); let zsh_shell = Shell { shell_type: ShellType::Zsh, shell_path: PathBuf::from("/bin/zsh"), - shell_snapshot: None, + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }; assert_safe(&zsh_shell, "ls -la"); @@ -329,7 +330,7 @@ mod tests { let powershell = Shell { shell_type: ShellType::PowerShell, shell_path: path.to_path_buf(), - shell_snapshot: None, + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }; assert_safe(&powershell, "ls -Name"); } @@ -338,7 +339,7 @@ mod tests { let pwsh = Shell { shell_type: ShellType::PowerShell, shell_path: path.to_path_buf(), - shell_snapshot: None, + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }; assert_safe(&pwsh, "ls -Name"); } @@ -391,12 +392,13 @@ mod tests { #[test] fn shell_command_handler_respects_explicit_login_flag() { + let (_tx, shell_snapshot) = watch::channel(Some(Arc::new(ShellSnapshot { + path: PathBuf::from("/tmp/snapshot.sh"), + }))); let shell = Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), - shell_snapshot: Some(Arc::new(ShellSnapshot { - path: PathBuf::from("/tmp/snapshot.sh"), - })), + shell_snapshot, }; let login_command = diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index daa39bc358..c9c5a3a71d 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -236,7 +236,7 @@ impl ToolHandler for UnifiedExecHandler { fn get_command(args: &ExecCommandArgs, session_shell: Arc) -> Vec { let model_shell = args.shell.as_ref().map(|shell_str| { let mut shell = get_shell_by_model_provided_path(&PathBuf::from(shell_str)); - shell.shell_snapshot = None; + shell.shell_snapshot = crate::shell::empty_shell_snapshot_receiver(); shell }); diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 044173bfbd..ddf3671785 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -54,7 +54,7 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot( command: &[String], session_shell: &Shell, ) -> Vec { - let Some(snapshot) = &session_shell.shell_snapshot else { + let Some(snapshot) = session_shell.shell_snapshot() else { return command.to_vec(); }; diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 9108f263f9..57142b07f2 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -1,5 +1,6 @@ use assert_cmd::Command as AssertCommand; use codex_core::RolloutRecorder; +use codex_core::auth::CODEX_API_KEY_ENV_VAR; use codex_core::protocol::GitInfo; use codex_utils_cargo_bin::find_resource; use core_test_support::fs_wait; @@ -108,11 +109,11 @@ async fn chat_mode_stream_cli() { ); } -/// Verify that passing `-c experimental_instructions_file=...` to the CLI +/// Verify that passing `-c model_instructions_file=...` to the CLI /// overrides the built-in base instructions by inspecting the request body /// received by a mock OpenAI Responses endpoint. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn exec_cli_applies_experimental_instructions_file() { +async fn exec_cli_applies_model_instructions_file() { skip_if_no_network!(); // Start mock server which will capture the request and return a minimal @@ -127,7 +128,7 @@ async fn exec_cli_applies_experimental_instructions_file() { // Create a temporary instructions file with a unique marker we can assert // appears in the outbound request payload. let custom = TempDir::new().unwrap(); - let marker = "cli-experimental-instructions-marker"; + let marker = "cli-model-instructions-file-marker"; let custom_path = custom.path().join("instr.md"); std::fs::write(&custom_path, marker).unwrap(); let custom_path_str = custom_path.to_string_lossy().replace('\\', "/"); @@ -150,9 +151,7 @@ async fn exec_cli_applies_experimental_instructions_file() { .arg("-c") .arg("model_provider=\"mock\"") .arg("-c") - .arg(format!( - "experimental_instructions_file=\"{custom_path_str}\"" - )) + .arg(format!("model_instructions_file=\"{custom_path_str}\"")) .arg("-C") .arg(&repo_root) .arg("hello?\n"); @@ -239,7 +238,7 @@ async fn integration_creates_and_checks_session_file() -> anyhow::Result<()> { .arg(&repo_root) .arg(&prompt); cmd.env("CODEX_HOME", home.path()) - .env("OPENAI_API_KEY", "dummy") + .env(CODEX_API_KEY_ENV_VAR, "dummy") .env("CODEX_RS_SSE_FIXTURE", &fixture) // Required for CLI arg parsing even though fixture short-circuits network usage. .env("OPENAI_BASE_URL", "http://unused.local"); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index cfae930d95..1b43f7932f 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -826,7 +826,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { .get("reasoning") .and_then(|t| t.get("effort")) .and_then(|v| v.as_str()), - None + Some("medium") ); Ok(()) diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index e054908aec..b7ce49abbe 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -278,6 +278,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "tool_choice": "auto", "parallel_tool_calls": false, "reasoning": { + "effort": "medium", "summary": "auto" }, "store": false, @@ -348,6 +349,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "tool_choice": "auto", "parallel_tool_calls": false, "reasoning": { + "effort": "medium", "summary": "auto" }, "store": false, @@ -409,6 +411,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "tool_choice": "auto", "parallel_tool_calls": false, "reasoning": { + "effort": "medium", "summary": "auto" }, "store": false, @@ -511,6 +514,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "tool_choice": "auto", "parallel_tool_calls": false, "reasoning": { + "effort": "medium", "summary": "auto" }, "store": false, @@ -634,6 +638,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "tool_choice": "auto", "parallel_tool_calls": false, "reasoning": { + "effort": "medium", "summary": "auto" }, "store": false, diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index bab715ebd8..d401a64cd4 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -1,15 +1,22 @@ #![cfg(not(target_os = "windows"))] use anyhow::Ok; +use codex_app_server_protocol::ConfigLayerSource; +use codex_core::config_loader::ConfigLayerEntry; +use codex_core::config_loader::ConfigLayerStack; +use codex_core::config_loader::ConfigRequirements; +use codex_core::config_loader::ConfigRequirementsToml; use codex_core::features::Feature; use codex_core::protocol::DeprecationNoticeEvent; use codex_core::protocol::EventMsg; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::test_absolute_path; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; +use toml::Value as TomlValue; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<()> { @@ -48,3 +55,58 @@ async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<() Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn emits_deprecation_notice_for_experimental_instructions_file() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + let mut table = toml::map::Map::new(); + table.insert( + "experimental_instructions_file".to_string(), + TomlValue::String("legacy.md".to_string()), + ); + let config_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: test_absolute_path("/tmp/config.toml"), + }, + TomlValue::Table(table), + ); + let config_layer_stack = ConfigLayerStack::new( + vec![config_layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("build config layer stack"); + config.config_layer_stack = config_layer_stack; + }); + + let TestCodex { codex, .. } = builder.build(&server).await?; + + let notice = wait_for_event_match(&codex, |event| match event { + EventMsg::DeprecationNotice(ev) + if ev.summary.contains("experimental_instructions_file") => + { + Some(ev.clone()) + } + _ => None, + }) + .await; + + let DeprecationNoticeEvent { summary, details } = notice; + assert_eq!( + summary, + "`experimental_instructions_file` is deprecated and ignored. Use `model_instructions_file` instead." + .to_string(), + ); + assert_eq!( + details.as_deref(), + Some( + "Move the setting to `model_instructions_file` in config.toml (or under a profile) to load instructions from a file." + ), + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/list_models.rs b/codex-rs/core/tests/suite/list_models.rs index 5f21e94537..1791d28b01 100644 --- a/codex-rs/core/tests/suite/list_models.rs +++ b/codex-rs/core/tests/suite/list_models.rs @@ -4,11 +4,13 @@ use codex_core::ThreadManager; use codex_core::built_in_model_providers; use codex_core::models_manager::manager::RefreshStrategy; use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use core_test_support::load_default_config_for_test; use indoc::indoc; use pretty_assertions::assert_eq; +use std::collections::HashMap; use tempfile::tempdir; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -50,35 +52,21 @@ async fn list_models_returns_chatgpt_models() -> Result<()> { fn expected_models_for_api_key() -> Vec { vec![ gpt_52_codex(), - gpt_5_1_codex_max(), - gpt_5_1_codex_mini(), gpt_5_2(), + gpt_5_1_codex_max(), + gpt_5_1_codex(), + gpt_5_1_codex_mini(), + gpt_5_1(), + gpt_5_codex(), + gpt_5(), + gpt_5_codex_mini(), bengalfox(), boomslang(), - gpt_5_codex(), - gpt_5_codex_mini(), - gpt_5_1_codex(), - gpt_5(), - gpt_5_1(), ] } fn expected_models_for_chatgpt() -> Vec { - let mut gpt_5_1_codex_max = gpt_5_1_codex_max(); - gpt_5_1_codex_max.is_default = false; - vec![ - gpt_52_codex(), - gpt_5_1_codex_max, - gpt_5_1_codex_mini(), - gpt_5_2(), - bengalfox(), - boomslang(), - gpt_5_codex(), - gpt_5_codex_mini(), - gpt_5_1_codex(), - gpt_5(), - gpt_5_1(), - ] + expected_models_for_api_key() } fn gpt_52_codex() -> ModelPreset { @@ -139,7 +127,17 @@ fn gpt_5_1_codex_max() -> ModelPreset { ), ], is_default: false, - upgrade: Some(gpt52_codex_upgrade()), + upgrade: Some(gpt52_codex_upgrade( + "gpt-5.1-codex-max", + HashMap::from([ + (ReasoningEffort::Low, ReasoningEffort::Low), + (ReasoningEffort::None, ReasoningEffort::Low), + (ReasoningEffort::Medium, ReasoningEffort::Medium), + (ReasoningEffort::High, ReasoningEffort::High), + (ReasoningEffort::Minimal, ReasoningEffort::Low), + (ReasoningEffort::XHigh, ReasoningEffort::XHigh), + ]), + )), show_in_picker: true, supported_in_api: true, } @@ -163,7 +161,17 @@ fn gpt_5_1_codex_mini() -> ModelPreset { ), ], is_default: false, - upgrade: Some(gpt52_codex_upgrade()), + upgrade: Some(gpt52_codex_upgrade( + "gpt-5.1-codex-mini", + HashMap::from([ + (ReasoningEffort::High, ReasoningEffort::High), + (ReasoningEffort::XHigh, ReasoningEffort::High), + (ReasoningEffort::Minimal, ReasoningEffort::Medium), + (ReasoningEffort::None, ReasoningEffort::Medium), + (ReasoningEffort::Low, ReasoningEffort::Medium), + (ReasoningEffort::Medium, ReasoningEffort::Medium), + ]), + )), show_in_picker: true, supported_in_api: true, } @@ -193,11 +201,21 @@ fn gpt_5_2() -> ModelPreset { ), effort( ReasoningEffort::XHigh, - "Extra high reasoning depth for complex problems", + "Extra high reasoning for complex problems", ), ], is_default: false, - upgrade: Some(gpt52_codex_upgrade()), + upgrade: Some(gpt52_codex_upgrade( + "gpt-5.2", + HashMap::from([ + (ReasoningEffort::High, ReasoningEffort::High), + (ReasoningEffort::None, ReasoningEffort::Low), + (ReasoningEffort::Minimal, ReasoningEffort::Low), + (ReasoningEffort::Low, ReasoningEffort::Low), + (ReasoningEffort::Medium, ReasoningEffort::Medium), + (ReasoningEffort::XHigh, ReasoningEffort::XHigh), + ]), + )), show_in_picker: true, supported_in_api: true, } @@ -289,7 +307,17 @@ fn gpt_5_codex() -> ModelPreset { ), ], is_default: false, - upgrade: Some(gpt52_codex_upgrade()), + upgrade: Some(gpt52_codex_upgrade( + "gpt-5-codex", + HashMap::from([ + (ReasoningEffort::Minimal, ReasoningEffort::Low), + (ReasoningEffort::High, ReasoningEffort::High), + (ReasoningEffort::Medium, ReasoningEffort::Medium), + (ReasoningEffort::XHigh, ReasoningEffort::High), + (ReasoningEffort::None, ReasoningEffort::Low), + (ReasoningEffort::Low, ReasoningEffort::Low), + ]), + )), show_in_picker: false, supported_in_api: true, } @@ -313,7 +341,17 @@ fn gpt_5_codex_mini() -> ModelPreset { ), ], is_default: false, - upgrade: Some(gpt52_codex_upgrade()), + upgrade: Some(gpt52_codex_upgrade( + "gpt-5-codex-mini", + HashMap::from([ + (ReasoningEffort::None, ReasoningEffort::Medium), + (ReasoningEffort::XHigh, ReasoningEffort::High), + (ReasoningEffort::High, ReasoningEffort::High), + (ReasoningEffort::Low, ReasoningEffort::Medium), + (ReasoningEffort::Medium, ReasoningEffort::Medium), + (ReasoningEffort::Minimal, ReasoningEffort::Medium), + ]), + )), show_in_picker: false, supported_in_api: true, } @@ -341,7 +379,17 @@ fn gpt_5_1_codex() -> ModelPreset { ), ], is_default: false, - upgrade: Some(gpt52_codex_upgrade()), + upgrade: Some(gpt52_codex_upgrade( + "gpt-5.1-codex", + HashMap::from([ + (ReasoningEffort::Minimal, ReasoningEffort::Low), + (ReasoningEffort::Low, ReasoningEffort::Low), + (ReasoningEffort::Medium, ReasoningEffort::Medium), + (ReasoningEffort::None, ReasoningEffort::Low), + (ReasoningEffort::High, ReasoningEffort::High), + (ReasoningEffort::XHigh, ReasoningEffort::High), + ]), + )), show_in_picker: false, supported_in_api: true, } @@ -373,7 +421,17 @@ fn gpt_5() -> ModelPreset { ), ], is_default: false, - upgrade: Some(gpt52_codex_upgrade()), + upgrade: Some(gpt52_codex_upgrade( + "gpt-5", + HashMap::from([ + (ReasoningEffort::XHigh, ReasoningEffort::High), + (ReasoningEffort::Minimal, ReasoningEffort::Minimal), + (ReasoningEffort::Low, ReasoningEffort::Low), + (ReasoningEffort::None, ReasoningEffort::Minimal), + (ReasoningEffort::High, ReasoningEffort::High), + (ReasoningEffort::Medium, ReasoningEffort::Medium), + ]), + )), show_in_picker: false, supported_in_api: true, } @@ -401,27 +459,37 @@ fn gpt_5_1() -> ModelPreset { ), ], is_default: false, - upgrade: Some(gpt52_codex_upgrade()), + upgrade: Some(gpt52_codex_upgrade( + "gpt-5.1", + HashMap::from([ + (ReasoningEffort::None, ReasoningEffort::Low), + (ReasoningEffort::Medium, ReasoningEffort::Medium), + (ReasoningEffort::High, ReasoningEffort::High), + (ReasoningEffort::XHigh, ReasoningEffort::High), + (ReasoningEffort::Low, ReasoningEffort::Low), + (ReasoningEffort::Minimal, ReasoningEffort::Low), + ]), + )), show_in_picker: false, supported_in_api: true, } } -fn gpt52_codex_upgrade() -> codex_protocol::openai_models::ModelUpgrade { - codex_protocol::openai_models::ModelUpgrade { +fn gpt52_codex_upgrade( + migration_config_key: &str, + reasoning_effort_mapping: HashMap, +) -> ModelUpgrade { + ModelUpgrade { id: "gpt-5.2-codex".to_string(), - reasoning_effort_mapping: None, - migration_config_key: "gpt-5.2-codex".to_string(), - model_link: Some("https://openai.com/index/introducing-gpt-5-2-codex".to_string()), - upgrade_copy: Some( - "Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work." - .to_string(), - ), + reasoning_effort_mapping: Some(reasoning_effort_mapping), + migration_config_key: migration_config_key.to_string(), + model_link: None, + upgrade_copy: None, migration_markdown: Some( indoc! {r#" **Codex just got an upgrade. Introducing {model_to}.** - Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work. Learn more about {model_to} at https://openai.com/index/introducing-gpt-5-2-codex + Codex is now powered by {model_to}, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work. Learn more about {model_to} at https://openai.com/index/introducing-gpt-5-2-codex You can continue using {model_from} if you prefer. "#} diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 3c4405eeb3..adf9e3e9d3 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -7,7 +7,9 @@ use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::Settings; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -45,6 +47,27 @@ fn call_output(req: &ResponsesRequest, call_id: &str) -> String { } } +fn call_output_content_and_success( + req: &ResponsesRequest, + call_id: &str, +) -> (String, Option) { + let raw = req.function_call_output(call_id); + assert_eq!( + raw.get("call_id").and_then(Value::as_str), + Some(call_id), + "mismatched call_id in function_call_output" + ); + let (content_opt, success) = match req.function_call_output_content_and_success(call_id) { + Some(values) => values, + None => panic!("function_call_output present"), + }; + let content = match content_opt { + Some(content) => content, + None => panic!("function_call_output content present"), + }; + (content, success) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); @@ -109,7 +132,11 @@ async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> model: session_model, effort: None, summary: ReasoningSummary::Auto, - collaboration_mode: None, + collaboration_mode: Some(CollaborationMode::Plan(Settings { + model: session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + })), }) .await?; @@ -153,3 +180,112 @@ async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> Ok(()) } + +async fn assert_request_user_input_rejected(mode_name: &str, build_mode: F) -> anyhow::Result<()> +where + F: FnOnce(String) -> CollaborationMode, +{ + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let builder = test_codex(); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder + .with_config(|config| { + config.features.enable(Feature::CollaborationModes); + }) + .build(&server) + .await?; + + let mode_slug = mode_name.to_lowercase(); + let call_id = format!("user-input-{mode_slug}-call"); + let request_args = json!({ + "questions": [{ + "id": "confirm_path", + "header": "Confirm", + "question": "Proceed with the plan?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the current plan." + }, { + "label": "No", + "description": "Stop and revisit the approach." + }] + }] + }) + .to_string(); + + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_function_call(&call_id, "request_user_input", &request_args), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, first_response).await; + + let second_response = sse(vec![ + ev_assistant_message("msg-1", "thanks"), + ev_completed("resp-2"), + ]); + let second_mock = responses::mount_sse_once(&server, second_response).await; + + let session_model = session_configured.model.clone(); + let collaboration_mode = build_mode(session_model.clone()); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "please confirm".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + collaboration_mode: Some(collaboration_mode), + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let req = second_mock.single_request(); + let (output, success) = call_output_content_and_success(&req, &call_id); + assert_eq!(success, None); + assert_eq!( + output, + format!("request_user_input is unavailable in {mode_name} mode") + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn request_user_input_rejected_in_execute_mode() -> anyhow::Result<()> { + assert_request_user_input_rejected("Execute", |model| { + CollaborationMode::Execute(Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }) + }) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn request_user_input_rejected_in_custom_mode() -> anyhow::Result<()> { + assert_request_user_input_rejected("Custom", |model| { + CollaborationMode::Custom(Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }) + }) + .await +} diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 443a41abab..2177ab9bdf 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -20,9 +20,11 @@ use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; use serde_json::json; +use std::path::Path; use std::path::PathBuf; use tokio::fs; use tokio::time::Duration; +use tokio::time::Instant; use tokio::time::sleep; #[derive(Debug)] @@ -34,6 +36,24 @@ struct SnapshotRun { codex_home: PathBuf, } +async fn wait_for_snapshot(codex_home: &Path) -> Result { + let snapshot_dir = codex_home.join("shell_snapshots"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Ok(mut entries) = fs::read_dir(&snapshot_dir).await + && let Some(entry) = entries.next_entry().await? + { + return Ok(entry.path()); + } + + if Instant::now() >= deadline { + anyhow::bail!("timed out waiting for shell snapshot"); + } + + sleep(Duration::from_millis(25)).await; + } +} + #[allow(clippy::expect_used)] async fn run_snapshot_command(command: &str) -> Result { let builder = test_codex().with_config(|config| { @@ -89,12 +109,7 @@ async fn run_snapshot_command(command: &str) -> Result { _ => None, }) .await; - let mut entries = fs::read_dir(codex_home.join("shell_snapshots")).await?; - let snapshot_path = entries - .next_entry() - .await? - .map(|entry| entry.path()) - .expect("shell snapshot created"); + let snapshot_path = wait_for_snapshot(&codex_home).await?; let snapshot_content = fs::read_to_string(&snapshot_path).await?; let end = wait_for_event_match(&codex, |ev| match ev { @@ -167,12 +182,7 @@ async fn run_shell_command_snapshot(command: &str) -> Result { _ => None, }) .await; - let mut entries = fs::read_dir(codex_home.join("shell_snapshots")).await?; - let snapshot_path = entries - .next_entry() - .await? - .map(|entry| entry.path()) - .expect("shell snapshot created"); + let snapshot_path = wait_for_snapshot(&codex_home).await?; let snapshot_content = fs::read_to_string(&snapshot_path).await?; let end = wait_for_event_match(&codex, |ev| match ev { @@ -305,12 +315,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { assert_eq!(fs::read_to_string(&target).await?, "hello from snapshot\n"); - let mut entries = fs::read_dir(codex_home.join("shell_snapshots")).await?; - let snapshot_path = entries - .next_entry() - .await? - .map(|entry| entry.path()) - .expect("shell snapshot created"); + let snapshot_path = wait_for_snapshot(&codex_home).await?; let snapshot_content = fs::read_to_string(&snapshot_path).await?; assert_posix_snapshot_sections(&snapshot_content); @@ -328,12 +333,7 @@ async fn shell_snapshot_deleted_after_shutdown_with_skills() -> Result<()> { let codex_home = home.path().to_path_buf(); let codex = harness.test().codex.clone(); - let mut entries = fs::read_dir(codex_home.join("shell_snapshots")).await?; - let snapshot_path = entries - .next_entry() - .await? - .map(|entry| entry.path()) - .expect("shell snapshot created"); + let snapshot_path = wait_for_snapshot(&codex_home).await?; assert!(snapshot_path.exists()); codex.submit(Op::Shutdown {}).await?; diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index 0f32f5a33d..e9ca09580e 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -47,14 +47,30 @@ impl TextElement { } } + /// Returns a copy of this element with a remapped byte range. + /// + /// The placeholder is preserved as-is; callers must ensure the new range + /// still refers to the same logical element (and same placeholder) + /// within the new text. + pub fn map_range(&self, map: F) -> Self + where + F: FnOnce(ByteRange) -> ByteRange, + { + Self { + byte_range: map(self.byte_range), + placeholder: self.placeholder.clone(), + } + } + pub fn set_placeholder(&mut self, placeholder: Option) { self.placeholder = placeholder; } /// Returns the stored placeholder without falling back to the text buffer. /// - /// This is intended only for protocol conversions where the full text is not - /// available; prefer `placeholder(text)` for UI logic. + /// This must only be used inside `From` implementations on equivalent + /// protocol types where the source text is unavailable. Prefer `placeholder(text)` + /// everywhere else. #[doc(hidden)] pub fn _placeholder_for_conversion_only(&self) -> Option<&str> { self.placeholder.as_deref() diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b361425aeb..4378ec602f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -84,6 +84,17 @@ pub struct AppExitInfo { pub exit_reason: ExitReason, } +impl AppExitInfo { + pub fn fatal(message: impl Into) -> Self { + Self { + token_usage: TokenUsage::default(), + thread_id: None, + update_action: None, + exit_reason: ExitReason::Fatal(message.into()), + } + } +} + #[derive(Debug)] pub(crate) enum AppRunControl { Continue, @@ -950,6 +961,24 @@ impl App { .submit_op(Op::PatchApproval { id, decision }); } } + Op::UserInputAnswer { id, response } => { + if let Some((thread_id, original_id)) = + self.external_approval_routes.remove(&id) + { + self.forward_external_op( + thread_id, + Op::UserInputAnswer { + id: original_id, + response, + }, + ) + .await; + self.finish_external_approval(); + } else { + self.chat_widget + .submit_op(Op::UserInputAnswer { id, response }); + } + } // Standard path where this is not an external approval response. _ => self.chat_widget.submit_op(op), }, @@ -1454,6 +1483,14 @@ impl App { /// can be routed back to the correct thread. fn handle_external_approval_request(&mut self, thread_id: ThreadId, mut event: Event) { match &mut event.msg { + EventMsg::RequestUserInput(ev) => { + let original_id = ev.turn_id.clone(); + let routing_id = format!("{thread_id}:{original_id}"); + self.external_approval_routes + .insert(routing_id.clone(), (thread_id, original_id)); + ev.turn_id = routing_id.clone(); + event.id = routing_id; + } EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) => { let original_id = event.id.clone(); let routing_id = format!("{thread_id}:{original_id}"); @@ -1506,7 +1543,9 @@ impl App { } }; match event.msg { - EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) => { + EventMsg::ExecApprovalRequest(_) + | EventMsg::ApplyPatchApprovalRequest(_) + | EventMsg::RequestUserInput(_) => { app_event_tx.send(AppEvent::ExternalApprovalRequest { thread_id, event }); } _ => {} diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 499801cbb0..b3be4fed1f 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -1,5 +1,6 @@ use crate::bottom_pane::ApprovalRequest; use crate::render::renderable::Renderable; +use codex_protocol::request_user_input::RequestUserInputEvent; use crossterm::event::KeyEvent; use super::CancellationEvent; @@ -34,4 +35,13 @@ pub(crate) trait BottomPaneView: Renderable { ) -> Option { Some(request) } + + /// Try to handle request_user_input; return the original value if not + /// consumed. + fn try_consume_user_input_request( + &mut self, + request: RequestUserInputEvent, + ) -> Option { + Some(request) + } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index d54e7005eb..724c267a5e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -15,6 +15,18 @@ //! [`ChatComposer::handle_key_event_without_popup`]. After every handled key, we call //! [`ChatComposer::sync_popups`] so UI state follows the latest buffer/cursor. //! +//! # Submission and Prompt Expansion +//! +//! On submit/queue paths, the composer: +//! +//! - Expands pending paste placeholders so element ranges align with the final text. +//! - Trims whitespace and rebases text elements accordingly. +//! - Expands `/prompts:` custom prompts (named or numeric args), preserving text elements. +//! - Prunes attached images so only placeholders that survive expansion are sent. +//! +//! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion +//! and attachment pruning, and clears pending paste state on success. +//! //! # Non-bracketed Paste Bursts //! //! On some terminals (especially on Windows), pastes arrive as a rapid sequence of @@ -171,8 +183,14 @@ enum PromptSelectionMode { } enum PromptSelectionAction { - Insert { text: String, cursor: Option }, - Submit { text: String }, + Insert { + text: String, + cursor: Option, + }, + Submit { + text: String, + text_elements: Vec, + }, } pub(crate) struct ChatComposer { @@ -609,6 +627,18 @@ impl ChatComposer { .collect() } + fn prune_attached_images_for_submission(&mut self, text: &str, text_elements: &[TextElement]) { + if self.attached_images.is_empty() { + return; + } + let image_placeholders: HashSet<&str> = text_elements + .iter() + .filter_map(|elem| elem.placeholder(text)) + .collect(); + self.attached_images + .retain(|img| image_placeholders.contains(img.placeholder.as_str())); + } + /// Insert an attachment placeholder and track it for the next submission. pub fn attach_image(&mut self, path: PathBuf) { let image_number = self.attached_images.len() + 1; @@ -836,6 +866,7 @@ impl ChatComposer { prompt, first_line, PromptSelectionMode::Completion, + &self.textarea.text_elements(), ) { PromptSelectionAction::Insert { text, cursor } => { let target = cursor.unwrap_or(text.len()); @@ -862,19 +893,31 @@ impl ChatComposer { // If the current line starts with a custom prompt name and includes // positional args for a numeric-style template, expand and submit // immediately regardless of the popup selection. - let first_line = self.textarea.text().lines().next().unwrap_or(""); - if let Some((name, _rest)) = parse_slash_name(first_line) + let mut text = self.textarea.text().to_string(); + let mut text_elements = self.textarea.text_elements(); + if !self.pending_pastes.is_empty() { + let (expanded, expanded_elements) = + Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes); + text = expanded; + text_elements = expanded_elements; + } + let first_line = text.lines().next().unwrap_or(""); + if let Some((name, _rest, _rest_offset)) = parse_slash_name(first_line) && let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) && let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == prompt_name) && let Some(expanded) = - expand_if_numeric_with_positional_args(prompt, first_line) + expand_if_numeric_with_positional_args(prompt, first_line, &text_elements) { + self.prune_attached_images_for_submission( + &expanded.text, + &expanded.text_elements, + ); + self.pending_pastes.clear(); self.textarea.set_text_clearing_elements(""); return ( InputResult::Submitted { - text: expanded, - // Expanded prompt is plain text; no UI element ranges to preserve. - text_elements: Vec::new(), + text: expanded.text, + text_elements: expanded.text_elements, }, true, ); @@ -892,14 +935,21 @@ impl ChatComposer { prompt, first_line, PromptSelectionMode::Submit, + &self.textarea.text_elements(), ) { - PromptSelectionAction::Submit { text } => { + PromptSelectionAction::Submit { + text, + text_elements, + } => { + self.prune_attached_images_for_submission( + &text, + &text_elements, + ); self.textarea.set_text_clearing_elements(""); return ( InputResult::Submitted { text, - // Submitting a slash/custom prompt generates plain text, so there are no UI element ranges. - text_elements: Vec::new(), + text_elements, }, true, ); @@ -1558,11 +1608,10 @@ impl ChatComposer { let expanded_input = text.clone(); // If there is neither text nor attachments, suppress submission entirely. - let has_attachments = !self.attached_images.is_empty(); text = text.trim().to_string(); text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements); - if let Some((name, _rest)) = parse_slash_name(&text) { + if let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) { let treat_as_plain_text = input_starts_with_space || name.contains('/'); if !treat_as_plain_text { let is_builtin = @@ -1596,29 +1645,31 @@ impl ChatComposer { } } - let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { - Ok(expanded) => expanded, - Err(err) => { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event(err.user_message()), - ))); - self.set_text_content( - original_input.clone(), - original_text_elements, - original_local_image_paths, - ); - self.pending_pastes.clone_from(&original_pending_pastes); - self.textarea.set_cursor(original_input.len()); - return None; - } - }; + let expanded_prompt = + match expand_custom_prompt(&text, &text_elements, &self.custom_prompts) { + Ok(expanded) => expanded, + Err(err) => { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(err.user_message()), + ))); + self.set_text_content( + original_input.clone(), + original_text_elements, + original_local_image_paths, + ); + self.pending_pastes.clone_from(&original_pending_pastes); + self.textarea.set_cursor(original_input.len()); + return None; + } + }; if let Some(expanded) = expanded_prompt { - text = expanded; - // Expanded prompt (e.g. custom prompt) is plain text; text elements not supported yet. - // TODO: Preserve UI element ranges through prompt expansion in a follow-up PR. - text_elements = Vec::new(); + text = expanded.text; + text_elements = expanded.text_elements; } - if text.is_empty() && !has_attachments { + // Custom prompt expansion can remove or rewrite image placeholders, so prune any + // attachments that no longer have a corresponding placeholder in the expanded text. + self.prune_attached_images_for_submission(&text, &text_elements); + if text.is_empty() && self.attached_images.is_empty() { return None; } if !text.is_empty() { @@ -1720,7 +1771,7 @@ impl ChatComposer { /// Returns Some(InputResult) if a command was dispatched, None otherwise. fn try_dispatch_bare_slash_command(&mut self) -> Option { let first_line = self.textarea.text().lines().next().unwrap_or(""); - if let Some((name, rest)) = parse_slash_name(first_line) + if let Some((name, rest, _rest_offset)) = parse_slash_name(first_line) && rest.is_empty() && let Some((_n, cmd)) = Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) @@ -1741,7 +1792,7 @@ impl ChatComposer { if !input_starts_with_space { let text = self.textarea.text().to_string(); - if let Some((name, rest)) = parse_slash_name(&text) + if let Some((name, rest, _rest_offset)) = parse_slash_name(&text) && !rest.is_empty() && !name.contains('/') && let Some((_n, cmd)) = @@ -1816,7 +1867,7 @@ impl ChatComposer { modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, .. - } => self.handle_submission(true), + } if self.is_task_running => self.handle_submission(true), KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, @@ -2495,6 +2546,7 @@ fn prompt_selection_action( prompt: &CustomPrompt, first_line: &str, mode: PromptSelectionMode, + text_elements: &[TextElement], ) -> PromptSelectionAction { let named_args = prompt_argument_names(&prompt.content); let has_numeric = prompt_has_numeric_placeholders(&prompt.content); @@ -2526,14 +2578,21 @@ fn prompt_selection_action( }; } if has_numeric { - if let Some(expanded) = expand_if_numeric_with_positional_args(prompt, first_line) { - return PromptSelectionAction::Submit { text: expanded }; + if let Some(expanded) = + expand_if_numeric_with_positional_args(prompt, first_line, text_elements) + { + return PromptSelectionAction::Submit { + text: expanded.text, + text_elements: expanded.text_elements, + }; } let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); return PromptSelectionAction::Insert { text, cursor: None }; } PromptSelectionAction::Submit { text: prompt.content.clone(), + // By now we know this custom prompt has no args, so no text elements to preserve. + text_elements: Vec::new(), } } } @@ -2554,6 +2613,7 @@ mod tests { use crate::bottom_pane::InputResult; use crate::bottom_pane::chat_composer::AttachedImage; use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; + use crate::bottom_pane::prompt_args::PromptArg; use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line; use crate::bottom_pane::textarea::TextArea; use tokio::sync::mpsc::unbounded_channel; @@ -3801,15 +3861,37 @@ mod tests { let args = extract_positional_args_for_prompt_line( "/prompts:review \"docs/My File.md\"", "review", + &[], + ); + assert_eq!( + args, + vec![PromptArg { + text: "docs/My File.md".to_string(), + text_elements: Vec::new(), + }] ); - assert_eq!(args, vec!["docs/My File.md".to_string()]); } #[test] fn extract_args_supports_mixed_quoted_and_unquoted() { - let args = - extract_positional_args_for_prompt_line("/prompts:cmd \"with spaces\" simple", "cmd"); - assert_eq!(args, vec!["with spaces".to_string(), "simple".to_string()]); + let args = extract_positional_args_for_prompt_line( + "/prompts:cmd \"with spaces\" simple", + "cmd", + &[], + ); + assert_eq!( + args, + vec![ + PromptArg { + text: "with spaces".to_string(), + text_elements: Vec::new(), + }, + PromptArg { + text: "simple".to_string(), + text_elements: Vec::new(), + } + ] + ); } #[test] @@ -4868,6 +4950,166 @@ mod tests { assert!(composer.textarea.is_empty()); } + #[test] + fn custom_prompt_submission_preserves_image_placeholder_unquoted() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $IMG".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt IMG="); + composer.textarea.set_cursor(composer.textarea.text().len()); + let path = PathBuf::from("/tmp/image_prompt.png"); + composer.attach_image(path); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let placeholder = local_image_label_text(1); + assert_eq!(text, format!("Review {placeholder}")); + assert_eq!( + text_elements, + vec![TextElement::new( + ByteRange { + start: "Review ".len(), + end: "Review ".len() + placeholder.len(), + }, + Some(placeholder), + )] + ); + } + _ => panic!("expected Submitted"), + } + } + + #[test] + fn custom_prompt_submission_preserves_image_placeholder_quoted() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $IMG".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt IMG=\""); + composer.textarea.set_cursor(composer.textarea.text().len()); + let path = PathBuf::from("/tmp/image_prompt_quoted.png"); + composer.attach_image(path); + composer.handle_paste("\"".to_string()); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let placeholder = local_image_label_text(1); + assert_eq!(text, format!("Review {placeholder}")); + assert_eq!( + text_elements, + vec![TextElement::new( + ByteRange { + start: "Review ".len(), + end: "Review ".len() + placeholder.len(), + }, + Some(placeholder), + )] + ); + } + _ => panic!("expected Submitted"), + } + } + + #[test] + fn custom_prompt_submission_drops_unused_image_arg() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review changes".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt IMG="); + composer.textarea.set_cursor(composer.textarea.text().len()); + let path = PathBuf::from("/tmp/unused_image.png"); + composer.attach_image(path); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "Review changes"); + assert!(text_elements.is_empty()); + } + _ => panic!("expected Submitted"), + } + assert!(composer.take_recent_submission_images().is_empty()); + } + /// Behavior: selecting a custom prompt that includes a large paste placeholder should expand /// to the full pasted content before submission. #[test] @@ -4935,6 +5177,65 @@ mod tests { assert!(composer.pending_pastes.is_empty()); } + #[test] + fn custom_prompt_with_large_paste_and_image_preserves_elements() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $IMG\n\n$CODE".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt IMG="); + composer.textarea.set_cursor(composer.textarea.text().len()); + let path = PathBuf::from("/tmp/image_prompt_combo.png"); + composer.attach_image(path); + composer.handle_paste(" CODE=".to_string()); + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large_content.clone()); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let placeholder = local_image_label_text(1); + assert_eq!(text, format!("Review {placeholder}\n\n{large_content}")); + assert_eq!( + text_elements, + vec![TextElement::new( + ByteRange { + start: "Review ".len(), + end: "Review ".len() + placeholder.len(), + }, + Some(placeholder), + )] + ); + } + _ => panic!("expected Submitted"), + } + } + #[test] fn slash_path_input_submits_without_command_error() { use crossterm::event::KeyCode; @@ -5153,6 +5454,201 @@ mod tests { )); } + #[test] + fn popup_prompt_submission_prunes_unused_image_attachments() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Hello".to_string(), + description: None, + argument_hint: None, + }]); + + composer.attach_image(PathBuf::from("/tmp/unused.png")); + composer.textarea.set_cursor(0); + composer.handle_paste(format!("/{PROMPTS_CMD_PREFIX}:my-prompt ")); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == "Hello" + )); + assert!( + composer + .take_recent_submission_images_with_placeholders() + .is_empty() + ); + } + + #[test] + fn numeric_prompt_auto_submit_prunes_unused_image_attachments() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Hello $1".to_string(), + description: None, + argument_hint: None, + }]); + + type_chars_humanlike( + &mut composer, + &[ + '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm', + 'p', 't', ' ', 'f', 'o', 'o', ' ', + ], + ); + composer.attach_image(PathBuf::from("/tmp/unused.png")); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == "Hello foo" + )); + assert!( + composer + .take_recent_submission_images_with_placeholders() + .is_empty() + ); + } + + #[test] + fn numeric_prompt_auto_submit_expands_pending_pastes() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Echo: $1".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt "); + composer.textarea.set_cursor(composer.textarea.text().len()); + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large_content.clone()); + + assert_eq!(composer.pending_pastes.len(), 1); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let expected = format!("Echo: {large_content}"); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == expected + )); + assert!(composer.pending_pastes.is_empty()); + } + + #[test] + fn queued_prompt_submission_prunes_unused_image_attachments() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(false); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Hello $1".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt foo "); + composer.textarea.set_cursor(composer.textarea.text().len()); + composer.attach_image(PathBuf::from("/tmp/unused.png")); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(matches!( + result, + InputResult::Queued { text, .. } if text == "Hello foo" + )); + assert!( + composer + .take_recent_submission_images_with_placeholders() + .is_empty() + ); + } + + #[test] + fn selecting_custom_prompt_with_positional_args_submits_numeric_expansion() { + let prompt_text = "Header: $1\nArgs: $ARGUMENTS\n"; + + let prompt = CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }; + + let action = prompt_selection_action( + &prompt, + "/prompts:my-prompt foo bar", + PromptSelectionMode::Submit, + &[], + ); + match action { + PromptSelectionAction::Submit { + text, + text_elements, + } => { + assert_eq!(text, "Header: foo\nArgs: foo bar\n"); + assert!(text_elements.is_empty()); + } + _ => panic!("expected Submit action"), + } + } + #[test] fn numeric_prompt_positional_args_does_not_error() { // Ensure that a prompt with only numeric placeholders does not trigger diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 3057544197..f249e20f0e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -28,6 +28,7 @@ use bottom_pane_view::BottomPaneView; use codex_core::features::Features; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; +use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::user_input::TextElement; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -37,8 +38,10 @@ use ratatui::text::Line; use std::time::Duration; mod approval_overlay; +mod request_user_input; pub(crate) use approval_overlay::ApprovalOverlay; pub(crate) use approval_overlay::ApprovalRequest; +pub(crate) use request_user_input::RequestUserInputOverlay; mod bottom_pane_view; #[derive(Clone, Debug, PartialEq, Eq)] @@ -612,8 +615,32 @@ impl BottomPane { self.push_view(Box::new(modal)); } + /// Called when the agent requests user input. + pub fn push_user_input_request(&mut self, request: RequestUserInputEvent) { + let request = if let Some(view) = self.view_stack.last_mut() { + match view.try_consume_user_input_request(request) { + Some(request) => request, + None => { + self.request_redraw(); + return; + } + } + } else { + request + }; + + let modal = RequestUserInputOverlay::new(request, self.app_event_tx.clone()); + self.pause_status_timer_for_modal(); + self.set_composer_input_enabled( + false, + Some("Answer the questions to continue.".to_string()), + ); + self.push_view(Box::new(modal)); + } + fn on_active_view_complete(&mut self) { self.resume_status_timer_after_modal(); + self.set_composer_input_enabled(true, None); } fn pause_status_timer_for_modal(&mut self) { diff --git a/codex-rs/tui/src/bottom_pane/prompt_args.rs b/codex-rs/tui/src/bottom_pane/prompt_args.rs index 48c3cedfab..efe0a00713 100644 --- a/codex-rs/tui/src/bottom_pane/prompt_args.rs +++ b/codex-rs/tui/src/bottom_pane/prompt_args.rs @@ -1,5 +1,7 @@ use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement; use lazy_static::lazy_static; use regex_lite::Regex; use shlex::Shlex; @@ -57,28 +59,49 @@ impl PromptExpansionError { } /// Parse a first-line slash command of the form `/name `. -/// Returns `(name, rest_after_name)` if the line begins with `/` and contains -/// a non-empty name; otherwise returns `None`. -pub fn parse_slash_name(line: &str) -> Option<(&str, &str)> { +/// Returns `(name, rest_after_name, rest_offset)` if the line begins with `/` +/// and contains a non-empty name; otherwise returns `None`. +/// +/// `rest_offset` is the byte index into the original line where `rest_after_name` +/// starts after trimming leading whitespace (so `line[rest_offset..] == rest_after_name`). +pub fn parse_slash_name(line: &str) -> Option<(&str, &str, usize)> { let stripped = line.strip_prefix('/')?; - let mut name_end = stripped.len(); + let mut name_end_in_stripped = stripped.len(); for (idx, ch) in stripped.char_indices() { if ch.is_whitespace() { - name_end = idx; + name_end_in_stripped = idx; break; } } - let name = &stripped[..name_end]; + let name = &stripped[..name_end_in_stripped]; if name.is_empty() { return None; } - let rest = stripped[name_end..].trim_start(); - Some((name, rest)) + let rest_untrimmed = &stripped[name_end_in_stripped..]; + let rest = rest_untrimmed.trim_start(); + let rest_start_in_stripped = name_end_in_stripped + (rest_untrimmed.len() - rest.len()); + // `stripped` is `line` without the leading '/', so add 1 to get the original offset. + let rest_offset = rest_start_in_stripped + 1; + Some((name, rest, rest_offset)) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PromptArg { + pub text: String, + pub text_elements: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PromptExpansion { + pub text: String, + pub text_elements: Vec, } /// Parse positional arguments using shlex semantics (supports quoted tokens). -pub fn parse_positional_args(rest: &str) -> Vec { - Shlex::new(rest).collect() +/// +/// `text_elements` must be relative to `rest`. +pub fn parse_positional_args(rest: &str, text_elements: &[TextElement]) -> Vec { + parse_tokens_with_elements(rest, text_elements) } /// Extracts the unique placeholder variable names from a prompt template. @@ -106,25 +129,56 @@ pub fn prompt_argument_names(content: &str) -> Vec { names } +/// Shift a text element's byte range left by `offset`, returning `None` if empty. +/// +/// `offset` is the byte length of the prefix removed from the original text. +fn shift_text_element_left(elem: &TextElement, offset: usize) -> Option { + if elem.byte_range.end <= offset { + return None; + } + let start = elem.byte_range.start.saturating_sub(offset); + let end = elem.byte_range.end.saturating_sub(offset); + (start < end).then_some(elem.map_range(|_| ByteRange { start, end })) +} + /// Parses the `key=value` pairs that follow a custom prompt name. /// /// The input is split using shlex rules, so quoted values are supported /// (for example `USER="Alice Smith"`). The function returns a map of parsed /// arguments, or an error if a token is missing `=` or if the key is empty. -pub fn parse_prompt_inputs(rest: &str) -> Result, PromptArgsError> { +pub fn parse_prompt_inputs( + rest: &str, + text_elements: &[TextElement], +) -> Result, PromptArgsError> { let mut map = HashMap::new(); if rest.trim().is_empty() { return Ok(map); } - for token in Shlex::new(rest) { - let Some((key, value)) = token.split_once('=') else { - return Err(PromptArgsError::MissingAssignment { token }); + // Tokenize the rest of the command using shlex rules, but keep text element + // ranges relative to each emitted token. + for token in parse_tokens_with_elements(rest, text_elements) { + let Some((key, value)) = token.text.split_once('=') else { + return Err(PromptArgsError::MissingAssignment { token: token.text }); }; if key.is_empty() { - return Err(PromptArgsError::MissingKey { token }); + return Err(PromptArgsError::MissingKey { token: token.text }); } - map.insert(key.to_string(), value.to_string()); + // The token is `key=value`; translate element ranges into the value-only + // coordinate space by subtracting the `key=` prefix length. + let value_start = key.len() + 1; + let value_elements = token + .text_elements + .iter() + .filter_map(|elem| shift_text_element_left(elem, value_start)) + .collect(); + map.insert( + key.to_string(), + PromptArg { + text: value.to_string(), + text_elements: value_elements, + }, + ); } Ok(map) } @@ -136,9 +190,10 @@ pub fn parse_prompt_inputs(rest: &str) -> Result, Prompt /// `Ok(Some(expanded))`; otherwise it returns a descriptive error. pub fn expand_custom_prompt( text: &str, + text_elements: &[TextElement], custom_prompts: &[CustomPrompt], -) -> Result, PromptExpansionError> { - let Some((name, rest)) = parse_slash_name(text) else { +) -> Result, PromptExpansionError> { + let Some((name, rest, rest_offset)) = parse_slash_name(text) else { return Ok(None); }; @@ -153,10 +208,24 @@ pub fn expand_custom_prompt( }; // If there are named placeholders, expect key=value inputs. let required = prompt_argument_names(&prompt.content); + let local_elements: Vec = text_elements + .iter() + .filter_map(|elem| { + let mut shifted = shift_text_element_left(elem, rest_offset)?; + if shifted.byte_range.start >= rest.len() { + return None; + } + let end = shifted.byte_range.end.min(rest.len()); + shifted.byte_range.end = end; + (shifted.byte_range.start < shifted.byte_range.end).then_some(shifted) + }) + .collect(); if !required.is_empty() { - let inputs = parse_prompt_inputs(rest).map_err(|error| PromptExpansionError::Args { - command: format!("/{name}"), - error, + let inputs = parse_prompt_inputs(rest, &local_elements).map_err(|error| { + PromptExpansionError::Args { + command: format!("/{name}"), + error, + } })?; let missing: Vec = required .into_iter() @@ -168,28 +237,19 @@ pub fn expand_custom_prompt( missing, }); } - let content = &prompt.content; - let replaced = PROMPT_ARG_REGEX.replace_all(content, |caps: ®ex_lite::Captures<'_>| { - if let Some(matched) = caps.get(0) - && matched.start() > 0 - && content.as_bytes()[matched.start() - 1] == b'$' - { - return matched.as_str().to_string(); - } - let whole = &caps[0]; - let key = &whole[1..]; - inputs - .get(key) - .cloned() - .unwrap_or_else(|| whole.to_string()) - }); - return Ok(Some(replaced.into_owned())); + let (text, elements) = expand_named_placeholders_with_elements(&prompt.content, &inputs); + return Ok(Some(PromptExpansion { + text, + text_elements: elements, + })); } // Otherwise, treat it as numeric/positional placeholder prompt (or none). - let pos_args: Vec = Shlex::new(rest).collect(); - let expanded = expand_numeric_placeholders(&prompt.content, &pos_args); - Ok(Some(expanded)) + let pos_args = parse_positional_args(rest, &local_elements); + Ok(Some(expand_numeric_placeholders( + &prompt.content, + &pos_args, + ))) } /// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`. @@ -213,25 +273,42 @@ pub fn prompt_has_numeric_placeholders(content: &str) -> bool { /// Extract positional arguments from a composer first line like "/name a b" for a given prompt name. /// Returns empty when the command name does not match or when there are no args. -pub fn extract_positional_args_for_prompt_line(line: &str, prompt_name: &str) -> Vec { +pub fn extract_positional_args_for_prompt_line( + line: &str, + prompt_name: &str, + text_elements: &[TextElement], +) -> Vec { let trimmed = line.trim_start(); - let Some(rest) = trimmed.strip_prefix('/') else { + let trim_offset = line.len() - trimmed.len(); + let Some((name, rest, rest_offset)) = parse_slash_name(trimmed) else { return Vec::new(); }; // Require the explicit prompts prefix for custom prompt invocations. - let Some(after_prefix) = rest.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { + let Some(after_prefix) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { return Vec::new(); }; - let mut parts = after_prefix.splitn(2, char::is_whitespace); - let cmd = parts.next().unwrap_or(""); - if cmd != prompt_name { + if after_prefix != prompt_name { return Vec::new(); } - let args_str = parts.next().unwrap_or("").trim(); + let rest_trimmed_start = rest.trim_start(); + let args_str = rest_trimmed_start.trim_end(); if args_str.is_empty() { return Vec::new(); } - parse_positional_args(args_str) + let args_offset = trim_offset + rest_offset + (rest.len() - rest_trimmed_start.len()); + let local_elements: Vec = text_elements + .iter() + .filter_map(|elem| { + let mut shifted = shift_text_element_left(elem, args_offset)?; + if shifted.byte_range.start >= args_str.len() { + return None; + } + let end = shifted.byte_range.end.min(args_str.len()); + shifted.byte_range.end = end; + (shifted.byte_range.start < shifted.byte_range.end).then_some(shifted) + }) + .collect(); + parse_positional_args(args_str, &local_elements) } /// If the prompt only uses numeric placeholders and the first line contains @@ -239,14 +316,15 @@ pub fn extract_positional_args_for_prompt_line(line: &str, prompt_name: &str) -> pub fn expand_if_numeric_with_positional_args( prompt: &CustomPrompt, first_line: &str, -) -> Option { + text_elements: &[TextElement], +) -> Option { if !prompt_argument_names(&prompt.content).is_empty() { return None; } if !prompt_has_numeric_placeholders(&prompt.content) { return None; } - let args = extract_positional_args_for_prompt_line(first_line, &prompt.name); + let args = extract_positional_args_for_prompt_line(first_line, &prompt.name, text_elements); if args.is_empty() { return None; } @@ -254,10 +332,10 @@ pub fn expand_if_numeric_with_positional_args( } /// Expand `$1..$9` and `$ARGUMENTS` in `content` with values from `args`. -pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { +pub fn expand_numeric_placeholders(content: &str, args: &[PromptArg]) -> PromptExpansion { let mut out = String::with_capacity(content.len()); + let mut out_elements = Vec::new(); let mut i = 0; - let mut cached_joined_args: Option = None; while let Some(off) = content[i..].find('$') { let j = i + off; out.push_str(&content[i..j]); @@ -272,8 +350,8 @@ pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { } b'1'..=b'9' => { let idx = (bytes[1] - b'1') as usize; - if let Some(val) = args.get(idx) { - out.push_str(val); + if let Some(arg) = args.get(idx) { + append_arg_with_elements(&mut out, &mut out_elements, arg); } i = j + 2; continue; @@ -283,8 +361,7 @@ pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { } if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") { if !args.is_empty() { - let joined = cached_joined_args.get_or_insert_with(|| args.join(" ")); - out.push_str(joined); + append_joined_args_with_elements(&mut out, &mut out_elements, args); } i = j + 1 + "ARGUMENTS".len(); continue; @@ -293,7 +370,179 @@ pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { i = j + 1; } out.push_str(&content[i..]); - out + PromptExpansion { + text: out, + text_elements: out_elements, + } +} + +fn parse_tokens_with_elements(rest: &str, text_elements: &[TextElement]) -> Vec { + let mut elements = text_elements.to_vec(); + elements.sort_by_key(|elem| elem.byte_range.start); + // Keep element placeholders intact across shlex splitting by replacing + // each element range with a unique sentinel token first. + let (rest_for_shlex, replacements) = replace_text_elements_with_sentinels(rest, &elements); + Shlex::new(&rest_for_shlex) + .map(|token| apply_replacements_to_token(token, &replacements)) + .collect() +} + +#[derive(Debug, Clone)] +struct ElementReplacement { + sentinel: String, + text: String, + placeholder: Option, +} + +/// Replace each text element range with a unique sentinel token. +/// +/// The sentinel is chosen so it will survive shlex tokenization as a single word. +fn replace_text_elements_with_sentinels( + rest: &str, + elements: &[TextElement], +) -> (String, Vec) { + let mut out = String::with_capacity(rest.len()); + let mut replacements = Vec::new(); + let mut cursor = 0; + + for (idx, elem) in elements.iter().enumerate() { + let start = elem.byte_range.start; + let end = elem.byte_range.end; + out.push_str(&rest[cursor..start]); + let mut sentinel = format!("__CODEX_ELEM_{idx}__"); + // Ensure we never collide with user content so a sentinel can't be mistaken for text. + while rest.contains(&sentinel) { + sentinel.push('_'); + } + out.push_str(&sentinel); + replacements.push(ElementReplacement { + sentinel, + text: rest[start..end].to_string(), + placeholder: elem.placeholder(rest).map(str::to_string), + }); + cursor = end; + } + + out.push_str(&rest[cursor..]); + (out, replacements) +} + +/// Rehydrate a shlex token by swapping sentinels back to the original text +/// and rebuilding text element ranges relative to the resulting token. +fn apply_replacements_to_token(token: String, replacements: &[ElementReplacement]) -> PromptArg { + if replacements.is_empty() { + return PromptArg { + text: token, + text_elements: Vec::new(), + }; + } + + let mut out = String::with_capacity(token.len()); + let mut out_elements = Vec::new(); + let mut cursor = 0; + + while cursor < token.len() { + let Some((offset, replacement)) = next_replacement(&token, cursor, replacements) else { + out.push_str(&token[cursor..]); + break; + }; + let start_in_token = cursor + offset; + out.push_str(&token[cursor..start_in_token]); + let start = out.len(); + out.push_str(&replacement.text); + let end = out.len(); + if start < end { + out_elements.push(TextElement::new( + ByteRange { start, end }, + replacement.placeholder.clone(), + )); + } + cursor = start_in_token + replacement.sentinel.len(); + } + + PromptArg { + text: out, + text_elements: out_elements, + } +} + +/// Find the earliest sentinel occurrence at or after `cursor`. +fn next_replacement<'a>( + token: &str, + cursor: usize, + replacements: &'a [ElementReplacement], +) -> Option<(usize, &'a ElementReplacement)> { + let slice = &token[cursor..]; + let mut best: Option<(usize, &'a ElementReplacement)> = None; + for replacement in replacements { + if let Some(pos) = slice.find(&replacement.sentinel) { + match best { + Some((best_pos, _)) if best_pos <= pos => {} + _ => best = Some((pos, replacement)), + } + } + } + best +} + +fn expand_named_placeholders_with_elements( + content: &str, + args: &HashMap, +) -> (String, Vec) { + let mut out = String::with_capacity(content.len()); + let mut out_elements = Vec::new(); + let mut cursor = 0; + for m in PROMPT_ARG_REGEX.find_iter(content) { + let start = m.start(); + let end = m.end(); + if start > 0 && content.as_bytes()[start - 1] == b'$' { + out.push_str(&content[cursor..end]); + cursor = end; + continue; + } + out.push_str(&content[cursor..start]); + cursor = end; + let key = &content[start + 1..end]; + if let Some(arg) = args.get(key) { + append_arg_with_elements(&mut out, &mut out_elements, arg); + } else { + out.push_str(&content[start..end]); + } + } + out.push_str(&content[cursor..]); + (out, out_elements) +} + +fn append_arg_with_elements( + out: &mut String, + out_elements: &mut Vec, + arg: &PromptArg, +) { + let start = out.len(); + out.push_str(&arg.text); + if arg.text_elements.is_empty() { + return; + } + out_elements.extend(arg.text_elements.iter().map(|elem| { + elem.map_range(|range| ByteRange { + start: start + range.start, + end: start + range.end, + }) + })); +} + +fn append_joined_args_with_elements( + out: &mut String, + out_elements: &mut Vec, + args: &[PromptArg], +) { + // `$ARGUMENTS` joins args with single spaces while preserving element ranges. + for (idx, arg) in args.iter().enumerate() { + if idx > 0 { + out.push(' '); + } + append_arg_with_elements(out, out_elements, arg); + } } /// Constructs a command text for a custom prompt with arguments. @@ -313,6 +562,7 @@ pub fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (Str #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn expand_arguments_basic() { @@ -324,9 +574,15 @@ mod tests { argument_hint: None, }]; - let out = - expand_custom_prompt("/prompts:my-prompt USER=Alice BRANCH=main", &prompts).unwrap(); - assert_eq!(out, Some("Review Alice changes on main".to_string())); + let out = expand_custom_prompt("/prompts:my-prompt USER=Alice BRANCH=main", &[], &prompts) + .unwrap(); + assert_eq!( + out, + Some(PromptExpansion { + text: "Review Alice changes on main".to_string(), + text_elements: Vec::new(), + }) + ); } #[test] @@ -341,10 +597,17 @@ mod tests { let out = expand_custom_prompt( "/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main", + &[], &prompts, ) .unwrap(); - assert_eq!(out, Some("Pair Alice Smith with dev-main".to_string())); + assert_eq!( + out, + Some(PromptExpansion { + text: "Pair Alice Smith with dev-main".to_string(), + text_elements: Vec::new(), + }) + ); } #[test] @@ -356,7 +619,7 @@ mod tests { description: None, argument_hint: None, }]; - let err = expand_custom_prompt("/prompts:my-prompt USER=Alice stray", &prompts) + let err = expand_custom_prompt("/prompts:my-prompt USER=Alice stray", &[], &prompts) .unwrap_err() .user_message(); assert!(err.contains("expected key=value")); @@ -371,7 +634,7 @@ mod tests { description: None, argument_hint: None, }]; - let err = expand_custom_prompt("/prompts:my-prompt USER=Alice", &prompts) + let err = expand_custom_prompt("/prompts:my-prompt USER=Alice", &[], &prompts) .unwrap_err() .user_message(); assert!(err.to_lowercase().contains("missing required args")); @@ -400,7 +663,192 @@ mod tests { argument_hint: None, }]; - let out = expand_custom_prompt("/prompts:my-prompt", &prompts).unwrap(); - assert_eq!(out, Some("literal $$USER".to_string())); + let out = expand_custom_prompt("/prompts:my-prompt", &[], &prompts).unwrap(); + assert_eq!( + out, + Some(PromptExpansion { + text: "literal $$USER".to_string(), + text_elements: Vec::new(), + }) + ); + } + + #[test] + fn positional_args_treat_placeholder_with_spaces_as_single_token() { + let placeholder = "[Image #1]"; + let rest = format!("alpha {placeholder} beta"); + let start = rest.find(placeholder).expect("placeholder"); + let end = start + placeholder.len(); + let text_elements = vec![TextElement::new( + ByteRange { start, end }, + Some(placeholder.to_string()), + )]; + + let args = parse_positional_args(&rest, &text_elements); + assert_eq!( + args, + vec![ + PromptArg { + text: "alpha".to_string(), + text_elements: Vec::new(), + }, + PromptArg { + text: placeholder.to_string(), + text_elements: vec![TextElement::new( + ByteRange { + start: 0, + end: placeholder.len(), + }, + Some(placeholder.to_string()), + )], + }, + PromptArg { + text: "beta".to_string(), + text_elements: Vec::new(), + } + ] + ); + } + + #[test] + fn extract_positional_args_shifts_element_offsets_into_args_str() { + let placeholder = "[Image #1]"; + let line = format!(" /{PROMPTS_CMD_PREFIX}:my-prompt alpha {placeholder} beta "); + let start = line.find(placeholder).expect("placeholder"); + let end = start + placeholder.len(); + let text_elements = vec![TextElement::new( + ByteRange { start, end }, + Some(placeholder.to_string()), + )]; + + let args = extract_positional_args_for_prompt_line(&line, "my-prompt", &text_elements); + assert_eq!( + args, + vec![ + PromptArg { + text: "alpha".to_string(), + text_elements: Vec::new(), + }, + PromptArg { + text: placeholder.to_string(), + text_elements: vec![TextElement::new( + ByteRange { + start: 0, + end: placeholder.len(), + }, + Some(placeholder.to_string()), + )], + }, + PromptArg { + text: "beta".to_string(), + text_elements: Vec::new(), + } + ] + ); + } + + #[test] + fn key_value_args_treat_placeholder_with_spaces_as_single_token() { + let placeholder = "[Image #1]"; + let rest = format!("IMG={placeholder} NOTE=hello"); + let start = rest.find(placeholder).expect("placeholder"); + let end = start + placeholder.len(); + let text_elements = vec![TextElement::new( + ByteRange { start, end }, + Some(placeholder.to_string()), + )]; + + let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs"); + assert_eq!( + args.get("IMG"), + Some(&PromptArg { + text: placeholder.to_string(), + text_elements: vec![TextElement::new( + ByteRange { + start: 0, + end: placeholder.len(), + }, + Some(placeholder.to_string()), + )], + }) + ); + assert_eq!( + args.get("NOTE"), + Some(&PromptArg { + text: "hello".to_string(), + text_elements: Vec::new(), + }) + ); + } + + #[test] + fn positional_args_allow_placeholder_inside_quotes() { + let placeholder = "[Image #1]"; + let rest = format!("alpha \"see {placeholder} here\" beta"); + let start = rest.find(placeholder).expect("placeholder"); + let end = start + placeholder.len(); + let text_elements = vec![TextElement::new( + ByteRange { start, end }, + Some(placeholder.to_string()), + )]; + + let args = parse_positional_args(&rest, &text_elements); + assert_eq!( + args, + vec![ + PromptArg { + text: "alpha".to_string(), + text_elements: Vec::new(), + }, + PromptArg { + text: format!("see {placeholder} here"), + text_elements: vec![TextElement::new( + ByteRange { + start: "see ".len(), + end: "see ".len() + placeholder.len(), + }, + Some(placeholder.to_string()), + )], + }, + PromptArg { + text: "beta".to_string(), + text_elements: Vec::new(), + } + ] + ); + } + + #[test] + fn key_value_args_allow_placeholder_inside_quotes() { + let placeholder = "[Image #1]"; + let rest = format!("IMG=\"see {placeholder} here\" NOTE=ok"); + let start = rest.find(placeholder).expect("placeholder"); + let end = start + placeholder.len(); + let text_elements = vec![TextElement::new( + ByteRange { start, end }, + Some(placeholder.to_string()), + )]; + + let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs"); + assert_eq!( + args.get("IMG"), + Some(&PromptArg { + text: format!("see {placeholder} here"), + text_elements: vec![TextElement::new( + ByteRange { + start: "see ".len(), + end: "see ".len() + placeholder.len(), + }, + Some(placeholder.to_string()), + )], + }) + ); + assert_eq!( + args.get("NOTE"), + Some(&PromptArg { + text: "ok".to_string(), + text_elements: Vec::new(), + }) + ); } } diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs new file mode 100644 index 0000000000..fd5066c994 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs @@ -0,0 +1,151 @@ +use ratatui::layout::Rect; + +use super::RequestUserInputOverlay; + +pub(super) struct LayoutSections { + pub(super) progress_area: Rect, + pub(super) header_area: Rect, + pub(super) question_area: Rect, + pub(super) answer_title_area: Rect, + // Wrapped question text lines to render in the question area. + pub(super) question_lines: Vec, + pub(super) options_area: Rect, + pub(super) notes_title_area: Rect, + pub(super) notes_area: Rect, + // Number of footer rows (status + hints). + pub(super) footer_lines: u16, +} + +impl RequestUserInputOverlay { + /// Compute layout sections, collapsing notes and hints as space shrinks. + pub(super) fn layout_sections(&self, area: Rect) -> LayoutSections { + let question_lines = self + .current_question() + .map(|q| { + textwrap::wrap(&q.question, area.width.max(1) as usize) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + }) + .unwrap_or_default(); + let question_text_height = question_lines.len() as u16; + let has_options = self.has_options(); + let mut notes_input_height = self.notes_input_height(area.width); + // Keep the question + options visible first; notes and hints collapse as space shrinks. + let footer_lines = if self.unanswered_count() > 0 { 2 } else { 1 }; + let mut notes_title_height = if has_options { 1 } else { 0 }; + + let mut cursor_y = area.y; + let progress_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: 1, + }; + cursor_y = cursor_y.saturating_add(1); + let header_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: 1, + }; + cursor_y = cursor_y.saturating_add(1); + let question_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: question_text_height, + }; + cursor_y = cursor_y.saturating_add(question_text_height); + // Remaining height after progress/header/question areas. + let remaining = area.height.saturating_sub(cursor_y.saturating_sub(area.y)); + let mut answer_title_height = if has_options { 1 } else { 0 }; + let mut options_height = 0; + if has_options { + let remaining_content = remaining.saturating_sub(footer_lines); + let options_len = self.options_len() as u16; + if remaining_content == 0 { + answer_title_height = 0; + notes_title_height = 0; + notes_input_height = 0; + options_height = 0; + } else { + let min_notes = 1u16; + let full_notes = 3u16; + // Prefer to keep all options visible, then allocate notes height. + if remaining_content + >= options_len + answer_title_height + notes_title_height + full_notes + { + let max_notes = remaining_content + .saturating_sub(options_len) + .saturating_sub(answer_title_height) + .saturating_sub(notes_title_height); + notes_input_height = notes_input_height.min(max_notes).max(full_notes); + } else if remaining_content > options_len + answer_title_height + min_notes { + notes_title_height = 0; + notes_input_height = min_notes; + } else { + // Tight layout: hide section titles and shrink notes to one line. + answer_title_height = 0; + notes_title_height = 0; + notes_input_height = min_notes; + } + + // Reserve notes/answer title area so options are scrollable if needed. + let reserved = answer_title_height + .saturating_add(notes_title_height) + .saturating_add(notes_input_height); + options_height = remaining_content.saturating_sub(reserved); + } + } else { + let max_notes = remaining.saturating_sub(footer_lines); + if max_notes == 0 { + notes_input_height = 0; + } else { + // When no options exist, notes are the primary input. + notes_input_height = notes_input_height.min(max_notes).max(3.min(max_notes)); + } + } + + let answer_title_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: answer_title_height, + }; + cursor_y = cursor_y.saturating_add(answer_title_height); + let options_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: options_height, + }; + cursor_y = cursor_y.saturating_add(options_height); + + let notes_title_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: notes_title_height, + }; + cursor_y = cursor_y.saturating_add(notes_title_height); + let notes_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: notes_input_height, + }; + + LayoutSections { + progress_area, + header_area, + question_area, + answer_title_area, + question_lines, + options_area, + notes_title_area, + notes_area, + footer_lines, + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs new file mode 100644 index 0000000000..60d91dde5e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -0,0 +1,770 @@ +//! Request-user-input overlay state machine. +//! +//! Core behaviors: +//! - Each question can be answered by selecting one option and/or providing notes. +//! - When options exist, notes are stored per selected option (notes become "other"). +//! - Typing while focused on options jumps into notes to keep freeform input fast. +//! - Enter advances to the next question; the last question submits all answers. +//! - Freeform-only questions submit "skipped" when empty. +use std::cell::RefCell; +use std::collections::HashMap; +use std::collections::VecDeque; + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +mod layout; +mod render; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::bottom_pane_view::BottomPaneView; +use crate::bottom_pane::scroll_state::ScrollState; +use crate::bottom_pane::textarea::TextArea; +use crate::bottom_pane::textarea::TextAreaState; + +use codex_core::protocol::Op; +use codex_protocol::request_user_input::RequestUserInputAnswer; +use codex_protocol::request_user_input::RequestUserInputEvent; +use codex_protocol::request_user_input::RequestUserInputResponse; + +const NOTES_PLACEHOLDER: &str = "Add notes (optional)"; +const ANSWER_PLACEHOLDER: &str = "Type your answer (optional)"; +const SELECT_OPTION_PLACEHOLDER: &str = "Select an option to add notes (optional)"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Focus { + Options, + Notes, +} + +struct NotesEntry { + text: TextArea, + state: RefCell, +} + +impl NotesEntry { + fn new() -> Self { + Self { + text: TextArea::new(), + state: RefCell::new(TextAreaState::default()), + } + } +} + +struct AnswerState { + // Final selection for the question (always set for option questions). + selected: Option, + // Scrollable cursor state for option navigation/highlight. + option_state: ScrollState, + // Notes for freeform-only questions. + notes: NotesEntry, + // Per-option notes for option questions. + option_notes: Vec, +} + +pub(crate) struct RequestUserInputOverlay { + app_event_tx: AppEventSender, + request: RequestUserInputEvent, + // Queue of incoming requests to process after the current one. + queue: VecDeque, + answers: Vec, + current_idx: usize, + focus: Focus, + done: bool, +} + +impl RequestUserInputOverlay { + pub(crate) fn new(request: RequestUserInputEvent, app_event_tx: AppEventSender) -> Self { + let mut overlay = Self { + app_event_tx, + request, + queue: VecDeque::new(), + answers: Vec::new(), + current_idx: 0, + focus: Focus::Options, + done: false, + }; + overlay.reset_for_request(); + overlay.ensure_focus_available(); + overlay + } + + fn current_index(&self) -> usize { + self.current_idx + } + + fn current_question( + &self, + ) -> Option<&codex_protocol::request_user_input::RequestUserInputQuestion> { + self.request.questions.get(self.current_index()) + } + + fn current_answer_mut(&mut self) -> Option<&mut AnswerState> { + let idx = self.current_index(); + self.answers.get_mut(idx) + } + + fn current_answer(&self) -> Option<&AnswerState> { + let idx = self.current_index(); + self.answers.get(idx) + } + + fn question_count(&self) -> usize { + self.request.questions.len() + } + + fn has_options(&self) -> bool { + self.current_question() + .and_then(|question| question.options.as_ref()) + .is_some_and(|options| !options.is_empty()) + } + + fn options_len(&self) -> usize { + self.current_question() + .and_then(|question| question.options.as_ref()) + .map(std::vec::Vec::len) + .unwrap_or(0) + } + + fn selected_option_index(&self) -> Option { + if !self.has_options() { + return None; + } + self.current_answer() + .and_then(|answer| answer.selected.or(answer.option_state.selected_idx)) + } + + fn current_option_label(&self) -> Option<&str> { + let idx = self.selected_option_index()?; + self.current_question() + .and_then(|question| question.options.as_ref()) + .and_then(|options| options.get(idx)) + .map(|option| option.label.as_str()) + } + + fn current_notes_entry(&self) -> Option<&NotesEntry> { + let answer = self.current_answer()?; + if !self.has_options() { + return Some(&answer.notes); + } + let idx = self + .selected_option_index() + .or(answer.option_state.selected_idx)?; + answer.option_notes.get(idx) + } + + fn current_notes_entry_mut(&mut self) -> Option<&mut NotesEntry> { + let has_options = self.has_options(); + let answer = self.current_answer_mut()?; + if !has_options { + return Some(&mut answer.notes); + } + let idx = answer + .selected + .or(answer.option_state.selected_idx) + .or_else(|| answer.option_notes.is_empty().then_some(0))?; + answer.option_notes.get_mut(idx) + } + + fn notes_placeholder(&self) -> &'static str { + if self.has_options() + && self + .current_answer() + .is_some_and(|answer| answer.selected.is_none()) + { + SELECT_OPTION_PLACEHOLDER + } else if self.has_options() { + NOTES_PLACEHOLDER + } else { + ANSWER_PLACEHOLDER + } + } + + /// Ensure the focus mode is valid for the current question. + fn ensure_focus_available(&mut self) { + if self.question_count() == 0 { + return; + } + if !self.has_options() { + self.focus = Focus::Notes; + } + } + + /// Rebuild local answer state from the current request. + fn reset_for_request(&mut self) { + self.answers = self + .request + .questions + .iter() + .map(|question| { + let mut option_state = ScrollState::new(); + let mut option_notes = Vec::new(); + if let Some(options) = question.options.as_ref() + && !options.is_empty() + { + option_state.selected_idx = Some(0); + option_notes = (0..options.len()).map(|_| NotesEntry::new()).collect(); + } + AnswerState { + selected: option_state.selected_idx, + option_state, + notes: NotesEntry::new(), + option_notes, + } + }) + .collect(); + + self.current_idx = 0; + self.focus = Focus::Options; + } + + /// Move to the next/previous question, wrapping in either direction. + fn move_question(&mut self, next: bool) { + let len = self.question_count(); + if len == 0 { + return; + } + let offset = if next { 1 } else { len.saturating_sub(1) }; + self.current_idx = (self.current_idx + offset) % len; + self.ensure_focus_available(); + } + + /// Synchronize selection state to the currently focused option. + fn select_current_option(&mut self) { + if !self.has_options() { + return; + } + let options_len = self.options_len(); + let Some(answer) = self.current_answer_mut() else { + return; + }; + answer.option_state.clamp_selection(options_len); + answer.selected = answer.option_state.selected_idx; + } + + /// Ensure there is a selection before allowing notes entry. + fn ensure_selected_for_notes(&mut self) { + if self.has_options() + && self + .current_answer() + .is_some_and(|answer| answer.selected.is_none()) + { + self.select_current_option(); + } + } + + /// Advance to next question, or submit when on the last one. + fn go_next_or_submit(&mut self) { + if self.current_index() + 1 >= self.question_count() { + self.submit_answers(); + } else { + self.move_question(true); + } + } + + /// Build the response payload and dispatch it to the app. + fn submit_answers(&mut self) { + let mut answers = HashMap::new(); + for (idx, question) in self.request.questions.iter().enumerate() { + let answer_state = &self.answers[idx]; + let options = question.options.as_ref(); + // For option questions we always produce a selection. + let selected_idx = if options.is_some_and(|opts| !opts.is_empty()) { + answer_state + .selected + .or(answer_state.option_state.selected_idx) + } else { + answer_state.selected + }; + // Notes map to "other". When options exist, notes are per selected option. + let notes = if options.is_some_and(|opts| !opts.is_empty()) { + selected_idx + .and_then(|selected| answer_state.option_notes.get(selected)) + .map(|entry| entry.text.text().trim().to_string()) + .unwrap_or_default() + } else { + answer_state.notes.text.text().trim().to_string() + }; + let selected_label = selected_idx.and_then(|selected_idx| { + question + .options + .as_ref() + .and_then(|opts| opts.get(selected_idx)) + .map(|opt| opt.label.clone()) + }); + let selected = selected_label.into_iter().collect::>(); + // For option questions, only send notes when present. + let other = if notes.is_empty() && options.is_some_and(|opts| !opts.is_empty()) { + None + } else if notes.is_empty() && selected.is_empty() { + Some("skipped".to_string()) + } else { + Some(notes) + }; + answers.insert( + question.id.clone(), + RequestUserInputAnswer { selected, other }, + ); + } + self.app_event_tx + .send(AppEvent::CodexOp(Op::UserInputAnswer { + id: self.request.turn_id.clone(), + response: RequestUserInputResponse { answers }, + })); + if let Some(next) = self.queue.pop_front() { + self.request = next; + self.reset_for_request(); + self.ensure_focus_available(); + } else { + self.done = true; + } + } + + /// Count freeform-only questions that have no notes. + fn unanswered_count(&self) -> usize { + self.request + .questions + .iter() + .enumerate() + .filter(|(idx, question)| { + let answer = &self.answers[*idx]; + let options = question.options.as_ref(); + if options.is_some_and(|opts| !opts.is_empty()) { + false + } else { + answer.notes.text.text().trim().is_empty() + } + }) + .count() + } + + /// Compute the preferred notes input height for the current question. + fn notes_input_height(&self, width: u16) -> u16 { + let Some(entry) = self.current_notes_entry() else { + return 3; + }; + let usable_width = width.saturating_sub(2); + let text_height = entry.text.desired_height(usable_width).clamp(1, 6); + text_height.saturating_add(2).clamp(3, 8) + } +} + +impl BottomPaneView for RequestUserInputOverlay { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Release { + return; + } + + if matches!(key_event.code, KeyCode::Esc) { + self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); + self.done = true; + return; + } + + // Question navigation is always available. + match key_event.code { + KeyCode::PageUp => { + self.move_question(false); + return; + } + KeyCode::PageDown => { + self.move_question(true); + return; + } + _ => {} + } + + match self.focus { + Focus::Options => { + let options_len = self.options_len(); + let Some(answer) = self.current_answer_mut() else { + return; + }; + // Keep selection synchronized as the user moves. + match key_event.code { + KeyCode::Up => { + answer.option_state.move_up_wrap(options_len); + answer.selected = answer.option_state.selected_idx; + } + KeyCode::Down => { + answer.option_state.move_down_wrap(options_len); + answer.selected = answer.option_state.selected_idx; + } + KeyCode::Char(' ') => { + self.select_current_option(); + } + KeyCode::Enter => { + self.select_current_option(); + self.go_next_or_submit(); + } + KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete => { + // Any typing while in options switches to notes for fast freeform input. + self.focus = Focus::Notes; + self.ensure_selected_for_notes(); + if let Some(entry) = self.current_notes_entry_mut() { + entry.text.input(key_event); + } + } + _ => {} + } + } + Focus::Notes => { + if matches!(key_event.code, KeyCode::Enter) { + self.go_next_or_submit(); + return; + } + if self.has_options() && matches!(key_event.code, KeyCode::Up | KeyCode::Down) { + let options_len = self.options_len(); + let Some(answer) = self.current_answer_mut() else { + return; + }; + match key_event.code { + KeyCode::Up => { + answer.option_state.move_up_wrap(options_len); + answer.selected = answer.option_state.selected_idx; + } + KeyCode::Down => { + answer.option_state.move_down_wrap(options_len); + answer.selected = answer.option_state.selected_idx; + } + _ => {} + } + return; + } + // Notes are per option when options exist. + self.ensure_selected_for_notes(); + if let Some(entry) = self.current_notes_entry_mut() { + entry.text.input(key_event); + } + } + } + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); + self.done = true; + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.done + } + + fn handle_paste(&mut self, pasted: String) -> bool { + if pasted.is_empty() { + return false; + } + if matches!(self.focus, Focus::Notes) { + self.ensure_selected_for_notes(); + if let Some(entry) = self.current_notes_entry_mut() { + entry.text.insert_str(&pasted); + return true; + } + return true; + } + if matches!(self.focus, Focus::Options) { + // Treat pastes the same as typing: switch into notes. + self.focus = Focus::Notes; + self.ensure_selected_for_notes(); + if let Some(entry) = self.current_notes_entry_mut() { + entry.text.insert_str(&pasted); + return true; + } + return true; + } + false + } + + fn try_consume_user_input_request( + &mut self, + request: RequestUserInputEvent, + ) -> Option { + self.queue.push_back(request); + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::render::renderable::Renderable; + use codex_protocol::request_user_input::RequestUserInputQuestion; + use codex_protocol::request_user_input::RequestUserInputQuestionOption; + use pretty_assertions::assert_eq; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use tokio::sync::mpsc::unbounded_channel; + + fn test_sender() -> ( + AppEventSender, + tokio::sync::mpsc::UnboundedReceiver, + ) { + let (tx_raw, rx) = unbounded_channel::(); + (AppEventSender::new(tx_raw), rx) + } + + fn question_with_options(id: &str, header: &str) -> RequestUserInputQuestion { + RequestUserInputQuestion { + id: id.to_string(), + header: header.to_string(), + question: "Choose an option.".to_string(), + options: Some(vec![ + RequestUserInputQuestionOption { + label: "Option 1".to_string(), + description: "First choice.".to_string(), + }, + RequestUserInputQuestionOption { + label: "Option 2".to_string(), + description: "Second choice.".to_string(), + }, + RequestUserInputQuestionOption { + label: "Option 3".to_string(), + description: "Third choice.".to_string(), + }, + ]), + } + } + + fn question_without_options(id: &str, header: &str) -> RequestUserInputQuestion { + RequestUserInputQuestion { + id: id.to_string(), + header: header.to_string(), + question: "Share details.".to_string(), + options: None, + } + } + + fn request_event( + turn_id: &str, + questions: Vec, + ) -> RequestUserInputEvent { + RequestUserInputEvent { + call_id: "call-1".to_string(), + turn_id: turn_id.to_string(), + questions, + } + } + + fn snapshot_buffer(buf: &Buffer) -> String { + let mut lines = Vec::new(); + for y in 0..buf.area().height { + let mut row = String::new(); + for x in 0..buf.area().width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + lines.push(row); + } + lines.join("\n") + } + + fn render_snapshot(overlay: &RequestUserInputOverlay, area: Rect) -> String { + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + snapshot_buffer(&buf) + } + + #[test] + fn queued_requests_are_fifo() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "First")]), + tx, + ); + overlay.try_consume_user_input_request(request_event( + "turn-2", + vec![question_with_options("q2", "Second")], + )); + overlay.try_consume_user_input_request(request_event( + "turn-3", + vec![question_with_options("q3", "Third")], + )); + + overlay.submit_answers(); + assert_eq!(overlay.request.turn_id, "turn-2"); + + overlay.submit_answers(); + assert_eq!(overlay.request.turn_id, "turn-3"); + } + + #[test] + fn options_always_return_a_selection() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + ); + + overlay.submit_answers(); + + let event = rx.try_recv().expect("expected AppEvent"); + let AppEvent::CodexOp(Op::UserInputAnswer { id, response }) = event else { + panic!("expected UserInputAnswer"); + }; + assert_eq!(id, "turn-1"); + let answer = response.answers.get("q1").expect("answer missing"); + assert_eq!(answer.selected, vec!["Option 1".to_string()]); + assert_eq!(answer.other, None); + } + + #[test] + fn freeform_questions_submit_skipped_when_empty() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_without_options("q1", "Notes")]), + tx, + ); + + overlay.submit_answers(); + + let event = rx.try_recv().expect("expected AppEvent"); + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + panic!("expected UserInputAnswer"); + }; + let answer = response.answers.get("q1").expect("answer missing"); + assert_eq!(answer.selected, Vec::::new()); + assert_eq!(answer.other, Some("skipped".to_string())); + } + + #[test] + fn notes_are_captured_for_selected_option() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + ); + + { + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.option_state.selected_idx = Some(1); + } + overlay.select_current_option(); + overlay + .current_notes_entry_mut() + .expect("notes entry missing") + .text + .insert_str("Notes for option 2"); + + overlay.submit_answers(); + + let event = rx.try_recv().expect("expected AppEvent"); + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + panic!("expected UserInputAnswer"); + }; + let answer = response.answers.get("q1").expect("answer missing"); + assert_eq!(answer.selected, vec!["Option 2".to_string()]); + assert_eq!(answer.other, Some("Notes for option 2".to_string())); + } + + #[test] + fn request_user_input_options_snapshot() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Area")]), + tx, + ); + let area = Rect::new(0, 0, 64, 16); + insta::assert_snapshot!( + "request_user_input_options", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn request_user_input_tight_height_snapshot() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Area")]), + tx, + ); + let area = Rect::new(0, 0, 60, 8); + insta::assert_snapshot!( + "request_user_input_tight_height", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn request_user_input_scroll_options_snapshot() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![RequestUserInputQuestion { + id: "q1".to_string(), + header: "Next Step".to_string(), + question: "What would you like to do next?".to_string(), + options: Some(vec![ + RequestUserInputQuestionOption { + label: "Discuss a code change (Recommended)".to_string(), + description: "Walk through a plan and edit code together.".to_string(), + }, + RequestUserInputQuestionOption { + label: "Run tests".to_string(), + description: "Pick a crate and run its tests.".to_string(), + }, + RequestUserInputQuestionOption { + label: "Review a diff".to_string(), + description: "Summarize or review current changes.".to_string(), + }, + RequestUserInputQuestionOption { + label: "Refactor".to_string(), + description: "Tighten structure and remove dead code.".to_string(), + }, + RequestUserInputQuestionOption { + label: "Ship it".to_string(), + description: "Finalize and open a PR.".to_string(), + }, + ]), + }], + ), + tx, + ); + { + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.option_state.selected_idx = Some(3); + answer.selected = Some(3); + } + let area = Rect::new(0, 0, 68, 10); + insta::assert_snapshot!( + "request_user_input_scrolling_options", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn request_user_input_freeform_snapshot() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_without_options("q1", "Goal")]), + tx, + ); + let area = Rect::new(0, 0, 64, 10); + insta::assert_snapshot!( + "request_user_input_freeform", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn options_scroll_while_editing_notes() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + ); + overlay.focus = Focus::Notes; + overlay + .current_notes_entry_mut() + .expect("notes entry missing") + .text + .insert_str("Notes"); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Down)); + + let answer = overlay.current_answer().expect("answer missing"); + assert_eq!(answer.selected, Some(1)); + } +} diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs new file mode 100644 index 0000000000..68b7eefdc3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs @@ -0,0 +1,375 @@ +use crossterm::event::KeyCode; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::Widget; + +use crate::bottom_pane::selection_popup_common::GenericDisplayRow; +use crate::bottom_pane::selection_popup_common::render_rows; +use crate::key_hint; +use crate::render::renderable::Renderable; + +use super::RequestUserInputOverlay; + +impl Renderable for RequestUserInputOverlay { + fn desired_height(&self, width: u16) -> u16 { + let sections = self.layout_sections(Rect::new(0, 0, width, u16::MAX)); + let mut height = sections + .question_lines + .len() + .saturating_add(5) + .saturating_add(self.notes_input_height(width) as usize) + .saturating_add(sections.footer_lines as usize); + if self.has_options() { + height = height.saturating_add(2); + } + height = height.max(8); + height as u16 + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + self.render_ui(area, buf); + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.cursor_pos_impl(area) + } +} + +impl RequestUserInputOverlay { + /// Render the full request-user-input overlay. + pub(super) fn render_ui(&self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + let sections = self.layout_sections(area); + + // Progress header keeps the user oriented across multiple questions. + let progress_line = if self.question_count() > 0 { + let idx = self.current_index() + 1; + let total = self.question_count(); + Line::from(format!("Question {idx}/{total}").dim()) + } else { + Line::from("No questions".dim()) + }; + Paragraph::new(progress_line).render(sections.progress_area, buf); + + // Question title and wrapped prompt text. + let question_header = self.current_question().map(|q| q.header.clone()); + let header_line = if let Some(header) = question_header { + Line::from(header.bold()) + } else { + Line::from("No questions".dim()) + }; + Paragraph::new(header_line).render(sections.header_area, buf); + + let question_y = sections.question_area.y; + for (offset, line) in sections.question_lines.iter().enumerate() { + if question_y.saturating_add(offset as u16) + >= sections.question_area.y + sections.question_area.height + { + break; + } + Paragraph::new(Line::from(line.clone())).render( + Rect { + x: sections.question_area.x, + y: question_y.saturating_add(offset as u16), + width: sections.question_area.width, + height: 1, + }, + buf, + ); + } + + if sections.answer_title_area.height > 0 { + let answer_label = "Answer"; + let answer_title = if self.focus_is_options() || self.focus_is_notes_without_options() { + answer_label.cyan().bold() + } else { + answer_label.dim() + }; + Paragraph::new(Line::from(answer_title)).render(sections.answer_title_area, buf); + } + + // Build rows with selection markers for the shared selection renderer. + let option_rows = self + .current_question() + .and_then(|question| question.options.as_ref()) + .map(|options| { + options + .iter() + .enumerate() + .map(|(idx, opt)| { + let selected = self + .current_answer() + .and_then(|answer| answer.selected) + .is_some_and(|sel| sel == idx); + let prefix = if selected { "(x)" } else { "( )" }; + GenericDisplayRow { + name: format!("{prefix} {}", opt.label), + description: Some(opt.description.clone()), + ..Default::default() + } + }) + .collect::>() + }) + .unwrap_or_default(); + + if self.has_options() { + let mut option_state = self + .current_answer() + .map(|answer| answer.option_state) + .unwrap_or_default(); + if sections.options_area.height > 0 { + // Ensure the selected option is visible in the scroll window. + option_state + .ensure_visible(option_rows.len(), sections.options_area.height as usize); + render_rows( + sections.options_area, + buf, + &option_rows, + &option_state, + option_rows.len().max(1), + "No options", + ); + } + } + + if sections.notes_title_area.height > 0 { + let notes_label = if self.has_options() + && self + .current_answer() + .is_some_and(|answer| answer.selected.is_some()) + { + if let Some(label) = self.current_option_label() { + format!("Notes for {label} (optional)") + } else { + "Notes (optional)".to_string() + } + } else { + "Notes (optional)".to_string() + }; + let notes_title = if self.focus_is_notes() { + notes_label.as_str().cyan().bold() + } else { + notes_label.as_str().dim() + }; + Paragraph::new(Line::from(notes_title)).render(sections.notes_title_area, buf); + } + + if sections.notes_area.height > 0 { + self.render_notes_input(sections.notes_area, buf); + } + + let footer_y = sections + .notes_area + .y + .saturating_add(sections.notes_area.height); + if sections.footer_lines == 2 { + // Status line for unanswered count when any question is empty. + let warning = format!( + "Unanswered: {} | Will submit as skipped", + self.unanswered_count() + ); + Paragraph::new(Line::from(warning.dim())).render( + Rect { + x: area.x, + y: footer_y, + width: area.width, + height: 1, + }, + buf, + ); + } + let hint_y = footer_y.saturating_add(sections.footer_lines.saturating_sub(1)); + // Footer hints (selection index + navigation keys). + let mut hint_spans = Vec::new(); + if self.has_options() { + let options_len = self.options_len(); + let option_index = self.selected_option_index().map_or(0, |idx| idx + 1); + hint_spans.extend(vec![ + format!("Option {option_index} of {options_len}").into(), + " | ".into(), + ]); + } + hint_spans.extend(vec![ + key_hint::plain(KeyCode::Up).into(), + "/".into(), + key_hint::plain(KeyCode::Down).into(), + " scroll | ".into(), + key_hint::plain(KeyCode::Enter).into(), + " next question | ".into(), + ]); + if self.question_count() > 1 { + hint_spans.extend(vec![ + key_hint::plain(KeyCode::PageUp).into(), + " prev | ".into(), + key_hint::plain(KeyCode::PageDown).into(), + " next | ".into(), + ]); + } + hint_spans.extend(vec![ + key_hint::plain(KeyCode::Esc).into(), + " interrupt".into(), + ]); + Paragraph::new(Line::from(hint_spans).dim()).render( + Rect { + x: area.x, + y: hint_y, + width: area.width, + height: 1, + }, + buf, + ); + } + + /// Return the cursor position when editing notes, if visible. + pub(super) fn cursor_pos_impl(&self, area: Rect) -> Option<(u16, u16)> { + if !self.focus_is_notes() { + return None; + } + let sections = self.layout_sections(area); + let entry = self.current_notes_entry()?; + let input_area = sections.notes_area; + if input_area.width <= 2 || input_area.height == 0 { + return None; + } + if input_area.height < 3 { + // Inline notes layout uses a prefix and a single-line text area. + let prefix = notes_prefix(); + let prefix_width = prefix.len() as u16; + if input_area.width <= prefix_width { + return None; + } + let textarea_rect = Rect { + x: input_area.x.saturating_add(prefix_width), + y: input_area.y, + width: input_area.width.saturating_sub(prefix_width), + height: 1, + }; + let state = *entry.state.borrow(); + return entry.text.cursor_pos_with_state(textarea_rect, state); + } + let text_area_height = input_area.height.saturating_sub(2); + let textarea_rect = Rect { + x: input_area.x.saturating_add(1), + y: input_area.y.saturating_add(1), + width: input_area.width.saturating_sub(2), + height: text_area_height, + }; + let state = *entry.state.borrow(); + entry.text.cursor_pos_with_state(textarea_rect, state) + } + + /// Render the notes input box or inline notes field. + fn render_notes_input(&self, area: Rect, buf: &mut Buffer) { + let Some(entry) = self.current_notes_entry() else { + return; + }; + if area.width < 2 || area.height == 0 { + return; + } + if area.height < 3 { + // Inline notes field for tight layouts. + let prefix = notes_prefix(); + let prefix_width = prefix.len() as u16; + if area.width <= prefix_width { + Paragraph::new(Line::from(prefix.dim())).render(area, buf); + return; + } + Paragraph::new(Line::from(prefix.dim())).render( + Rect { + x: area.x, + y: area.y, + width: prefix_width, + height: 1, + }, + buf, + ); + let textarea_rect = Rect { + x: area.x.saturating_add(prefix_width), + y: area.y, + width: area.width.saturating_sub(prefix_width), + height: 1, + }; + let mut state = entry.state.borrow_mut(); + Clear.render(textarea_rect, buf); + StatefulWidgetRef::render_ref(&(&entry.text), textarea_rect, buf, &mut state); + if entry.text.text().is_empty() { + Paragraph::new(Line::from(self.notes_placeholder().dim())) + .render(textarea_rect, buf); + } + return; + } + // Draw a light ASCII frame around the notes area. + let top_border = format!("+{}+", "-".repeat(area.width.saturating_sub(2) as usize)); + let bottom_border = top_border.clone(); + Paragraph::new(Line::from(top_border)).render( + Rect { + x: area.x, + y: area.y, + width: area.width, + height: 1, + }, + buf, + ); + Paragraph::new(Line::from(bottom_border)).render( + Rect { + x: area.x, + y: area.y.saturating_add(area.height.saturating_sub(1)), + width: area.width, + height: 1, + }, + buf, + ); + for row in 1..area.height.saturating_sub(1) { + Line::from(vec![ + "|".into(), + " ".repeat(area.width.saturating_sub(2) as usize).into(), + "|".into(), + ]) + .render( + Rect { + x: area.x, + y: area.y.saturating_add(row), + width: area.width, + height: 1, + }, + buf, + ); + } + let text_area_height = area.height.saturating_sub(2); + let textarea_rect = Rect { + x: area.x.saturating_add(1), + y: area.y.saturating_add(1), + width: area.width.saturating_sub(2), + height: text_area_height, + }; + let mut state = entry.state.borrow_mut(); + Clear.render(textarea_rect, buf); + StatefulWidgetRef::render_ref(&(&entry.text), textarea_rect, buf, &mut state); + if entry.text.text().is_empty() { + Paragraph::new(Line::from(self.notes_placeholder().dim())).render(textarea_rect, buf); + } + } + + fn focus_is_options(&self) -> bool { + matches!(self.focus, super::Focus::Options) + } + + fn focus_is_notes(&self) -> bool { + matches!(self.focus, super::Focus::Notes) + } + + fn focus_is_notes_without_options(&self) -> bool { + !self.has_options() && self.focus_is_notes() + } +} + +fn notes_prefix() -> &'static str { + "Notes: " +} diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap new file mode 100644 index 0000000000..b698776b13 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- +Question 1/1 +Goal +Share details. ++--------------------------------------------------------------+ +|Type your answer (optional) | ++--------------------------------------------------------------+ +Unanswered: 1 | Will submit as skipped +↑/↓ scroll | enter next question | esc interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap new file mode 100644 index 0000000000..bc3d7d55b2 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- +Question 1/1 +Area +Choose an option. +Answer +(x) Option 1 First choice. +( ) Option 2 Second choice. +( ) Option 3 Third choice. + + + + +Notes for Option 1 (optional) ++--------------------------------------------------------------+ +|Add notes (optional) | ++--------------------------------------------------------------+ +Option 1 of 3 | ↑/↓ scroll | enter next question | esc interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap new file mode 100644 index 0000000000..22cb487413 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- +Question 1/1 +Next Step +What would you like to do next? +( ) Discuss a code change (Recommended) Walk through a plan and + edit code together. +( ) Run tests Pick a crate and run its + tests. +( ) Review a diff Summarize or review current +Notes: Add notes (optional) +Option 4 of 5 | ↑/↓ scroll | enter next question | esc interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap new file mode 100644 index 0000000000..e1d01df7af --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- +Question 1/1 +Area +Choose an option. +(x) Option 1 First choice. +( ) Option 2 Second choice. +( ) Option 3 Third choice. +Notes: Add notes (optional) +Option 1 of 3 | ↑/↓ scroll | enter next question | esc inter diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8fb3dbacbe..110280612c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -96,6 +96,7 @@ use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Settings; use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; use crossterm::event::KeyCode; @@ -1209,6 +1210,14 @@ impl ChatWidget { ); } + fn on_request_user_input(&mut self, ev: RequestUserInputEvent) { + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_user_input(ev), + |s| s.handle_request_user_input_now(ev2), + ); + } + fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) { self.flush_answer_stream_with_separator(); if is_unified_exec_source(ev.source) { @@ -1519,6 +1528,7 @@ impl ChatWidget { #[inline] fn handle_streaming_delta(&mut self, delta: String) { // Before streaming agent content, flush any active exec cell group. + self.flush_unified_exec_wait_streak(); self.flush_active_cell(); if self.stream_controller.is_none() { @@ -1676,6 +1686,12 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn handle_request_user_input_now(&mut self, ev: RequestUserInputEvent) { + self.flush_answer_stream_with_separator(); + self.bottom_pane.push_user_input_request(ev); + self.request_redraw(); + } + pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { // Ensure the status indicator is visible while the command runs. self.running_commands.insert( @@ -1807,9 +1823,7 @@ impl ChatWidget { let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager); - let model_for_header = model - .clone() - .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); + let model_for_header = model.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { CollaborationMode::Custom(Settings { @@ -1826,15 +1840,11 @@ impl ChatWidget { }) }; - let active_cell = if model.is_none() { - Some(Self::placeholder_session_header_cell( - &config, - config.features.enabled(Feature::CollaborationModes), - stored_collaboration_mode.clone(), - )) - } else { - None - }; + let active_cell = Some(Self::placeholder_session_header_cell( + &config, + config.features.enabled(Feature::CollaborationModes), + stored_collaboration_mode.clone(), + )); let mut widget = Self { app_event_tx: app_event_tx.clone(), @@ -2680,6 +2690,9 @@ impl ChatWidget { EventMsg::ElicitationRequest(ev) => { self.on_elicitation_request(ev); } + EventMsg::RequestUserInput(ev) => { + self.on_request_user_input(ev); + } EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta), EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), @@ -2740,8 +2753,7 @@ impl ChatWidget { | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) - | EventMsg::RequestUserInput(_) => {} + | EventMsg::ReasoningRawContentDelta(_) => {} } } diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index dc1e683ea5..5241c79b89 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -8,6 +8,7 @@ use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::PatchApplyEndEvent; use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::request_user_input::RequestUserInputEvent; use super::ChatWidget; @@ -16,6 +17,7 @@ pub(crate) enum QueuedInterrupt { ExecApproval(String, ExecApprovalRequestEvent), ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent), Elicitation(ElicitationRequestEvent), + RequestUserInput(RequestUserInputEvent), ExecBegin(ExecCommandBeginEvent), ExecEnd(ExecCommandEndEvent), McpBegin(McpToolCallBeginEvent), @@ -57,6 +59,10 @@ impl InterruptManager { self.queue.push_back(QueuedInterrupt::Elicitation(ev)); } + pub(crate) fn push_user_input(&mut self, ev: RequestUserInputEvent) { + self.queue.push_back(QueuedInterrupt::RequestUserInput(ev)); + } + pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) { self.queue.push_back(QueuedInterrupt::ExecBegin(ev)); } @@ -85,6 +91,7 @@ impl InterruptManager { chat.handle_apply_patch_approval_now(id, ev) } QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev), + QueuedInterrupt::RequestUserInput(ev) => chat.handle_request_user_input_now(ev), QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev), QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev), QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev), diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap index 905925709e..d2676235a2 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap @@ -6,11 +6,11 @@ expression: popup Access legacy models by running codex -m or in your config.toml › 1. gpt-5.2-codex (default) Latest frontier agentic coding model. - 2. gpt-5.1-codex-max Codex-optimized flagship for deep and fast - reasoning. - 3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less - capable. - 4. gpt-5.2 Latest frontier model with improvements across + 2. gpt-5.2 Latest frontier model with improvements across knowledge, reasoning and coding + 3. gpt-5.1-codex-max Codex-optimized flagship for deep and fast + reasoning. + 4. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less + capable. Press enter to select reasoning effort, or esc to dismiss. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap new file mode 100644 index 0000000000..5caf42e1bc --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: combined +--- +↳ Interacted with background terminal · cargo test -p codex-core + └ (waited) + +• Final response. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap new file mode 100644 index 0000000000..cfea4f8a66 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: combined +--- +↳ Interacted with background terminal · cargo test -p codex-core + └ (waited) + +• Streaming response. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 0610a640c7..35ab9579e4 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1754,6 +1754,79 @@ async fn unified_exec_interaction_after_task_complete_is_suppressed() { ); } +#[tokio::test] +async fn unified_exec_wait_after_final_agent_message_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + model_context_window: None, + }), + }); + + begin_unified_exec_startup(&mut chat, "call-wait", "proc-1", "cargo test -p codex-core"); + terminal_interaction(&mut chat, "call-wait-stdin", "proc-1", ""); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "Final response.".into(), + }), + }); + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: Some("Final response.".into()), + }), + }); + + let cells = drain_insert_history(&mut rx); + let combined = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert_snapshot!("unified_exec_wait_after_final_agent_message", combined); +} + +#[tokio::test] +async fn unified_exec_wait_before_streamed_agent_message_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + model_context_window: None, + }), + }); + + begin_unified_exec_startup( + &mut chat, + "call-wait-stream", + "proc-1", + "cargo test -p codex-core", + ); + terminal_interaction(&mut chat, "call-wait-stream-stdin", "proc-1", ""); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: "Streaming response.".into(), + }), + }); + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + }), + }); + + let cells = drain_insert_history(&mut rx); + let combined = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert_snapshot!("unified_exec_wait_before_streamed_agent_message", combined); +} + #[tokio::test] async fn unified_exec_wait_status_header_updates_on_late_command_display() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/collab.rs b/codex-rs/tui/src/collab.rs index 5a1a18f63c..b6c7d80959 100644 --- a/codex-rs/tui/src/collab.rs +++ b/codex-rs/tui/src/collab.rs @@ -7,123 +7,122 @@ use codex_core::protocol::CollabAgentSpawnEndEvent; use codex_core::protocol::CollabCloseEndEvent; use codex_core::protocol::CollabWaitingBeginEvent; use codex_core::protocol::CollabWaitingEndEvent; +use codex_protocol::ThreadId; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::text::Span; +use std::collections::HashMap; const COLLAB_PROMPT_PREVIEW_GRAPHEMES: usize = 160; +const COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES: usize = 160; +const COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES: usize = 240; pub(crate) fn spawn_end(ev: CollabAgentSpawnEndEvent) -> PlainHistoryCell { let CollabAgentSpawnEndEvent { call_id, - sender_thread_id, + sender_thread_id: _, new_thread_id, prompt, status, } = ev; let new_agent = new_thread_id - .map(|id| id.to_string()) - .unwrap_or_else(|| "none".to_string()); + .map(|id| Span::from(id.to_string())) + .unwrap_or_else(|| Span::from("not created").dim()); let mut details = vec![ detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("new_agent", new_agent), + detail_line("agent", new_agent), status_line(&status), ]; if let Some(line) = prompt_line(&prompt) { details.push(line); } - collab_event("Collab spawn", details) + collab_event("Agent spawned", details) } pub(crate) fn interaction_end(ev: CollabAgentInteractionEndEvent) -> PlainHistoryCell { let CollabAgentInteractionEndEvent { call_id, - sender_thread_id, + sender_thread_id: _, receiver_thread_id, prompt, status, } = ev; let mut details = vec![ detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("receiver", receiver_thread_id), + detail_line("receiver", receiver_thread_id.to_string()), status_line(&status), ]; if let Some(line) = prompt_line(&prompt) { details.push(line); } - collab_event("Collab send input", details) + collab_event("Input sent", details) } pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell { let CollabWaitingBeginEvent { call_id, - sender_thread_id, + sender_thread_id: _, receiver_thread_ids, } = ev; let details = vec![ detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("receiver", format!("{receiver_thread_ids:?}")), + detail_line("receivers", format_thread_ids(&receiver_thread_ids)), ]; - collab_event("Collab wait begin", details) + collab_event("Waiting for agents", details) } pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell { let CollabWaitingEndEvent { call_id, - sender_thread_id, + sender_thread_id: _, statuses, } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("statuses", format!("{statuses:#?}")), - ]; - collab_event("Collab wait end", details) + let mut details = vec![detail_line("call", call_id)]; + details.extend(wait_complete_lines(&statuses)); + collab_event("Wait complete", details) } pub(crate) fn close_end(ev: CollabCloseEndEvent) -> PlainHistoryCell { let CollabCloseEndEvent { call_id, - sender_thread_id, + sender_thread_id: _, receiver_thread_id, status, } = ev; let details = vec![ detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("receiver", receiver_thread_id), + detail_line("receiver", receiver_thread_id.to_string()), status_line(&status), ]; - collab_event("Collab close", details) + collab_event("Agent closed", details) } fn collab_event(title: impl Into, details: Vec>) -> PlainHistoryCell { let title = title.into(); - let mut lines: Vec> = vec![vec!["• ".dim(), title.bold()].into()]; + let mut lines: Vec> = + vec![vec![Span::from("• ").dim(), Span::from(title).bold()].into()]; if !details.is_empty() { lines.extend(prefix_lines(details, " └ ".dim(), " ".into())); } PlainHistoryCell::new(lines) } -fn detail_line(label: &str, value: impl std::fmt::Display) -> Line<'static> { - Line::from(format!("{label}: {value}").dim()) +fn detail_line(label: &str, value: impl Into>) -> Line<'static> { + vec![Span::from(format!("{label}: ")).dim(), value.into()].into() } fn status_line(status: &AgentStatus) -> Line<'static> { - Line::from(format!("status: {}", status_text(status)).dim()) + detail_line("status", status_span(status)) } -fn status_text(status: &AgentStatus) -> &'static str { +fn status_span(status: &AgentStatus) -> Span<'static> { match status { - AgentStatus::PendingInit => "pending_init", - AgentStatus::Running => "running", - AgentStatus::Completed(_) => "completed", - AgentStatus::Errored(_) => "errored", - AgentStatus::Shutdown => "shutdown", - AgentStatus::NotFound => "not_found", + AgentStatus::PendingInit => Span::from("pending init").dim(), + AgentStatus::Running => Span::from("running").cyan().bold(), + AgentStatus::Completed(_) => Span::from("completed").green(), + AgentStatus::Errored(_) => Span::from("errored").red(), + AgentStatus::Shutdown => Span::from("shutdown").dim(), + AgentStatus::NotFound => Span::from("not found").red(), } } @@ -134,7 +133,133 @@ fn prompt_line(prompt: &str) -> Option> { } else { Some(detail_line( "prompt", - truncate_text(trimmed, COLLAB_PROMPT_PREVIEW_GRAPHEMES), + Span::from(truncate_text(trimmed, COLLAB_PROMPT_PREVIEW_GRAPHEMES)).dim(), )) } } + +fn format_thread_ids(ids: &[ThreadId]) -> Span<'static> { + if ids.is_empty() { + return Span::from("none").dim(); + } + let joined = ids + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + Span::from(joined) +} + +fn wait_complete_lines(statuses: &HashMap) -> Vec> { + if statuses.is_empty() { + return vec![detail_line("agents", Span::from("none").dim())]; + } + + let mut pending_init = 0usize; + let mut running = 0usize; + let mut completed = 0usize; + let mut errored = 0usize; + let mut shutdown = 0usize; + let mut not_found = 0usize; + for status in statuses.values() { + match status { + AgentStatus::PendingInit => pending_init += 1, + AgentStatus::Running => running += 1, + AgentStatus::Completed(_) => completed += 1, + AgentStatus::Errored(_) => errored += 1, + AgentStatus::Shutdown => shutdown += 1, + AgentStatus::NotFound => not_found += 1, + } + } + + let mut summary = vec![Span::from(format!("{} total", statuses.len())).dim()]; + push_status_count( + &mut summary, + pending_init, + "pending init", + ratatui::prelude::Stylize::dim, + ); + push_status_count(&mut summary, running, "running", |span| span.cyan().bold()); + push_status_count( + &mut summary, + completed, + "completed", + ratatui::prelude::Stylize::green, + ); + push_status_count( + &mut summary, + errored, + "errored", + ratatui::prelude::Stylize::red, + ); + push_status_count( + &mut summary, + shutdown, + "shutdown", + ratatui::prelude::Stylize::dim, + ); + push_status_count( + &mut summary, + not_found, + "not found", + ratatui::prelude::Stylize::red, + ); + + let mut entries: Vec<(String, &AgentStatus)> = statuses + .iter() + .map(|(thread_id, status)| (thread_id.to_string(), status)) + .collect(); + entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + + let mut lines = Vec::with_capacity(entries.len() + 1); + lines.push(detail_line_spans("agents", summary)); + lines.extend(entries.into_iter().map(|(thread_id, status)| { + let mut spans = vec![ + Span::from(thread_id).dim(), + Span::from(" ").dim(), + status_span(status), + ]; + match status { + AgentStatus::Completed(Some(message)) => { + let message_preview = truncate_text( + &message.split_whitespace().collect::>().join(" "), + COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES, + ); + spans.push(Span::from(": ").dim()); + spans.push(Span::from(message_preview)); + } + AgentStatus::Errored(error) => { + let error_preview = truncate_text( + &error.split_whitespace().collect::>().join(" "), + COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES, + ); + spans.push(Span::from(": ").dim()); + spans.push(Span::from(error_preview).dim()); + } + _ => {} + } + spans.into() + })); + lines +} + +fn push_status_count( + spans: &mut Vec>, + count: usize, + label: &'static str, + style: impl FnOnce(Span<'static>) -> Span<'static>, +) { + if count == 0 { + return; + } + + spans.push(Span::from(" · ").dim()); + spans.push(style(Span::from(format!("{count} {label}")))); +} + +fn detail_line_spans(label: &str, mut value: Vec>) -> Line<'static> { + let mut spans = Vec::with_capacity(value.len() + 1); + spans.push(Span::from(format!("{label}: ")).dim()); + spans.append(&mut value); + spans.into() +} diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 157ccb97a2..9ad40e3afe 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1123,7 +1123,9 @@ impl HistoryCell for SessionHeaderHistoryCell { label_width = label_width ); let mut spans = vec![Span::from(format!("{collab_label} ")).dim()]; - if let Some(mode_label) = self.collaboration_mode_label() { + if self.model == "loading" { + spans.push(Span::styled(self.model.clone(), self.model_style)); + } else if let Some(mode_label) = self.collaboration_mode_label() { spans.push(Span::styled(mode_label.to_string(), self.model_style)); } else { spans.push(Span::styled("Custom", self.model_style)); diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 920d571451..97e4c195ef 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -47,6 +47,7 @@ use codex_ansi_escape::ansi_escape_line; use codex_core::AuthManager; use codex_core::ThreadManager; use codex_core::config::Config; +use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; #[cfg(target_os = "windows")] use codex_core::features::Feature; @@ -1991,6 +1992,39 @@ impl App { } } } + AppEvent::UpdateFeatureFlags { updates } => { + if updates.is_empty() { + return Ok(AppRunControl::Continue); + } + + let mut builder = ConfigEditsBuilder::new(&self.config.codex_home) + .with_profile(self.active_profile.as_deref()); + for (feature, enabled) in &updates { + let key = feature.key(); + if *enabled { + self.config.features.enable(*feature); + self.chat_widget.set_feature_enabled(*feature, true); + builder = builder.set_feature_enabled(key, true); + } else { + self.config.features.disable(*feature); + self.chat_widget.set_feature_enabled(*feature, false); + if feature.default_enabled() { + builder = builder.set_feature_enabled(key, false); + } else { + builder = builder.with_edits([ConfigEdit::ClearPath { + segments: vec!["features".to_string(), key.to_string()], + }]); + } + } + } + + if let Err(err) = builder.apply().await { + tracing::error!(error = %err, "failed to persist feature flags"); + self.chat_widget.add_error_message(format!( + "Failed to update experimental features: {err}" + )); + } + } AppEvent::SkipNextWorldWritableScan => { self.skip_world_writable_scan_once = true; } @@ -2685,6 +2719,14 @@ mod tests { app.chat_widget.current_model(), event, is_first, + false, + codex_protocol::config_types::CollaborationMode::Custom( + codex_protocol::config_types::Settings { + model: "gpt-test".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + ), )) as Arc }; diff --git a/codex-rs/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs index ff3f0e3875..f4640968da 100644 --- a/codex-rs/tui2/src/app_event.rs +++ b/codex-rs/tui2/src/app_event.rs @@ -11,6 +11,7 @@ use std::path::PathBuf; use codex_common::approval_presets::ApprovalPreset; +use codex_core::features::Feature; use codex_core::protocol::Event; use codex_core::protocol::RateLimitSnapshot; use codex_file_search::FileMatch; @@ -168,6 +169,11 @@ pub(crate) enum AppEvent { /// Update the current sandbox policy in the running app and widget. UpdateSandboxPolicy(SandboxPolicy), + /// Update feature flags and persist them to config. + UpdateFeatureFlags { + updates: Vec<(Feature, bool)>, + }, + /// Update whether the full access warning prompt has been acknowledged. UpdateFullAccessWarningAcknowledged(bool), diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 34a4795de9..c8bd88d414 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -15,6 +15,18 @@ //! [`ChatComposer::handle_key_event_without_popup`]. After every handled key, we call //! [`ChatComposer::sync_popups`] so UI state follows the latest buffer/cursor. //! +//! # Submission and Prompt Expansion +//! +//! On submit/queue paths, the composer: +//! +//! - Expands pending paste placeholders so element ranges align with the final text. +//! - Trims whitespace and rebases text elements accordingly. +//! - Expands `/prompts:` custom prompts (named or numeric args), preserving text elements. +//! - Prunes attached images so only placeholders that survive expansion are sent. +//! +//! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion +//! and attachment pruning, and clears pending paste state on success. +//! //! # Non-bracketed Paste Bursts //! //! On some terminals (especially on Windows), pastes arrive as a rapid sequence of @@ -172,8 +184,14 @@ enum PromptSelectionMode { } enum PromptSelectionAction { - Insert { text: String, cursor: Option }, - Submit { text: String }, + Insert { + text: String, + cursor: Option, + }, + Submit { + text: String, + text_elements: Vec, + }, } pub(crate) struct ChatComposer { @@ -541,6 +559,18 @@ impl ChatComposer { .collect() } + fn prune_attached_images_for_submission(&mut self, text: &str, text_elements: &[TextElement]) { + if self.attached_images.is_empty() { + return; + } + let image_placeholders: HashSet<&str> = text_elements + .iter() + .filter_map(|elem| elem.placeholder(text)) + .collect(); + self.attached_images + .retain(|img| image_placeholders.contains(img.placeholder.as_str())); + } + /// Insert an attachment placeholder and track it for the next submission. pub fn attach_image(&mut self, path: PathBuf) { let image_number = self.attached_images.len() + 1; @@ -769,6 +799,7 @@ impl ChatComposer { prompt, first_line, PromptSelectionMode::Completion, + &self.textarea.text_elements(), ) { PromptSelectionAction::Insert { text, cursor } => { let target = cursor.unwrap_or(text.len()); @@ -795,19 +826,31 @@ impl ChatComposer { // If the current line starts with a custom prompt name and includes // positional args for a numeric-style template, expand and submit // immediately regardless of the popup selection. - let first_line = self.textarea.text().lines().next().unwrap_or(""); - if let Some((name, _rest)) = parse_slash_name(first_line) + let mut text = self.textarea.text().to_string(); + let mut text_elements = self.textarea.text_elements(); + if !self.pending_pastes.is_empty() { + let (expanded, expanded_elements) = + Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes); + text = expanded; + text_elements = expanded_elements; + } + let first_line = text.lines().next().unwrap_or(""); + if let Some((name, _rest, _rest_offset)) = parse_slash_name(first_line) && let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) && let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == prompt_name) && let Some(expanded) = - expand_if_numeric_with_positional_args(prompt, first_line) + expand_if_numeric_with_positional_args(prompt, first_line, &text_elements) { + self.prune_attached_images_for_submission( + &expanded.text, + &expanded.text_elements, + ); + self.pending_pastes.clear(); self.textarea.set_text_clearing_elements(""); return ( InputResult::Submitted { - text: expanded, - // Expanded prompt is plain text; no UI element ranges to preserve. - text_elements: Vec::new(), + text: expanded.text, + text_elements: expanded.text_elements, }, true, ); @@ -825,14 +868,21 @@ impl ChatComposer { prompt, first_line, PromptSelectionMode::Submit, + &self.textarea.text_elements(), ) { - PromptSelectionAction::Submit { text } => { + PromptSelectionAction::Submit { + text, + text_elements, + } => { + self.prune_attached_images_for_submission( + &text, + &text_elements, + ); self.textarea.set_text_clearing_elements(""); return ( InputResult::Submitted { text, - // Submitting a slash/custom prompt generates plain text, so there are no UI element ranges. - text_elements: Vec::new(), + text_elements, }, true, ); @@ -1487,11 +1537,10 @@ impl ChatComposer { let expanded_input = text.clone(); // If there is neither text nor attachments, suppress submission entirely. - let has_attachments = !self.attached_images.is_empty(); text = text.trim().to_string(); text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements); - if let Some((name, _rest)) = parse_slash_name(&text) { + if let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) { let treat_as_plain_text = input_starts_with_space || name.contains('/'); if !treat_as_plain_text { let is_builtin = @@ -1525,29 +1574,31 @@ impl ChatComposer { } } - let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { - Ok(expanded) => expanded, - Err(err) => { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event(err.user_message()), - ))); - self.set_text_content( - original_input.clone(), - original_text_elements, - original_local_image_paths, - ); - self.pending_pastes.clone_from(&original_pending_pastes); - self.textarea.set_cursor(original_input.len()); - return None; - } - }; + let expanded_prompt = + match expand_custom_prompt(&text, &text_elements, &self.custom_prompts) { + Ok(expanded) => expanded, + Err(err) => { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(err.user_message()), + ))); + self.set_text_content( + original_input.clone(), + original_text_elements, + original_local_image_paths, + ); + self.pending_pastes.clone_from(&original_pending_pastes); + self.textarea.set_cursor(original_input.len()); + return None; + } + }; if let Some(expanded) = expanded_prompt { - text = expanded; - // Expanded prompt (e.g. custom prompt) is plain text; text elements not supported yet. - // TODO: Preserve UI element ranges through prompt expansion in a follow-up PR. - text_elements = Vec::new(); + text = expanded.text; + text_elements = expanded.text_elements; } - if text.is_empty() && !has_attachments { + // Custom prompt expansion can remove or rewrite image placeholders, so prune any + // attachments that no longer have a corresponding placeholder in the expanded text. + self.prune_attached_images_for_submission(&text, &text_elements); + if text.is_empty() && self.attached_images.is_empty() { return None; } if !text.is_empty() { @@ -1650,7 +1701,7 @@ impl ChatComposer { /// Returns Some(InputResult) if a command was dispatched, None otherwise. fn try_dispatch_bare_slash_command(&mut self) -> Option { let first_line = self.textarea.text().lines().next().unwrap_or(""); - if let Some((name, rest)) = parse_slash_name(first_line) + if let Some((name, rest, _rest_offset)) = parse_slash_name(first_line) && rest.is_empty() && let Some((_n, cmd)) = Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) @@ -1671,7 +1722,7 @@ impl ChatComposer { if !input_starts_with_space { let text = self.textarea.text().to_string(); - if let Some((name, rest)) = parse_slash_name(&text) + if let Some((name, rest, _rest_offset)) = parse_slash_name(&text) && !rest.is_empty() && !name.contains('/') && let Some((_n, cmd)) = @@ -1746,7 +1797,7 @@ impl ChatComposer { modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, .. - } => self.handle_submission(true), + } if self.is_task_running => self.handle_submission(true), KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, @@ -2463,6 +2514,7 @@ fn prompt_selection_action( prompt: &CustomPrompt, first_line: &str, mode: PromptSelectionMode, + text_elements: &[TextElement], ) -> PromptSelectionAction { let named_args = prompt_argument_names(&prompt.content); let has_numeric = prompt_has_numeric_placeholders(&prompt.content); @@ -2494,14 +2546,21 @@ fn prompt_selection_action( }; } if has_numeric { - if let Some(expanded) = expand_if_numeric_with_positional_args(prompt, first_line) { - return PromptSelectionAction::Submit { text: expanded }; + if let Some(expanded) = + expand_if_numeric_with_positional_args(prompt, first_line, text_elements) + { + return PromptSelectionAction::Submit { + text: expanded.text, + text_elements: expanded.text_elements, + }; } let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); return PromptSelectionAction::Insert { text, cursor: None }; } PromptSelectionAction::Submit { text: prompt.content.clone(), + // By now we know this custom prompt has no args, so no text elements to preserve. + text_elements: Vec::new(), } } } @@ -2522,6 +2581,7 @@ mod tests { use crate::bottom_pane::InputResult; use crate::bottom_pane::chat_composer::AttachedImage; use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; + use crate::bottom_pane::prompt_args::PromptArg; use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line; use crate::bottom_pane::textarea::TextArea; use tokio::sync::mpsc::unbounded_channel; @@ -3792,15 +3852,37 @@ mod tests { let args = extract_positional_args_for_prompt_line( "/prompts:review \"docs/My File.md\"", "review", + &[], + ); + assert_eq!( + args, + vec![PromptArg { + text: "docs/My File.md".to_string(), + text_elements: Vec::new(), + }] ); - assert_eq!(args, vec!["docs/My File.md".to_string()]); } #[test] fn extract_args_supports_mixed_quoted_and_unquoted() { - let args = - extract_positional_args_for_prompt_line("/prompts:cmd \"with spaces\" simple", "cmd"); - assert_eq!(args, vec!["with spaces".to_string(), "simple".to_string()]); + let args = extract_positional_args_for_prompt_line( + "/prompts:cmd \"with spaces\" simple", + "cmd", + &[], + ); + assert_eq!( + args, + vec![ + PromptArg { + text: "with spaces".to_string(), + text_elements: Vec::new(), + }, + PromptArg { + text: "simple".to_string(), + text_elements: Vec::new(), + } + ] + ); } #[test] @@ -4831,6 +4913,166 @@ mod tests { assert!(composer.textarea.is_empty()); } + #[test] + fn custom_prompt_submission_preserves_image_placeholder_unquoted() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $IMG".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt IMG="); + composer.textarea.set_cursor(composer.textarea.text().len()); + let path = PathBuf::from("/tmp/image_prompt.png"); + composer.attach_image(path); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let placeholder = local_image_label_text(1); + assert_eq!(text, format!("Review {placeholder}")); + assert_eq!( + text_elements, + vec![TextElement::new( + ByteRange { + start: "Review ".len(), + end: "Review ".len() + placeholder.len(), + }, + Some(placeholder), + )] + ); + } + _ => panic!("expected Submitted"), + } + } + + #[test] + fn custom_prompt_submission_preserves_image_placeholder_quoted() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $IMG".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt IMG=\""); + composer.textarea.set_cursor(composer.textarea.text().len()); + let path = PathBuf::from("/tmp/image_prompt_quoted.png"); + composer.attach_image(path); + composer.handle_paste("\"".to_string()); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let placeholder = local_image_label_text(1); + assert_eq!(text, format!("Review {placeholder}")); + assert_eq!( + text_elements, + vec![TextElement::new( + ByteRange { + start: "Review ".len(), + end: "Review ".len() + placeholder.len(), + }, + Some(placeholder), + )] + ); + } + _ => panic!("expected Submitted"), + } + } + + #[test] + fn custom_prompt_submission_drops_unused_image_arg() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review changes".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt IMG="); + composer.textarea.set_cursor(composer.textarea.text().len()); + let path = PathBuf::from("/tmp/unused_image.png"); + composer.attach_image(path); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "Review changes"); + assert!(text_elements.is_empty()); + } + _ => panic!("expected Submitted"), + } + assert!(composer.take_recent_submission_images().is_empty()); + } + /// Behavior: selecting a custom prompt that includes a large paste placeholder should expand /// to the full pasted content before submission. #[test] @@ -4898,6 +5140,65 @@ mod tests { assert!(composer.pending_pastes.is_empty()); } + #[test] + fn custom_prompt_with_large_paste_and_image_preserves_elements() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $IMG\n\n$CODE".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt IMG="); + composer.textarea.set_cursor(composer.textarea.text().len()); + let path = PathBuf::from("/tmp/image_prompt_combo.png"); + composer.attach_image(path); + composer.handle_paste(" CODE=".to_string()); + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large_content.clone()); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let placeholder = local_image_label_text(1); + assert_eq!(text, format!("Review {placeholder}\n\n{large_content}")); + assert_eq!( + text_elements, + vec![TextElement::new( + ByteRange { + start: "Review ".len(), + end: "Review ".len() + placeholder.len(), + }, + Some(placeholder), + )] + ); + } + _ => panic!("expected Submitted"), + } + } + #[test] fn slash_path_input_submits_without_command_error() { use crossterm::event::KeyCode; @@ -5116,6 +5417,201 @@ mod tests { )); } + #[test] + fn popup_prompt_submission_prunes_unused_image_attachments() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Hello".to_string(), + description: None, + argument_hint: None, + }]); + + composer.attach_image(PathBuf::from("/tmp/unused.png")); + composer.textarea.set_cursor(0); + composer.handle_paste(format!("/{PROMPTS_CMD_PREFIX}:my-prompt ")); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == "Hello" + )); + assert!( + composer + .take_recent_submission_images_with_placeholders() + .is_empty() + ); + } + + #[test] + fn numeric_prompt_auto_submit_prunes_unused_image_attachments() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Hello $1".to_string(), + description: None, + argument_hint: None, + }]); + + type_chars_humanlike( + &mut composer, + &[ + '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm', + 'p', 't', ' ', 'f', 'o', 'o', ' ', + ], + ); + composer.attach_image(PathBuf::from("/tmp/unused.png")); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == "Hello foo" + )); + assert!( + composer + .take_recent_submission_images_with_placeholders() + .is_empty() + ); + } + + #[test] + fn numeric_prompt_auto_submit_expands_pending_pastes() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Echo: $1".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt "); + composer.textarea.set_cursor(composer.textarea.text().len()); + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large_content.clone()); + + assert_eq!(composer.pending_pastes.len(), 1); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let expected = format!("Echo: {large_content}"); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == expected + )); + assert!(composer.pending_pastes.is_empty()); + } + + #[test] + fn queued_prompt_submission_prunes_unused_image_attachments() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(false); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Hello $1".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt foo "); + composer.textarea.set_cursor(composer.textarea.text().len()); + composer.attach_image(PathBuf::from("/tmp/unused.png")); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(matches!( + result, + InputResult::Queued { text, .. } if text == "Hello foo" + )); + assert!( + composer + .take_recent_submission_images_with_placeholders() + .is_empty() + ); + } + + #[test] + fn selecting_custom_prompt_with_positional_args_submits_numeric_expansion() { + let prompt_text = "Header: $1\nArgs: $ARGUMENTS\n"; + + let prompt = CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }; + + let action = prompt_selection_action( + &prompt, + "/prompts:my-prompt foo bar", + PromptSelectionMode::Submit, + &[], + ); + match action { + PromptSelectionAction::Submit { + text, + text_elements, + } => { + assert_eq!(text, "Header: foo\nArgs: foo bar\n"); + assert!(text_elements.is_empty()); + } + _ => panic!("expected Submit action"), + } + } + #[test] fn numeric_prompt_positional_args_does_not_error() { // Ensure that a prompt with only numeric placeholders does not trigger diff --git a/codex-rs/tui2/src/bottom_pane/command_popup.rs b/codex-rs/tui2/src/bottom_pane/command_popup.rs index 000ce2e4ea..47bae3a34f 100644 --- a/codex-rs/tui2/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui2/src/bottom_pane/command_popup.rs @@ -297,7 +297,17 @@ mod tests { CommandItem::UserPrompt(_) => None, }) .collect(); - assert_eq!(cmds, vec!["model", "resume", "compact", "mention", "mcp"]); + assert_eq!( + cmds, + vec![ + "model", + "experimental", + "resume", + "compact", + "mention", + "mcp" + ] + ); } #[test] diff --git a/codex-rs/tui2/src/bottom_pane/experimental_features_view.rs b/codex-rs/tui2/src/bottom_pane/experimental_features_view.rs new file mode 100644 index 0000000000..13f0f3a1d6 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/experimental_features_view.rs @@ -0,0 +1,293 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Block; +use ratatui::widgets::Widget; + +use codex_core::features::Feature; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::key_hint; +use crate::render::Insets; +use crate::render::RectExt as _; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::style::user_message_style; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::measure_rows_height; +use super::selection_popup_common::render_rows; + +pub(crate) struct BetaFeatureItem { + pub(crate) feature: Feature, + pub(crate) name: String, + pub(crate) description: String, + pub(crate) enabled: bool, +} + +pub(crate) struct ExperimentalFeaturesView { + features: Vec, + state: ScrollState, + complete: bool, + app_event_tx: AppEventSender, + header: Box, + footer_hint: Line<'static>, +} + +impl ExperimentalFeaturesView { + pub(crate) fn new(features: Vec, app_event_tx: AppEventSender) -> Self { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Experimental features".bold())); + header.push(Line::from( + "Toggle beta features. Changes are saved to config.toml.".dim(), + )); + + let mut view = Self { + features, + state: ScrollState::new(), + complete: false, + app_event_tx, + header: Box::new(header), + footer_hint: experimental_popup_hint_line(), + }; + view.initialize_selection(); + view + } + + fn initialize_selection(&mut self) { + if self.visible_len() == 0 { + self.state.selected_idx = None; + } else if self.state.selected_idx.is_none() { + self.state.selected_idx = Some(0); + } + } + + fn visible_len(&self) -> usize { + self.features.len() + } + + fn build_rows(&self) -> Vec { + let mut rows = Vec::with_capacity(self.features.len()); + let selected_idx = self.state.selected_idx; + for (idx, item) in self.features.iter().enumerate() { + let prefix = if selected_idx == Some(idx) { + '›' + } else { + ' ' + }; + let marker = if item.enabled { 'x' } else { ' ' }; + let name = format!("{prefix} [{marker}] {}", item.name); + rows.push(GenericDisplayRow { + name, + display_shortcut: None, + match_indices: None, + description: Some(item.description.clone()), + wrap_indent: None, + }); + } + + rows + } + + fn move_up(&mut self) { + let len = self.visible_len(); + if len == 0 { + return; + } + self.state.move_up_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + fn move_down(&mut self) { + let len = self.visible_len(); + if len == 0 { + return; + } + self.state.move_down_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + fn toggle_selected(&mut self) { + let Some(selected_idx) = self.state.selected_idx else { + return; + }; + + if let Some(item) = self.features.get_mut(selected_idx) { + item.enabled = !item.enabled; + } + } + + fn rows_width(total_width: u16) -> u16 { + total_width.saturating_sub(2) + } +} + +impl BottomPaneView for ExperimentalFeaturesView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('\u{0010}'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_up(), + KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_up(), + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('\u{000e}'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_down(), + KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_down(), + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => self.toggle_selected(), + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.on_ctrl_c(); + } + _ => {} + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + if !self.features.is_empty() { + let updates = self + .features + .iter() + .map(|item| (item.feature, item.enabled)) + .collect(); + self.app_event_tx + .send(AppEvent::UpdateFeatureFlags { updates }); + } + + self.complete = true; + CancellationEvent::Handled + } +} + +impl Renderable for ExperimentalFeaturesView { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + let [content_area, footer_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); + + Block::default() + .style(user_message_style()) + .render(content_area, buf); + + let header_height = self + .header + .desired_height(content_area.width.saturating_sub(4)); + let rows = self.build_rows(); + let rows_width = Self::rows_width(content_area.width); + let rows_height = measure_rows_height( + &rows, + &self.state, + MAX_POPUP_ROWS, + rows_width.saturating_add(1), + ); + let [header_area, _, list_area] = Layout::vertical([ + Constraint::Max(header_height), + Constraint::Max(1), + Constraint::Length(rows_height), + ]) + .areas(content_area.inset(Insets::vh(1, 2))); + + self.header.render(header_area, buf); + + if list_area.height > 0 { + let render_area = Rect { + x: list_area.x.saturating_sub(2), + y: list_area.y, + width: rows_width.max(1), + height: list_area.height, + }; + render_rows( + render_area, + buf, + &rows, + &self.state, + MAX_POPUP_ROWS, + " No experimental features available for now", + ); + } + + let hint_area = Rect { + x: footer_area.x + 2, + y: footer_area.y, + width: footer_area.width.saturating_sub(2), + height: footer_area.height, + }; + self.footer_hint.clone().dim().render(hint_area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + let rows = self.build_rows(); + let rows_width = Self::rows_width(width); + let rows_height = measure_rows_height( + &rows, + &self.state, + MAX_POPUP_ROWS, + rows_width.saturating_add(1), + ); + + let mut height = self.header.desired_height(width.saturating_sub(4)); + height = height.saturating_add(rows_height + 3); + height.saturating_add(1) + } +} + +fn experimental_popup_hint_line() -> Line<'static> { + Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Enter).into(), + " to toggle or ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to save for next conversation".into(), + ]) +} diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index 4fa0ae5fe9..ec5df34f66 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -49,6 +49,7 @@ mod chat_composer; mod chat_composer_history; mod command_popup; pub mod custom_prompt_view; +mod experimental_features_view; mod file_search_popup; mod footer; mod list_selection_view; @@ -99,6 +100,8 @@ pub(crate) use chat_composer::InputResult; use codex_protocol::custom_prompts::CustomPrompt; use crate::status_indicator_widget::StatusIndicatorWidget; +pub(crate) use experimental_features_view::BetaFeatureItem; +pub(crate) use experimental_features_view::ExperimentalFeaturesView; pub(crate) use list_selection_view::SelectionAction; pub(crate) use list_selection_view::SelectionItem; diff --git a/codex-rs/tui2/src/bottom_pane/prompt_args.rs b/codex-rs/tui2/src/bottom_pane/prompt_args.rs index 48c3cedfab..ade750f671 100644 --- a/codex-rs/tui2/src/bottom_pane/prompt_args.rs +++ b/codex-rs/tui2/src/bottom_pane/prompt_args.rs @@ -1,5 +1,7 @@ use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement; use lazy_static::lazy_static; use regex_lite::Regex; use shlex::Shlex; @@ -57,28 +59,49 @@ impl PromptExpansionError { } /// Parse a first-line slash command of the form `/name `. -/// Returns `(name, rest_after_name)` if the line begins with `/` and contains -/// a non-empty name; otherwise returns `None`. -pub fn parse_slash_name(line: &str) -> Option<(&str, &str)> { +/// Returns `(name, rest_after_name, rest_offset)` if the line begins with `/` +/// and contains a non-empty name; otherwise returns `None`. +/// +/// `rest_offset` is the byte index into the original line where `rest_after_name` +/// starts after trimming leading whitespace (so `line[rest_offset..] == rest_after_name`). +pub fn parse_slash_name(line: &str) -> Option<(&str, &str, usize)> { let stripped = line.strip_prefix('/')?; - let mut name_end = stripped.len(); + let mut name_end_in_stripped = stripped.len(); for (idx, ch) in stripped.char_indices() { if ch.is_whitespace() { - name_end = idx; + name_end_in_stripped = idx; break; } } - let name = &stripped[..name_end]; + let name = &stripped[..name_end_in_stripped]; if name.is_empty() { return None; } - let rest = stripped[name_end..].trim_start(); - Some((name, rest)) + let rest_untrimmed = &stripped[name_end_in_stripped..]; + let rest = rest_untrimmed.trim_start(); + let rest_start_in_stripped = name_end_in_stripped + (rest_untrimmed.len() - rest.len()); + // `stripped` is `line` without the leading '/', so add 1 to get the original offset. + let rest_offset = rest_start_in_stripped + 1; + Some((name, rest, rest_offset)) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PromptArg { + pub text: String, + pub text_elements: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PromptExpansion { + pub text: String, + pub text_elements: Vec, } /// Parse positional arguments using shlex semantics (supports quoted tokens). -pub fn parse_positional_args(rest: &str) -> Vec { - Shlex::new(rest).collect() +/// +/// `text_elements` must be relative to `rest`. +pub fn parse_positional_args(rest: &str, text_elements: &[TextElement]) -> Vec { + parse_tokens_with_elements(rest, text_elements) } /// Extracts the unique placeholder variable names from a prompt template. @@ -111,20 +134,39 @@ pub fn prompt_argument_names(content: &str) -> Vec { /// The input is split using shlex rules, so quoted values are supported /// (for example `USER="Alice Smith"`). The function returns a map of parsed /// arguments, or an error if a token is missing `=` or if the key is empty. -pub fn parse_prompt_inputs(rest: &str) -> Result, PromptArgsError> { +pub fn parse_prompt_inputs( + rest: &str, + text_elements: &[TextElement], +) -> Result, PromptArgsError> { let mut map = HashMap::new(); if rest.trim().is_empty() { return Ok(map); } - for token in Shlex::new(rest) { - let Some((key, value)) = token.split_once('=') else { - return Err(PromptArgsError::MissingAssignment { token }); + // Tokenize the rest of the command using shlex rules, but keep text element + // ranges relative to each emitted token. + for token in parse_tokens_with_elements(rest, text_elements) { + let Some((key, value)) = token.text.split_once('=') else { + return Err(PromptArgsError::MissingAssignment { token: token.text }); }; if key.is_empty() { - return Err(PromptArgsError::MissingKey { token }); + return Err(PromptArgsError::MissingKey { token: token.text }); } - map.insert(key.to_string(), value.to_string()); + // The token is `key=value`; translate element ranges into the value-only + // coordinate space by subtracting the `key=` prefix length. + let value_start = key.len() + 1; + let value_elements = token + .text_elements + .iter() + .filter_map(|elem| shift_text_element_left(elem, value_start)) + .collect(); + map.insert( + key.to_string(), + PromptArg { + text: value.to_string(), + text_elements: value_elements, + }, + ); } Ok(map) } @@ -136,9 +178,10 @@ pub fn parse_prompt_inputs(rest: &str) -> Result, Prompt /// `Ok(Some(expanded))`; otherwise it returns a descriptive error. pub fn expand_custom_prompt( text: &str, + text_elements: &[TextElement], custom_prompts: &[CustomPrompt], -) -> Result, PromptExpansionError> { - let Some((name, rest)) = parse_slash_name(text) else { +) -> Result, PromptExpansionError> { + let Some((name, rest, rest_offset)) = parse_slash_name(text) else { return Ok(None); }; @@ -153,10 +196,24 @@ pub fn expand_custom_prompt( }; // If there are named placeholders, expect key=value inputs. let required = prompt_argument_names(&prompt.content); + let local_elements: Vec = text_elements + .iter() + .filter_map(|elem| { + let mut shifted = shift_text_element_left(elem, rest_offset)?; + if shifted.byte_range.start >= rest.len() { + return None; + } + let end = shifted.byte_range.end.min(rest.len()); + shifted.byte_range.end = end; + (shifted.byte_range.start < shifted.byte_range.end).then_some(shifted) + }) + .collect(); if !required.is_empty() { - let inputs = parse_prompt_inputs(rest).map_err(|error| PromptExpansionError::Args { - command: format!("/{name}"), - error, + let inputs = parse_prompt_inputs(rest, &local_elements).map_err(|error| { + PromptExpansionError::Args { + command: format!("/{name}"), + error, + } })?; let missing: Vec = required .into_iter() @@ -168,28 +225,19 @@ pub fn expand_custom_prompt( missing, }); } - let content = &prompt.content; - let replaced = PROMPT_ARG_REGEX.replace_all(content, |caps: ®ex_lite::Captures<'_>| { - if let Some(matched) = caps.get(0) - && matched.start() > 0 - && content.as_bytes()[matched.start() - 1] == b'$' - { - return matched.as_str().to_string(); - } - let whole = &caps[0]; - let key = &whole[1..]; - inputs - .get(key) - .cloned() - .unwrap_or_else(|| whole.to_string()) - }); - return Ok(Some(replaced.into_owned())); + let (text, elements) = expand_named_placeholders_with_elements(&prompt.content, &inputs); + return Ok(Some(PromptExpansion { + text, + text_elements: elements, + })); } // Otherwise, treat it as numeric/positional placeholder prompt (or none). - let pos_args: Vec = Shlex::new(rest).collect(); - let expanded = expand_numeric_placeholders(&prompt.content, &pos_args); - Ok(Some(expanded)) + let pos_args = parse_positional_args(rest, &local_elements); + Ok(Some(expand_numeric_placeholders( + &prompt.content, + &pos_args, + ))) } /// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`. @@ -213,25 +261,42 @@ pub fn prompt_has_numeric_placeholders(content: &str) -> bool { /// Extract positional arguments from a composer first line like "/name a b" for a given prompt name. /// Returns empty when the command name does not match or when there are no args. -pub fn extract_positional_args_for_prompt_line(line: &str, prompt_name: &str) -> Vec { +pub fn extract_positional_args_for_prompt_line( + line: &str, + prompt_name: &str, + text_elements: &[TextElement], +) -> Vec { let trimmed = line.trim_start(); - let Some(rest) = trimmed.strip_prefix('/') else { + let trim_offset = line.len() - trimmed.len(); + let Some((name, rest, rest_offset)) = parse_slash_name(trimmed) else { return Vec::new(); }; // Require the explicit prompts prefix for custom prompt invocations. - let Some(after_prefix) = rest.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { + let Some(after_prefix) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { return Vec::new(); }; - let mut parts = after_prefix.splitn(2, char::is_whitespace); - let cmd = parts.next().unwrap_or(""); - if cmd != prompt_name { + if after_prefix != prompt_name { return Vec::new(); } - let args_str = parts.next().unwrap_or("").trim(); + let rest_trimmed_start = rest.trim_start(); + let args_str = rest_trimmed_start.trim_end(); if args_str.is_empty() { return Vec::new(); } - parse_positional_args(args_str) + let args_offset = trim_offset + rest_offset + (rest.len() - rest_trimmed_start.len()); + let local_elements: Vec = text_elements + .iter() + .filter_map(|elem| { + let mut shifted = shift_text_element_left(elem, args_offset)?; + if shifted.byte_range.start >= args_str.len() { + return None; + } + let end = shifted.byte_range.end.min(args_str.len()); + shifted.byte_range.end = end; + (shifted.byte_range.start < shifted.byte_range.end).then_some(shifted) + }) + .collect(); + parse_positional_args(args_str, &local_elements) } /// If the prompt only uses numeric placeholders and the first line contains @@ -239,14 +304,15 @@ pub fn extract_positional_args_for_prompt_line(line: &str, prompt_name: &str) -> pub fn expand_if_numeric_with_positional_args( prompt: &CustomPrompt, first_line: &str, -) -> Option { + text_elements: &[TextElement], +) -> Option { if !prompt_argument_names(&prompt.content).is_empty() { return None; } if !prompt_has_numeric_placeholders(&prompt.content) { return None; } - let args = extract_positional_args_for_prompt_line(first_line, &prompt.name); + let args = extract_positional_args_for_prompt_line(first_line, &prompt.name, text_elements); if args.is_empty() { return None; } @@ -254,10 +320,10 @@ pub fn expand_if_numeric_with_positional_args( } /// Expand `$1..$9` and `$ARGUMENTS` in `content` with values from `args`. -pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { +pub fn expand_numeric_placeholders(content: &str, args: &[PromptArg]) -> PromptExpansion { let mut out = String::with_capacity(content.len()); + let mut out_elements = Vec::new(); let mut i = 0; - let mut cached_joined_args: Option = None; while let Some(off) = content[i..].find('$') { let j = i + off; out.push_str(&content[i..j]); @@ -272,8 +338,8 @@ pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { } b'1'..=b'9' => { let idx = (bytes[1] - b'1') as usize; - if let Some(val) = args.get(idx) { - out.push_str(val); + if let Some(arg) = args.get(idx) { + append_arg_with_elements(&mut out, &mut out_elements, arg); } i = j + 2; continue; @@ -283,8 +349,7 @@ pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { } if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") { if !args.is_empty() { - let joined = cached_joined_args.get_or_insert_with(|| args.join(" ")); - out.push_str(joined); + append_joined_args_with_elements(&mut out, &mut out_elements, args); } i = j + 1 + "ARGUMENTS".len(); continue; @@ -293,7 +358,191 @@ pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { i = j + 1; } out.push_str(&content[i..]); - out + PromptExpansion { + text: out, + text_elements: out_elements, + } +} + +fn parse_tokens_with_elements(rest: &str, text_elements: &[TextElement]) -> Vec { + let mut elements = text_elements.to_vec(); + elements.sort_by_key(|elem| elem.byte_range.start); + // Keep element placeholders intact across shlex splitting by replacing + // each element range with a unique sentinel token first. + let (rest_for_shlex, replacements) = replace_text_elements_with_sentinels(rest, &elements); + Shlex::new(&rest_for_shlex) + .map(|token| apply_replacements_to_token(token, &replacements)) + .collect() +} + +#[derive(Debug, Clone)] +struct ElementReplacement { + sentinel: String, + text: String, + placeholder: Option, +} + +/// Replace each text element range with a unique sentinel token. +/// +/// The sentinel is chosen so it will survive shlex tokenization as a single word. +fn replace_text_elements_with_sentinels( + rest: &str, + elements: &[TextElement], +) -> (String, Vec) { + let mut out = String::with_capacity(rest.len()); + let mut replacements = Vec::new(); + let mut cursor = 0; + + for (idx, elem) in elements.iter().enumerate() { + let start = elem.byte_range.start; + let end = elem.byte_range.end; + out.push_str(&rest[cursor..start]); + let mut sentinel = format!("__CODEX_ELEM_{idx}__"); + // Ensure we never collide with user content so a sentinel can't be mistaken for text. + while rest.contains(&sentinel) { + sentinel.push('_'); + } + out.push_str(&sentinel); + replacements.push(ElementReplacement { + sentinel, + text: rest[start..end].to_string(), + placeholder: elem.placeholder(rest).map(str::to_string), + }); + cursor = end; + } + + out.push_str(&rest[cursor..]); + (out, replacements) +} + +/// Rehydrate a shlex token by swapping sentinels back to the original text +/// and rebuilding text element ranges relative to the resulting token. +fn apply_replacements_to_token(token: String, replacements: &[ElementReplacement]) -> PromptArg { + if replacements.is_empty() { + return PromptArg { + text: token, + text_elements: Vec::new(), + }; + } + + let mut out = String::with_capacity(token.len()); + let mut out_elements = Vec::new(); + let mut cursor = 0; + + while cursor < token.len() { + let Some((offset, replacement)) = next_replacement(&token, cursor, replacements) else { + out.push_str(&token[cursor..]); + break; + }; + let start_in_token = cursor + offset; + out.push_str(&token[cursor..start_in_token]); + let start = out.len(); + out.push_str(&replacement.text); + let end = out.len(); + if start < end { + out_elements.push(TextElement::new( + ByteRange { start, end }, + replacement.placeholder.clone(), + )); + } + cursor = start_in_token + replacement.sentinel.len(); + } + + PromptArg { + text: out, + text_elements: out_elements, + } +} + +/// Find the earliest sentinel occurrence at or after `cursor`. +fn next_replacement<'a>( + token: &str, + cursor: usize, + replacements: &'a [ElementReplacement], +) -> Option<(usize, &'a ElementReplacement)> { + let slice = &token[cursor..]; + let mut best: Option<(usize, &'a ElementReplacement)> = None; + for replacement in replacements { + if let Some(pos) = slice.find(&replacement.sentinel) { + match best { + Some((best_pos, _)) if best_pos <= pos => {} + _ => best = Some((pos, replacement)), + } + } + } + best +} + +/// Shift a text element's byte range left by `offset`, returning `None` if empty. +/// +/// `offset` is the byte length of the prefix removed from the original text. +fn shift_text_element_left(elem: &TextElement, offset: usize) -> Option { + if elem.byte_range.end <= offset { + return None; + } + let start = elem.byte_range.start.saturating_sub(offset); + let end = elem.byte_range.end.saturating_sub(offset); + (start < end).then_some(elem.map_range(|_| ByteRange { start, end })) +} + +fn expand_named_placeholders_with_elements( + content: &str, + args: &HashMap, +) -> (String, Vec) { + let mut out = String::with_capacity(content.len()); + let mut out_elements = Vec::new(); + let mut cursor = 0; + for m in PROMPT_ARG_REGEX.find_iter(content) { + let start = m.start(); + let end = m.end(); + if start > 0 && content.as_bytes()[start - 1] == b'$' { + out.push_str(&content[cursor..end]); + cursor = end; + continue; + } + out.push_str(&content[cursor..start]); + cursor = end; + let key = &content[start + 1..end]; + if let Some(arg) = args.get(key) { + append_arg_with_elements(&mut out, &mut out_elements, arg); + } else { + out.push_str(&content[start..end]); + } + } + out.push_str(&content[cursor..]); + (out, out_elements) +} + +fn append_arg_with_elements( + out: &mut String, + out_elements: &mut Vec, + arg: &PromptArg, +) { + let start = out.len(); + out.push_str(&arg.text); + if arg.text_elements.is_empty() { + return; + } + out_elements.extend(arg.text_elements.iter().map(|elem| { + elem.map_range(|range| ByteRange { + start: start + range.start, + end: start + range.end, + }) + })); +} + +fn append_joined_args_with_elements( + out: &mut String, + out_elements: &mut Vec, + args: &[PromptArg], +) { + // `$ARGUMENTS` joins args with single spaces while preserving element ranges. + for (idx, arg) in args.iter().enumerate() { + if idx > 0 { + out.push(' '); + } + append_arg_with_elements(out, out_elements, arg); + } } /// Constructs a command text for a custom prompt with arguments. @@ -313,6 +562,7 @@ pub fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (Str #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn expand_arguments_basic() { @@ -324,9 +574,15 @@ mod tests { argument_hint: None, }]; - let out = - expand_custom_prompt("/prompts:my-prompt USER=Alice BRANCH=main", &prompts).unwrap(); - assert_eq!(out, Some("Review Alice changes on main".to_string())); + let out = expand_custom_prompt("/prompts:my-prompt USER=Alice BRANCH=main", &[], &prompts) + .unwrap(); + assert_eq!( + out, + Some(PromptExpansion { + text: "Review Alice changes on main".to_string(), + text_elements: Vec::new(), + }) + ); } #[test] @@ -341,10 +597,17 @@ mod tests { let out = expand_custom_prompt( "/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main", + &[], &prompts, ) .unwrap(); - assert_eq!(out, Some("Pair Alice Smith with dev-main".to_string())); + assert_eq!( + out, + Some(PromptExpansion { + text: "Pair Alice Smith with dev-main".to_string(), + text_elements: Vec::new(), + }) + ); } #[test] @@ -356,7 +619,7 @@ mod tests { description: None, argument_hint: None, }]; - let err = expand_custom_prompt("/prompts:my-prompt USER=Alice stray", &prompts) + let err = expand_custom_prompt("/prompts:my-prompt USER=Alice stray", &[], &prompts) .unwrap_err() .user_message(); assert!(err.contains("expected key=value")); @@ -371,7 +634,7 @@ mod tests { description: None, argument_hint: None, }]; - let err = expand_custom_prompt("/prompts:my-prompt USER=Alice", &prompts) + let err = expand_custom_prompt("/prompts:my-prompt USER=Alice", &[], &prompts) .unwrap_err() .user_message(); assert!(err.to_lowercase().contains("missing required args")); @@ -400,7 +663,192 @@ mod tests { argument_hint: None, }]; - let out = expand_custom_prompt("/prompts:my-prompt", &prompts).unwrap(); - assert_eq!(out, Some("literal $$USER".to_string())); + let out = expand_custom_prompt("/prompts:my-prompt", &[], &prompts).unwrap(); + assert_eq!( + out, + Some(PromptExpansion { + text: "literal $$USER".to_string(), + text_elements: Vec::new(), + }) + ); + } + + #[test] + fn positional_args_treat_placeholder_with_spaces_as_single_token() { + let placeholder = "[Image #1]"; + let rest = format!("alpha {placeholder} beta"); + let start = rest.find(placeholder).expect("placeholder"); + let end = start + placeholder.len(); + let text_elements = vec![TextElement::new( + ByteRange { start, end }, + Some(placeholder.to_string()), + )]; + + let args = parse_positional_args(&rest, &text_elements); + assert_eq!( + args, + vec![ + PromptArg { + text: "alpha".to_string(), + text_elements: Vec::new(), + }, + PromptArg { + text: placeholder.to_string(), + text_elements: vec![TextElement::new( + ByteRange { + start: 0, + end: placeholder.len(), + }, + Some(placeholder.to_string()), + )], + }, + PromptArg { + text: "beta".to_string(), + text_elements: Vec::new(), + } + ] + ); + } + + #[test] + fn extract_positional_args_shifts_element_offsets_into_args_str() { + let placeholder = "[Image #1]"; + let line = format!(" /{PROMPTS_CMD_PREFIX}:my-prompt alpha {placeholder} beta "); + let start = line.find(placeholder).expect("placeholder"); + let end = start + placeholder.len(); + let text_elements = vec![TextElement::new( + ByteRange { start, end }, + Some(placeholder.to_string()), + )]; + + let args = extract_positional_args_for_prompt_line(&line, "my-prompt", &text_elements); + assert_eq!( + args, + vec![ + PromptArg { + text: "alpha".to_string(), + text_elements: Vec::new(), + }, + PromptArg { + text: placeholder.to_string(), + text_elements: vec![TextElement::new( + ByteRange { + start: 0, + end: placeholder.len(), + }, + Some(placeholder.to_string()), + )], + }, + PromptArg { + text: "beta".to_string(), + text_elements: Vec::new(), + } + ] + ); + } + + #[test] + fn key_value_args_treat_placeholder_with_spaces_as_single_token() { + let placeholder = "[Image #1]"; + let rest = format!("IMG={placeholder} NOTE=hello"); + let start = rest.find(placeholder).expect("placeholder"); + let end = start + placeholder.len(); + let text_elements = vec![TextElement::new( + ByteRange { start, end }, + Some(placeholder.to_string()), + )]; + + let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs"); + assert_eq!( + args.get("IMG"), + Some(&PromptArg { + text: placeholder.to_string(), + text_elements: vec![TextElement::new( + ByteRange { + start: 0, + end: placeholder.len(), + }, + Some(placeholder.to_string()), + )], + }) + ); + assert_eq!( + args.get("NOTE"), + Some(&PromptArg { + text: "hello".to_string(), + text_elements: Vec::new(), + }) + ); + } + + #[test] + fn positional_args_allow_placeholder_inside_quotes() { + let placeholder = "[Image #1]"; + let rest = format!("alpha \"see {placeholder} here\" beta"); + let start = rest.find(placeholder).expect("placeholder"); + let end = start + placeholder.len(); + let text_elements = vec![TextElement::new( + ByteRange { start, end }, + Some(placeholder.to_string()), + )]; + + let args = parse_positional_args(&rest, &text_elements); + assert_eq!( + args, + vec![ + PromptArg { + text: "alpha".to_string(), + text_elements: Vec::new(), + }, + PromptArg { + text: format!("see {placeholder} here"), + text_elements: vec![TextElement::new( + ByteRange { + start: "see ".len(), + end: "see ".len() + placeholder.len(), + }, + Some(placeholder.to_string()), + )], + }, + PromptArg { + text: "beta".to_string(), + text_elements: Vec::new(), + } + ] + ); + } + + #[test] + fn key_value_args_allow_placeholder_inside_quotes() { + let placeholder = "[Image #1]"; + let rest = format!("IMG=\"see {placeholder} here\" NOTE=ok"); + let start = rest.find(placeholder).expect("placeholder"); + let end = start + placeholder.len(); + let text_elements = vec![TextElement::new( + ByteRange { start, end }, + Some(placeholder.to_string()), + )]; + + let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs"); + assert_eq!( + args.get("IMG"), + Some(&PromptArg { + text: format!("see {placeholder} here"), + text_elements: vec![TextElement::new( + ByteRange { + start: "see ".len(), + end: "see ".len() + placeholder.len(), + }, + Some(placeholder.to_string()), + )], + }) + ); + assert_eq!( + args.get("NOTE"), + Some(&PromptArg { + text: "ok".to_string(), + text_elements: Vec::new(), + }) + ); } } diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 23c6195aca..9888128baa 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -33,6 +33,7 @@ use codex_backend_client::Client as BackendClient; use codex_core::config::Config; use codex_core::config::ConstraintResult; use codex_core::config::types::Notifications; +use codex_core::features::FEATURES; use codex_core::features::Feature; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; @@ -121,10 +122,12 @@ use crate::app_event::WindowsSandboxEnableMode; use crate::app_event::WindowsSandboxFallbackReason; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; +use crate::bottom_pane::BetaFeatureItem; use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED; +use crate::bottom_pane::ExperimentalFeaturesView; use crate::bottom_pane::InputResult; use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT; @@ -159,6 +162,7 @@ use crate::slash_command::SlashCommand; use crate::status::RateLimitSnapshotDisplay; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; +use crate::ui_consts::DEFAULT_MODEL_DISPLAY_NAME; mod interrupts; use self::interrupts::InterruptManager; mod agent; @@ -210,7 +214,6 @@ impl UnifiedExecWaitState { const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; const NUDGE_MODEL_SLUG: &str = "gpt-5.1-codex-mini"; const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; -const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading"; #[derive(Default)] struct RateLimitWarningState { @@ -647,6 +650,8 @@ impl ChatWidget { &model_for_header, event, self.show_welcome_banner, + self.collaboration_modes_enabled(), + self.stored_collaboration_mode.clone(), ); self.apply_session_info_cell(session_info_cell); @@ -1607,15 +1612,7 @@ impl ChatWidget { let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager); - let model_for_header = model - .clone() - .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); - let active_cell = if model.is_none() { - Some(Self::placeholder_session_header_cell(&config)) - } else { - None - }; - + let model_for_header = model.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { CollaborationMode::Custom(Settings { @@ -1631,6 +1628,11 @@ impl ChatWidget { developer_instructions: None, }) }; + let active_cell = Some(Self::placeholder_session_header_cell( + &config, + config.features.enabled(Feature::CollaborationModes), + stored_collaboration_mode.clone(), + )); let mut widget = Self { app_event_tx: app_event_tx.clone(), @@ -2059,6 +2061,9 @@ impl ChatWidget { // Not supported; on non-Windows this command should never be reachable. }; } + SlashCommand::Experimental => { + self.open_experimental_popup(); + } SlashCommand::Quit | SlashCommand::Exit => { self.request_quit_without_confirmation(); } @@ -3338,6 +3343,25 @@ impl ChatWidget { }); } + pub(crate) fn open_experimental_popup(&mut self) { + let features: Vec = FEATURES + .iter() + .filter_map(|spec| { + let name = spec.stage.beta_menu_name()?; + let description = spec.stage.beta_menu_description()?; + Some(BetaFeatureItem { + feature: spec.id, + name: name.to_string(), + description: description.to_string(), + enabled: self.config.features.enabled(spec.id), + }) + }) + .collect(); + + let view = ExperimentalFeaturesView::new(features, self.app_event_tx.clone()); + self.bottom_pane.show_view(Box::new(view)); + } + fn approval_preset_actions( approval: AskForApproval, sandbox: SandboxPolicy, @@ -4026,7 +4050,11 @@ impl ChatWidget { } /// Build a placeholder header cell while the session is configuring. - fn placeholder_session_header_cell(config: &Config) -> Box { + fn placeholder_session_header_cell( + config: &Config, + is_collaboration: bool, + collaboration_mode: CollaborationMode, + ) -> Box { let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC); Box::new(history_cell::SessionHeaderHistoryCell::new_with_style( DEFAULT_MODEL_DISPLAY_NAME.to_string(), @@ -4034,6 +4062,8 @@ impl ChatWidget { None, config.cwd.clone(), CODEX_CLI_VERSION, + is_collaboration, + collaboration_mode, )) } diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_selection_popup.snap index 190f9c2933..27479f97f0 100644 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_selection_popup.snap +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_selection_popup.snap @@ -6,11 +6,11 @@ expression: popup Access legacy models by running codex -m or in your config.toml › 1. gpt-5.2-codex (default) Latest frontier agentic coding model. - 2. gpt-5.1-codex-max Codex-optimized flagship for deep and fast - reasoning. - 3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less - capable. - 4. gpt-5.2 Latest frontier model with improvements across + 2. gpt-5.2 Latest frontier model with improvements across knowledge, reasoning and coding + 3. gpt-5.1-codex-max Codex-optimized flagship for deep and fast + reasoning. + 4. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less + capable. Press enter to select reasoning effort, or esc to dismiss. diff --git a/codex-rs/tui2/src/history_cell.rs b/codex-rs/tui2/src/history_cell.rs index a313f9b2a7..a4dc77a257 100644 --- a/codex-rs/tui2/src/history_cell.rs +++ b/codex-rs/tui2/src/history_cell.rs @@ -19,6 +19,7 @@ use crate::exec_cell::output_lines; use crate::exec_cell::spinner; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; +use crate::key_hint; use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; @@ -27,6 +28,7 @@ use crate::style::user_message_style; use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; use crate::tooltips; +use crate::ui_consts::DEFAULT_MODEL_DISPLAY_NAME; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; @@ -40,11 +42,13 @@ use codex_core::protocol::FileChange; use codex_core::protocol::McpAuthStatus; use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::user_input::TextElement; +use crossterm::event::KeyCode; use image::DynamicImage; use image::ImageReader; use mcp_types::EmbeddedResourceResource; @@ -971,6 +975,8 @@ pub(crate) fn new_session_info( requested_model: &str, event: SessionConfiguredEvent, is_first_event: bool, + is_collaboration: bool, + collaboration_mode: CollaborationMode, ) -> SessionInfoCell { let SessionConfiguredEvent { model, @@ -984,6 +990,8 @@ pub(crate) fn new_session_info( reasoning_effort, config.cwd.clone(), CODEX_CLI_VERSION, + is_collaboration, + collaboration_mode, ); let mut parts: Vec> = vec![Box::new(header)]; @@ -1060,6 +1068,8 @@ pub(crate) struct SessionHeaderHistoryCell { model_style: Style, reasoning_effort: Option, directory: PathBuf, + is_collaboration: bool, + collaboration_mode: CollaborationMode, } impl SessionHeaderHistoryCell { @@ -1069,8 +1079,18 @@ impl SessionHeaderHistoryCell { reasoning_effort: Option, directory: PathBuf, version: &'static str, + is_collaboration: bool, + collaboration_mode: CollaborationMode, ) -> Self { - Self::new_with_style(model, model_style, reasoning_effort, directory, version) + Self::new_with_style( + model, + model_style, + reasoning_effort, + directory, + version, + is_collaboration, + collaboration_mode, + ) } pub(crate) fn new_with_style( @@ -1079,6 +1099,8 @@ impl SessionHeaderHistoryCell { reasoning_effort: Option, directory: PathBuf, version: &'static str, + is_collaboration: bool, + collaboration_mode: CollaborationMode, ) -> Self { Self { version, @@ -1086,6 +1108,20 @@ impl SessionHeaderHistoryCell { model_style, reasoning_effort, directory, + is_collaboration, + collaboration_mode, + } + } + + fn collaboration_mode_label(&self) -> Option<&'static str> { + if !self.is_collaboration { + return None; + } + match &self.collaboration_mode { + CollaborationMode::Plan(_) => Some("Plan"), + CollaborationMode::PairProgramming(_) => Some("Pair Programming"), + CollaborationMode::Execute(_) => Some("Execute"), + CollaborationMode::Custom(_) => None, } } @@ -1146,25 +1182,48 @@ impl HistoryCell for SessionHeaderHistoryCell { const CHANGE_MODEL_HINT_COMMAND: &str = "/model"; const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change"; + const CHANGE_MODE_HINT_EXPLANATION: &str = " to change mode"; const DIR_LABEL: &str = "directory:"; let label_width = DIR_LABEL.len(); - let model_label = format!( - "{model_label:> = vec![ - Span::from(format!("{model_label} ")).dim(), - Span::styled(self.model.clone(), self.model_style), - ]; - if let Some(reasoning) = reasoning_label { - model_spans.push(Span::from(" ")); - model_spans.push(Span::from(reasoning)); - } - model_spans.push(" ".dim()); - model_spans.push(CHANGE_MODEL_HINT_COMMAND.cyan()); - model_spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim()); + let model_spans: Vec> = if self.is_collaboration { + let collab_label = format!( + "{collab_label: = key_hint::shift(KeyCode::Tab).into(); + spans.push(shift_tab_span.cyan()); + spans.push(CHANGE_MODE_HINT_EXPLANATION.dim()); + spans + } else { + let model_label = format!( + "{model_label: CollaborationMode { + CollaborationMode::Custom(Settings { + model: model.to_string(), + reasoning_effort: None, + developer_instructions: None, + }) + } + + fn plan_collaboration_mode(model: &str) -> CollaborationMode { + CollaborationMode::Plan(Settings { + model: model.to_string(), + reasoning_effort: None, + developer_instructions: None, + }) + } + fn render_lines(lines: &[Line<'static>]) -> Vec { lines .iter() @@ -2420,6 +2498,8 @@ mod tests { Some(ReasoningEffortConfig::High), std::env::temp_dir(), "test", + false, + default_collaboration_mode("gpt-4o"), ); let lines = render_lines(&cell.display_lines(80)); @@ -2432,6 +2512,51 @@ mod tests { assert!(model_line.contains("/model to change")); } + #[test] + fn session_header_collaboration_mode_includes_label_and_hint() { + let cell = SessionHeaderHistoryCell::new( + "gpt-4o".to_string(), + Style::default(), + None, + std::env::temp_dir(), + "test", + true, + plan_collaboration_mode("gpt-4o"), + ); + + let lines = render_lines(&cell.display_lines(80)); + let mode_line = lines + .into_iter() + .find(|line| line.contains("mode:")) + .expect("mode line"); + + assert!(mode_line.contains("Plan")); + assert!(mode_line.contains("shift + tab")); + assert!(mode_line.contains("to change mode")); + } + + #[test] + fn session_header_collaboration_mode_uses_placeholder_when_loading() { + let cell = SessionHeaderHistoryCell::new( + DEFAULT_MODEL_DISPLAY_NAME.to_string(), + Style::default(), + None, + std::env::temp_dir(), + "test", + true, + plan_collaboration_mode("gpt-4o"), + ); + + let lines = render_lines(&cell.display_lines(80)); + let mode_line = lines + .into_iter() + .find(|line| line.contains("mode:")) + .expect("mode line"); + + assert!(mode_line.contains(DEFAULT_MODEL_DISPLAY_NAME)); + assert!(!mode_line.contains("Plan")); + } + #[test] fn session_header_directory_center_truncates() { let mut dir = home_dir().expect("home directory"); diff --git a/codex-rs/tui2/src/slash_command.rs b/codex-rs/tui2/src/slash_command.rs index 2e1e032983..15e85734bb 100644 --- a/codex-rs/tui2/src/slash_command.rs +++ b/codex-rs/tui2/src/slash_command.rs @@ -16,6 +16,7 @@ pub enum SlashCommand { Approvals, #[strum(serialize = "setup-elevated-sandbox")] ElevateSandbox, + Experimental, Skills, Review, New, @@ -58,6 +59,7 @@ impl SlashCommand { SlashCommand::Collab => "change collaboration mode (experimental)", SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::ElevateSandbox => "set up elevated agent sandbox", + SlashCommand::Experimental => "toggle beta features", SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Logout => "log out of Codex", SlashCommand::Rollout => "print the rollout file path", @@ -83,6 +85,7 @@ impl SlashCommand { | SlashCommand::Model | SlashCommand::Approvals | SlashCommand::ElevateSandbox + | SlashCommand::Experimental | SlashCommand::Review | SlashCommand::Logout => false, SlashCommand::Diff diff --git a/codex-rs/tui2/src/ui_consts.rs b/codex-rs/tui2/src/ui_consts.rs index ebbe4ce366..a24c6d5af9 100644 --- a/codex-rs/tui2/src/ui_consts.rs +++ b/codex-rs/tui2/src/ui_consts.rs @@ -9,3 +9,4 @@ /// - User history lines account for this many columns (e.g., "▌ ") when wrapping. pub(crate) const LIVE_PREFIX_COLS: u16 = 2; pub(crate) const FOOTER_INDENT_COLS: usize = LIVE_PREFIX_COLS as usize; +pub(crate) const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading"; diff --git a/docs/tui-chat-composer.md b/docs/tui-chat-composer.md index bda9b7ff7c..7b1f0fed33 100644 --- a/docs/tui-chat-composer.md +++ b/docs/tui-chat-composer.md @@ -51,6 +51,34 @@ The solution is to detect paste-like _bursts_ and buffer them into a single expl - After handling the key, `sync_popups()` runs so popup visibility/filters stay consistent with the latest text + cursor. +## Submission flow (Enter/Tab) + +There are multiple submission paths, but they share the same core rules: + +### Normal submit/queue path + +`handle_submission` calls `prepare_submission_text` for both submit and queue. That method: + +1. Expands any pending paste placeholders so element ranges align with the final text. +2. Trims whitespace and rebases element ranges to the trimmed buffer. +3. Expands `/prompts:` custom prompts: + - Named args use key=value parsing. + - Numeric args use positional parsing for `$1..$9` and `$ARGUMENTS`. + The expansion preserves text elements and yields the final submission payload. +4. Prunes attachments so only placeholders that survive expansion are sent. +5. Clears pending pastes on success and suppresses submission if the final text is empty and there + are no attachments. + +### Numeric auto-submit path + +When the slash popup is open and the first line matches a numeric-only custom prompt with +positional args, Enter auto-submits without calling `prepare_submission_text`. That path still: + +- Expands pending pastes before parsing positional args. +- Uses expanded text elements for prompt expansion. +- Prunes attachments based on expanded placeholders. +- Clears pending pastes after a successful auto-submit. + ## Paste burst: concepts and assumptions The burst detector is intentionally conservative: it only processes “plain” character input diff --git a/docs/tui-request-user-input.md b/docs/tui-request-user-input.md new file mode 100644 index 0000000000..f8eb7aeb73 --- /dev/null +++ b/docs/tui-request-user-input.md @@ -0,0 +1,39 @@ +# Request user input overlay (TUI) + +This note documents the TUI overlay used to gather answers for +`RequestUserInputEvent`. + +## Overview + +The overlay renders one question at a time and collects: + +- A single selected option (when options exist). +- Freeform notes (always available). + +When options are present, notes are stored per selected option and the first +option is selected by default, so every option question has an answer. If a +question has no options and no notes are provided, the answer is submitted as +`skipped`. + +## Focus and input routing + +The overlay tracks a small focus state: + +- **Options**: Up/Down move the selection and Space selects. +- **Notes**: Text input edits notes for the currently selected option. + +Typing while focused on options switches into notes automatically to reduce +friction for freeform input. + +## Navigation + +- Enter advances to the next question. +- Enter on the last question submits all answers. +- PageUp/PageDown navigate across questions (when multiple are present). +- Esc interrupts the run. + +## Layout priorities + +The layout prefers to keep the question and all options visible. Notes and +footer hints collapse as space shrinks, with notes falling back to a single-line +"Notes: ..." input in tight terminals.