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-cli/bin/codex.js b/codex-cli/bin/codex.js old mode 100644 new mode 100755 diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7c5b5a8634..3a81d2b7d6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1888,7 +1888,9 @@ dependencies = [ name = "codex-utils-absolute-path" version = "0.0.0" dependencies = [ + "dirs", "path-absolutize", + "pretty_assertions", "schemars 0.8.22", "serde", "serde_json", @@ -3455,7 +3457,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.2", ] [[package]] @@ -3505,7 +3507,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -5342,7 +5344,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.1", + "socket2 0.5.10", "thiserror 2.0.17", "tokio", "tracing", @@ -5379,7 +5381,7 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -5664,7 +5666,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", + "webpki-roots 1.0.2", ] [[package]] @@ -7147,14 +7149,18 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +version = "0.28.0" +source = "git+https://github.com/JakkuSakura/tokio-tungstenite?rev=2ae536b0de793f3ddf31fc2f22d445bf1ef2023d#2ae536b0de793f3ddf31fc2f22d445bf1ef2023d" dependencies = [ "futures-util", "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite", + "webpki-roots 0.26.11", ] [[package]] @@ -7553,20 +7559,19 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +version = "0.28.0" +source = "git+https://github.com/JakkuSakura/tungstenite-rs?rev=f514de8644821113e5d18a027d6d28a5c8cc0a6e#f514de8644821113e5d18a027d6d28a5c8cc0a6e" dependencies = [ - "byteorder", "bytes", "data-encoding", "http 1.3.1", "httparse", "log", - "rand 0.8.5", + "rand 0.9.2", + "rustls", + "rustls-pki-types", "sha1", - "thiserror 1.0.69", - "url", + "thiserror 2.0.17", "utf-8", ] @@ -8037,6 +8042,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.2", +] + [[package]] name = "webpki-roots" version = "1.0.2" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index a74fb1e828..96df92b81e 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -182,7 +182,7 @@ ratatui-core = "0.1.0" ratatui-macros = "0.6.0" regex = "1.12.2" regex-lite = "0.1.8" -reqwest = "0.12" +reqwest = { version = "0.12", features = ["rustls-tls-webpki-roots", "rustls-tls-native-roots"] } rmcp = { version = "0.12.0", default-features = false } schemars = "0.8.22" seccompiler = "0.5.0" @@ -212,7 +212,7 @@ tiny_http = "0.12" tokio = "1" tokio-stream = "0.1.18" tokio-test = "0.4" -tokio-tungstenite = "0.21.0" +tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots", "rustls-tls-native-roots", "proxy"] } tokio-util = "0.7.18" toml = "0.9.5" toml_edit = "0.24.0" @@ -303,6 +303,10 @@ opt-level = 0 # ratatui = { path = "../../ratatui" } crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" } ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } +tokio-tungstenite = { git = "https://github.com/JakkuSakura/tokio-tungstenite", rev = "2ae536b0de793f3ddf31fc2f22d445bf1ef2023d" } # Uncomment to debug local changes. # rmcp = { path = "../../rust-sdk/crates/rmcp" } + +[patch."ssh://git@github.com/JakkuSakura/tungstenite-rs.git"] +tungstenite = { git = "https://github.com/JakkuSakura/tungstenite-rs", rev = "f514de8644821113e5d18a027d6d28a5c8cc0a6e" } diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 39090c022f..488862b842 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -503,17 +503,14 @@ impl From for V1TextElement { fn from(value: CoreTextElement) -> Self { Self { byte_range: value.byte_range.into(), - placeholder: value.placeholder, + placeholder: value._placeholder_for_conversion_only().map(str::to_string), } } } impl From for CoreTextElement { fn from(value: V1TextElement) -> Self { - Self { - byte_range: value.byte_range.into(), - placeholder: value.placeholder, - } + Self::new(value.byte_range.into(), value.placeholder) } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 563a78cc6e..0635b530e8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1665,24 +1665,38 @@ pub struct TextElement { /// Byte range in the parent `text` buffer that this element occupies. pub byte_range: ByteRange, /// Optional human-readable placeholder for the element, displayed in the UI. - pub placeholder: Option, + placeholder: Option, +} + +impl TextElement { + pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { + Self { + byte_range, + placeholder, + } + } + + pub fn set_placeholder(&mut self, placeholder: Option) { + self.placeholder = placeholder; + } + + pub fn placeholder(&self) -> Option<&str> { + self.placeholder.as_deref() + } } impl From for TextElement { fn from(value: CoreTextElement) -> Self { - Self { - byte_range: value.byte_range.into(), - placeholder: value.placeholder, - } + Self::new( + value.byte_range.into(), + value._placeholder_for_conversion_only().map(str::to_string), + ) } } impl From for CoreTextElement { fn from(value: TextElement) -> Self { - Self { - byte_range: value.byte_range.into(), - placeholder: value.placeholder, - } + Self::new(value.byte_range.into(), value.placeholder) } } 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 be2971397d..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(()) } @@ -74,10 +76,10 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { create_config_toml(codex_home.path(), &server.uri())?; let preview = "Saved user message"; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 5 }, - placeholder: Some("".into()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 5 }, + Some("".into()), + )]; let conversation_id = create_fake_rollout_with_text_elements( codex_home.path(), "2025-01-05T12-00-00", @@ -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/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 29a7d6b936..c465444709 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -142,10 +142,10 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { .await??; let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 5 }, - placeholder: Some("".to_string()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 5 }, + Some("".to_string()), + )]; let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), diff --git a/codex-rs/backend-client/Cargo.toml b/codex-rs/backend-client/Cargo.toml index ec5546a670..b369953495 100644 --- a/codex-rs/backend-client/Cargo.toml +++ b/codex-rs/backend-client/Cargo.toml @@ -12,7 +12,7 @@ path = "src/lib.rs" anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "rustls-tls-native-roots"] } codex-backend-openapi-models = { path = "../codex-backend-openapi-models" } codex-protocol = { workspace = true } codex-core = { workspace = true } 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 39e2f2fd05..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(); @@ -150,7 +152,7 @@ async fn connect_websocket( turn_state: Option>>, ) -> Result<(WsStream, bool), ApiError> { let mut request = url - .clone() + .as_str() .into_client_request() .map_err(|err| ApiError::Stream(format!("failed to build websocket request: {err}")))?; request.headers_mut().extend(headers); @@ -209,7 +211,7 @@ async fn run_websocket_response_stream( } }; - if let Err(err) = ws_stream.send(Message::Text(request_text)).await { + if let Err(err) = ws_stream.send(Message::Text(request_text.into())).await { return Err(ApiError::Stream(format!( "failed to send websocket request: {err}" ))); 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/BUILD.bazel b/codex-rs/core/BUILD.bazel index 37a3173f9a..f1649b36c7 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -18,6 +18,7 @@ codex_rust_crate( ), integration_compile_data_extra = [ "//codex-rs/apply-patch:apply_patch_tool_instructions.md", + "models.json", "prompt.md", ], test_data_extra = [ diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index bfb54821fc..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,22 @@ "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": [ + { + "$ref": "#/definitions/Personality" + } + ] + }, "model_provider": { "description": "Provider to use from the model_providers map.", "type": "string" @@ -359,12 +378,12 @@ }, "shell_environment_policy": { "default": { - "inherit": null, - "ignore_default_excludes": null, "exclude": null, - "set": null, + "experimental_use_profile": null, + "ignore_default_excludes": null, "include_only": null, - "experimental_use_profile": null + "inherit": null, + "set": null }, "allOf": [ { @@ -425,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": [ @@ -537,9 +568,6 @@ "experimental_compact_prompt_file": { "$ref": "#/definitions/AbsolutePathBuf" }, - "experimental_instructions_file": { - "$ref": "#/definitions/AbsolutePathBuf" - }, "experimental_use_freeform_apply_patch": { "type": "boolean" }, @@ -596,6 +624,9 @@ "remote_models": { "type": "boolean" }, + "responses_websockets": { + "type": "boolean" + }, "shell_snapshot": { "type": "boolean" }, @@ -632,6 +663,17 @@ "model": { "type": "string" }, + "model_instructions_file": { + "description": "Optional path to a file containing model instructions.", + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ] + }, + "model_personality": { + "$ref": "#/definitions/Personality" + }, "model_provider": { "description": "The key in the `model_providers` map identifying the [`ModelProviderInfo`] to use.", "type": "string" @@ -1051,6 +1093,13 @@ }, "additionalProperties": false }, + "Personality": { + "type": "string", + "enum": [ + "friendly", + "pragmatic" + ] + }, "ProjectConfig": { "type": "object", "properties": { diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 9f10a6c721..d4566cf5a9 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; @@ -14,18 +15,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 if defined. @@ -36,14 +44,14 @@ impl AgentControl { session_source_override: Option, ) -> CodexResult { let state = self.upgrade()?; - let new_thread = state - .start_thread( - config, - InitialHistory::New, - self.clone(), - session_source_override, - ) - .await?; + let reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; + + // The same `AgentControl` is sent to spawn the thread. + let new_thread = state.start_thread(config, + InitialHistory::New, + self.clone(), + session_source_override,).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. @@ -91,6 +99,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 } @@ -106,6 +115,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 } @@ -156,17 +166,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, @@ -397,4 +415,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/client_common.rs b/codex-rs/core/src/client_common.rs index 2abe4883e7..2614ce83ef 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -1,4 +1,5 @@ use crate::client_common::tools::ToolSpec; +use crate::config::types::Personality; use crate::error::Result; pub use codex_api::common::ResponseEvent; use codex_protocol::models::BaseInstructions; @@ -35,6 +36,9 @@ pub struct Prompt { pub base_instructions: BaseInstructions, + /// Optionally specify the personality of the model. + pub personality: Option, + /// Optional the output schema for the model's response. pub output_schema: Option, } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1ded6ec62a..d99593aef2 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; @@ -1518,6 +1531,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 +2576,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), &[], ); @@ -2791,10 +2809,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 })) @@ -2971,6 +2990,7 @@ async fn run_sampling_request( tools: router.specs(), parallel_tool_calls: model_supports_parallel, base_instructions, + personality: None, output_schema: turn_context.final_output_json_schema.clone(), }; @@ -4334,6 +4354,8 @@ mod tests { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + // Interrupts persist a model-visible `` marker into history, but there is no + // separate client-visible event for that marker (only `EventMsg::TurnAborted`). let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) .await .expect("timeout waiting for event") @@ -4342,6 +4364,7 @@ mod tests { EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), other => panic!("unexpected event: {other:?}"), } + // No extra events should be emitted after an abort. assert!(rx.try_recv().is_err()); } @@ -4364,11 +4387,17 @@ mod tests { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - let evt = rx.recv().await.expect("event"); + // Even if tasks handle cancellation gracefully, interrupts still result in `TurnAborted` + // being the only client-visible signal. + let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for event") + .expect("event"); match evt.msg { EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), other => panic!("unexpected event: {other:?}"), } + // No extra events should be emitted after an abort. assert!(rx.try_recv().is_err()); } @@ -4390,42 +4419,67 @@ mod tests { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - // Drain events until we observe ExitedReviewMode; earlier - // RawResponseItem entries (e.g., environment context) may arrive first. - loop { - let evt = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) + // Aborting a review task should exit review mode before surfacing the abort to the client. + // We scan for these events (rather than relying on fixed ordering) since unrelated events + // may interleave. + let mut exited_review_mode_idx = None; + let mut turn_aborted_idx = None; + let mut idx = 0usize; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(3); + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let evt = tokio::time::timeout(remaining, rx.recv()) .await - .expect("timeout waiting for first event") - .expect("first event"); + .expect("timeout waiting for event") + .expect("event"); + let event_idx = idx; + idx = idx.saturating_add(1); match evt.msg { EventMsg::ExitedReviewMode(ev) => { assert!(ev.review_output.is_none()); + exited_review_mode_idx = Some(event_idx); + } + EventMsg::TurnAborted(ev) => { + assert_eq!(TurnAbortReason::Interrupted, ev.reason); + turn_aborted_idx = Some(event_idx); break; } - // Ignore any non-critical events before exit. - _ => continue, - } - } - loop { - let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("timeout waiting for next event") - .expect("event"); - match evt.msg { - EventMsg::RawResponseItem(_) => continue, - EventMsg::ItemStarted(_) | EventMsg::ItemCompleted(_) => continue, - EventMsg::AgentMessage(_) => continue, - EventMsg::TurnAborted(e) => { - assert_eq!(TurnAbortReason::Interrupted, e.reason); - break; - } - other => panic!("unexpected second event: {other:?}"), + _ => {} } } + assert!( + exited_review_mode_idx.is_some(), + "expected ExitedReviewMode after abort" + ); + assert!( + turn_aborted_idx.is_some(), + "expected TurnAborted after abort" + ); + assert!( + exited_review_mode_idx.unwrap() < turn_aborted_idx.unwrap(), + "expected ExitedReviewMode before TurnAborted" + ); - // TODO(jif) investigate what is this? let history = sess.clone_history().await; - let _ = history.raw_items(); + // The `` marker is silent in the event stream, so verify it is still + // recorded in history for the model. + assert!( + history.raw_items().iter().any(|item| { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "user" { + return false; + } + content.iter().any(|content_item| { + let ContentItem::InputText { text } = content_item else { + return false; + }; + text.contains(crate::session_prefix::TURN_ABORTED_OPEN_TAG) + }) + }), + "expected a model-visible turn aborted marker in history after interrupt" + ); } #[tokio::test] 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/compact.rs b/codex-rs/core/src/compact.rs index bde807154d..970eb0054d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -15,6 +15,7 @@ use crate::protocol::EventMsg; use crate::protocol::TurnContextItem; use crate::protocol::TurnStartedEvent; use crate::protocol::WarningEvent; +use crate::session_prefix::TURN_ABORTED_OPEN_TAG; use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::truncate_text; @@ -223,11 +224,31 @@ pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec { Some(user.message()) } } - _ => None, + _ => collect_turn_aborted_marker(item), }) .collect() } +fn collect_turn_aborted_marker(item: &ResponseItem) -> Option { + let ResponseItem::Message { role, content, .. } = item else { + return None; + }; + if role != "user" { + return None; + } + + let text = content_items_to_text(content)?; + if text + .trim_start() + .to_ascii_lowercase() + .starts_with(TURN_ABORTED_OPEN_TAG) + { + Some(text) + } else { + None + } +} + pub(crate) fn is_summary_message(message: &str) -> bool { message.starts_with(format!("{SUMMARY_PREFIX}\n").as_str()) } @@ -337,6 +358,7 @@ async fn drain_to_completed( mod tests { use super::*; + use crate::session_prefix::TURN_ABORTED_OPEN_TAG; use pretty_assertions::assert_eq; #[test] @@ -489,4 +511,41 @@ mod tests { }; assert_eq!(summary, summary_text); } + + #[test] + fn build_compacted_history_preserves_turn_aborted_markers() { + let marker = format!( + "{TURN_ABORTED_OPEN_TAG}\n turn-1\n interrupted\n" + ); + let items = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: marker.clone(), + }], + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "real user message".to_string(), + }], + }, + ]; + + let user_messages = collect_user_messages(&items); + let history = build_compacted_history(Vec::new(), &user_messages, "SUMMARY"); + + let found_marker = history.iter().any(|item| match item { + ResponseItem::Message { role, content, .. } if role == "user" => { + content_items_to_text(content).is_some_and(|text| text == marker) + } + _ => false, + }); + assert!( + found_marker, + "expected compacted history to retain marker" + ); + } } diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index b8dae3ceab..ff5ca53010 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -55,6 +55,7 @@ async fn run_remote_compact_task_inner_impl( tools: vec![], parallel_tool_calls: false, base_instructions: sess.get_base_instructions().await, + personality: None, output_schema: None, }; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b4e6c0c8bf..1e409559e6 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -11,6 +11,7 @@ use crate::config::types::Notifications; use crate::config::types::OtelConfig; use crate::config::types::OtelConfigToml; use crate::config::types::OtelExporterKind; +use crate::config::types::Personality; use crate::config::types::SandboxWorkspaceWrite; use crate::config::types::ScrollInputMode; use crate::config::types::ShellEnvironmentPolicy; @@ -88,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"; @@ -127,6 +129,9 @@ pub struct Config { /// Info needed to make an API request to the model. pub model_provider: ModelProviderInfo, + /// Optionally specify the personality of the model + pub model_personality: Option, + /// Approval policy for executing commands. pub approval_policy: Constrained, @@ -295,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, @@ -395,6 +403,7 @@ pub struct ConfigBuilder { cli_overrides: Option>, harness_overrides: Option, loader_overrides: Option, + fallback_cwd: Option, } impl ConfigBuilder { @@ -418,21 +427,29 @@ impl ConfigBuilder { self } + pub fn fallback_cwd(mut self, fallback_cwd: Option) -> Self { + self.fallback_cwd = fallback_cwd; + self + } + pub async fn build(self) -> std::io::Result { let Self { codex_home, cli_overrides, harness_overrides, loader_overrides, + fallback_cwd, } = self; let codex_home = codex_home.map_or_else(find_codex_home, std::io::Result::Ok)?; let cli_overrides = cli_overrides.unwrap_or_default(); - let harness_overrides = harness_overrides.unwrap_or_default(); + let mut harness_overrides = harness_overrides.unwrap_or_default(); let loader_overrides = loader_overrides.unwrap_or_default(); - let cwd = match harness_overrides.cwd.as_deref() { + let cwd_override = harness_overrides.cwd.as_deref().or(fallback_cwd.as_deref()); + let cwd = match cwd_override { Some(path) => AbsolutePathBuf::try_from(path)?, None => AbsolutePathBuf::current_dir()?, }; + harness_overrides.cwd = Some(cwd.to_path_buf()); let config_layer_stack = load_config_layers_state(&codex_home, Some(cwd), &cli_overrides, loader_overrides) .await?; @@ -807,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, @@ -890,6 +913,10 @@ pub struct ConfigToml { /// Override to force-enable reasoning summaries for the configured model. pub model_supports_reasoning_summaries: Option, + /// EXPERIMENTAL + /// Optionally specify a personality for the model + pub model_personality: Option, + /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: Option, @@ -901,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, @@ -948,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, @@ -1008,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 { @@ -1338,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); @@ -1361,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() @@ -1415,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); @@ -1474,6 +1531,7 @@ impl Config { notify: cfg.notify, user_instructions, base_instructions, + model_personality: config_profile.model_personality.or(cfg.model_personality), developer_instructions, compact_prompt, // The config.toml omits "_mode" because it's a config file. However, "_mode" @@ -1500,6 +1558,7 @@ impl Config { }) .collect(), tool_output_token_limit: cfg.tool_output_token_limit, + agent_max_threads, codex_home, config_layer_stack, history, @@ -1664,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`. @@ -2292,6 +2375,52 @@ trust_level = "trusted" Ok(()) } + #[tokio::test] + async fn project_profile_overrides_user_profile() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#" +profile = "global" + +[profiles.global] +model = "gpt-global" + +[profiles.project] +model = "gpt-project" + +[projects."{workspace_key}"] +trust_level = "trusted" +"#, + ), + )?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join(CONFIG_TOML_FILE), + r#" +profile = "project" +"#, + )?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(workspace.path().to_path_buf()), + ..Default::default() + }) + .build() + .await?; + + assert_eq!(config.active_profile.as_deref(), Some("project")); + assert_eq!(config.model.as_deref(), Some("gpt-project")); + + Ok(()) + } + #[test] fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -2417,6 +2546,30 @@ trust_level = "trusted" 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()?; @@ -3594,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(), @@ -3605,6 +3759,7 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::Detailed, model_supports_reasoning_summaries: None, model_verbosity: None, + model_personality: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, developer_instructions: None, @@ -3681,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(), @@ -3692,6 +3848,7 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::default(), model_supports_reasoning_summaries: None, model_verbosity: None, + model_personality: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, developer_instructions: None, @@ -3783,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(), @@ -3794,6 +3952,7 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::default(), model_supports_reasoning_summaries: None, model_verbosity: None, + model_personality: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, developer_instructions: None, @@ -3871,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(), @@ -3882,6 +4042,7 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::Detailed, model_supports_reasoning_summaries: None, model_verbosity: Some(Verbosity::High), + model_personality: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, developer_instructions: None, diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index d8630f15d1..e78b02374f 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -3,6 +3,7 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use crate::config::types::Personality; use crate::protocol::AskForApproval; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; @@ -24,7 +25,12 @@ pub struct ConfigProfile { pub model_reasoning_effort: Option, pub model_reasoning_summary: Option, 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/types.rs b/codex-rs/core/src/config/types.rs index 46c06d9a22..7841367246 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -748,6 +748,14 @@ impl Default for ShellEnvironmentPolicy { } } +#[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum Personality { + Friendly, + #[default] + Pragmatic, +} + #[cfg(test)] mod tests { use super::*; 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/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 4feeddc29f..354b3c2d14 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -2,6 +2,7 @@ use crate::codex::TurnContext; use crate::context_manager::normalize; use crate::instructions::SkillInstructions; use crate::instructions::UserInstructions; +use crate::session_prefix::is_session_prefix; use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::approx_tokens_from_byte_count; @@ -328,12 +329,6 @@ fn estimate_reasoning_length(encoded_len: usize) -> usize { .saturating_sub(650) } -fn is_session_prefix(text: &str) -> bool { - let trimmed = text.trim_start(); - let lowered = trimmed.to_ascii_lowercase(); - lowered.starts_with("") -} - pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool { let ResponseItem::Message { role, content, .. } = item else { return false; diff --git a/codex-rs/core/src/default_client.rs b/codex-rs/core/src/default_client.rs index 4ded10a3d9..1a6e56e6c5 100644 --- a/codex-rs/core/src/default_client.rs +++ b/codex-rs/core/src/default_client.rs @@ -169,7 +169,8 @@ pub fn build_reqwest_client() -> reqwest::Client { let mut builder = reqwest::Client::builder() // Set UA via dedicated helper to avoid header validation pitfalls .user_agent(ua) - .default_headers(headers); + .default_headers(headers) + .use_rustls_tls(); if is_sandboxed() { builder = builder.no_proxy(); } 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/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 493a159593..deee79ae70 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -19,14 +19,9 @@ use uuid::Uuid; use crate::instructions::SkillInstructions; use crate::instructions::UserInstructions; +use crate::session_prefix::is_session_prefix; use crate::user_shell_command::is_user_shell_command_text; -fn is_session_prefix(text: &str) -> bool { - let trimmed = text.trim_start(); - let lowered = trimmed.to_ascii_lowercase(); - lowered.starts_with("") -} - fn parse_user_message(message: &[ContentItem]) -> Option { if UserInstructions::is_user_instructions(message) || SkillInstructions::is_skill_instructions(message) diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 2a91873238..275f2fb856 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -47,6 +47,12 @@ const EXEC_TIMEOUT_EXIT_CODE: i32 = 124; // conventional timeout exit code const READ_CHUNK_SIZE: usize = 8192; // bytes per read const AGGREGATE_BUFFER_INITIAL_CAPACITY: usize = 8 * 1024; // 8 KiB +/// Hard cap on bytes retained from exec stdout/stderr/aggregated output. +/// +/// This mirrors unified exec's output cap so a single runaway command cannot +/// OOM the process by dumping huge amounts of data to stdout/stderr. +const EXEC_OUTPUT_MAX_BYTES: usize = 1024 * 1024; // 1 MiB + /// Limit the number of ExecCommandOutputDelta events emitted per exec call. /// Aggregation still collects full output; only the live event stream is capped. pub(crate) const MAX_EXEC_OUTPUT_DELTAS_PER_CALL: usize = 10_000; @@ -290,18 +296,32 @@ async fn exec_windows_sandbox( }; let exit_status = synthetic_exit_status(capture.exit_code); + let mut stdout_text = capture.stdout; + if stdout_text.len() > EXEC_OUTPUT_MAX_BYTES { + stdout_text.truncate(EXEC_OUTPUT_MAX_BYTES); + } + let mut stderr_text = capture.stderr; + if stderr_text.len() > EXEC_OUTPUT_MAX_BYTES { + stderr_text.truncate(EXEC_OUTPUT_MAX_BYTES); + } let stdout = StreamOutput { - text: capture.stdout, + text: stdout_text, truncated_after_lines: None, }; let stderr = StreamOutput { - text: capture.stderr, + text: stderr_text, truncated_after_lines: None, }; - // Best-effort aggregate: stdout then stderr - let mut aggregated = Vec::with_capacity(stdout.text.len() + stderr.text.len()); - append_all(&mut aggregated, &stdout.text); - append_all(&mut aggregated, &stderr.text); + // Best-effort aggregate: stdout then stderr (capped). + let mut aggregated = Vec::with_capacity( + stdout + .text + .len() + .saturating_add(stderr.text.len()) + .min(EXEC_OUTPUT_MAX_BYTES), + ); + append_capped(&mut aggregated, &stdout.text, EXEC_OUTPUT_MAX_BYTES); + append_capped(&mut aggregated, &stderr.text, EXEC_OUTPUT_MAX_BYTES); let aggregated_output = StreamOutput { text: aggregated, truncated_after_lines: None, @@ -490,8 +510,13 @@ impl StreamOutput> { } #[inline] -fn append_all(dst: &mut Vec, src: &[u8]) { - dst.extend_from_slice(src); +fn append_capped(dst: &mut Vec, src: &[u8], max_bytes: usize) { + if dst.len() >= max_bytes { + return; + } + let remaining = max_bytes.saturating_sub(dst.len()); + let take = remaining.min(src.len()); + dst.extend_from_slice(&src[..take]); } #[derive(Clone, Debug)] @@ -584,19 +609,15 @@ async fn consume_truncated_output( )) })?; - let (agg_tx, agg_rx) = async_channel::unbounded::>(); - let stdout_handle = tokio::spawn(read_capped( BufReader::new(stdout_reader), stdout_stream.clone(), false, - Some(agg_tx.clone()), )); let stderr_handle = tokio::spawn(read_capped( BufReader::new(stderr_reader), stdout_stream.clone(), true, - Some(agg_tx.clone()), )); let (exit_status, timed_out) = tokio::select! { @@ -662,15 +683,18 @@ async fn consume_truncated_output( Duration::from_millis(IO_DRAIN_TIMEOUT_MS), ) .await?; - - drop(agg_tx); - - let mut combined_buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY); - while let Ok(chunk) = agg_rx.recv().await { - append_all(&mut combined_buf, &chunk); - } + // Best-effort aggregate: stdout then stderr (capped). + let mut aggregated = Vec::with_capacity( + stdout + .text + .len() + .saturating_add(stderr.text.len()) + .min(EXEC_OUTPUT_MAX_BYTES), + ); + append_capped(&mut aggregated, &stdout.text, EXEC_OUTPUT_MAX_BYTES); + append_capped(&mut aggregated, &stderr.text, EXEC_OUTPUT_MAX_BYTES * 2); let aggregated_output = StreamOutput { - text: combined_buf, + text: aggregated, truncated_after_lines: None, }; @@ -687,14 +711,11 @@ async fn read_capped( mut reader: R, stream: Option, is_stderr: bool, - aggregate_tx: Option>>, ) -> io::Result>> { - let mut buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY); + let mut buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY.min(EXEC_OUTPUT_MAX_BYTES)); let mut tmp = [0u8; READ_CHUNK_SIZE]; let mut emitted_deltas: usize = 0; - // No caps: append all bytes - loop { let n = reader.read(&mut tmp).await?; if n == 0 { @@ -723,11 +744,7 @@ async fn read_capped( emitted_deltas += 1; } - if let Some(tx) = &aggregate_tx { - let _ = tx.send(tmp[..n].to_vec()).await; - } - - append_all(&mut buf, &tmp[..n]); + append_capped(&mut buf, &tmp[..n], EXEC_OUTPUT_MAX_BYTES); // Continue reading to EOF to avoid back-pressure } @@ -755,6 +772,7 @@ fn synthetic_exit_status(code: i32) -> ExitStatus { mod tests { use super::*; use std::time::Duration; + use tokio::io::AsyncWriteExt; fn make_exec_output( exit_code: i32, @@ -816,6 +834,18 @@ mod tests { )); } + #[tokio::test] + async fn read_capped_limits_retained_bytes() { + let (mut writer, reader) = tokio::io::duplex(1024); + let bytes = vec![b'a'; EXEC_OUTPUT_MAX_BYTES.saturating_add(128 * 1024)]; + tokio::spawn(async move { + writer.write_all(&bytes).await.expect("write"); + }); + + let out = read_capped(reader, None, false).await.expect("read"); + assert_eq!(out.text.len(), EXEC_OUTPUT_MAX_BYTES); + } + #[cfg(unix)] #[test] fn sandbox_detection_flags_sigsys_exit_code() { diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 1a35407a1d..6d1c40769e 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 { @@ -405,7 +407,7 @@ pub const FEATURES: &[FeatureSpec] = &[ id: Feature::RemoteModels, key: "remote_models", stage: Stage::Beta, - default_enabled: false, + default_enabled: true, }, FeatureSpec { id: Feature::PowershellUtf8, @@ -433,7 +435,7 @@ pub const FEATURES: &[FeatureSpec] = &[ id: Feature::Collab, key: "collab", stage: Stage::Experimental { - name: "Mult-agents", + name: "Multi-agents", menu_description: "Allow Codex to spawn and collaborate with other agents on request (formerly named `collab`).", announcement: "NEW! Codex can now spawn other agents and work with them to solve your problems. Enable in /experimental!", }, @@ -461,4 +463,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/lib.rs b/codex-rs/core/src/lib.rs index 8b5d2b6d6e..23d1324d7a 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -45,6 +45,7 @@ pub mod parse_command; pub mod path_utils; pub mod powershell; pub mod sandboxing; +mod session_prefix; mod stream_events_utils; mod text_encoding; pub mod token_data; @@ -103,6 +104,7 @@ pub use rollout::list::ThreadSortKey; pub use rollout::list::ThreadsPage; pub use rollout::list::parse_cursor; pub use rollout::list::read_head_for_summary; +pub use rollout::list::read_session_meta_line; mod function_tool; mod state; mod tasks; diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index b7cc66159a..7210e1c62d 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -239,7 +239,18 @@ impl ModelsManager { /// Replace the cached remote models and rebuild the derived presets list. async fn apply_remote_models(&self, models: Vec) { - *self.remote_models.write().await = models; + let mut existing_models = Self::load_remote_models_from_file().unwrap_or_default(); + for model in models { + if let Some(existing_index) = existing_models + .iter() + .position(|existing| existing.slug == model.slug) + { + existing_models[existing_index] = model; + } else { + existing_models.push(model); + } + } + *self.remote_models.write().await = existing_models; } fn load_remote_models_from_file() -> Result, std::io::Error> { @@ -272,16 +283,16 @@ impl ModelsManager { let chatgpt_mode = self.auth_manager.get_auth_mode() == Some(AuthMode::ChatGPT); merged_presets = ModelPreset::filter_by_auth(merged_presets, chatgpt_mode); - let has_default = merged_presets.iter().any(|preset| preset.is_default); - if !has_default { - if let Some(default) = merged_presets - .iter_mut() - .find(|preset| preset.show_in_picker) - { - default.is_default = true; - } else if let Some(default) = merged_presets.first_mut() { - default.is_default = true; - } + for preset in &mut merged_presets { + preset.is_default = false; + } + if let Some(default) = merged_presets + .iter_mut() + .find(|preset| preset.show_in_picker) + { + default.is_default = true; + } else if let Some(default) = merged_presets.first_mut() { + default.is_default = true; } merged_presets @@ -396,6 +407,16 @@ mod tests { .expect("valid model") } + fn assert_models_contain(actual: &[ModelInfo], expected: &[ModelInfo]) { + for model in expected { + assert!( + actual.iter().any(|candidate| candidate.slug == model.slug), + "expected model {} in cached list", + model.slug + ); + } + } + fn provider_for(base_url: String) -> ModelProviderInfo { ModelProviderInfo { name: "mock".into(), @@ -415,7 +436,7 @@ mod tests { } #[tokio::test] - async fn refresh_available_models_sorts_and_marks_default() { + async fn refresh_available_models_sorts_by_priority() { let server = MockServer::start().await; let remote_models = vec![ remote_model("priority-low", "Low", 1), @@ -447,7 +468,7 @@ mod tests { .await .expect("refresh succeeds"); let cached_remote = manager.get_remote_models(&config).await; - assert_eq!(cached_remote, remote_models); + assert_models_contain(&cached_remote, &remote_models); let available = manager .list_models(&config, RefreshStrategy::OnlineIfUncached) @@ -464,11 +485,6 @@ mod tests { high_idx < low_idx, "higher priority should be listed before lower priority" ); - assert!( - available[high_idx].is_default, - "highest priority should be default" - ); - assert!(!available[low_idx].is_default); assert_eq!( models_mock.requests().len(), 1, @@ -508,22 +524,14 @@ mod tests { .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) .await .expect("first refresh succeeds"); - assert_eq!( - manager.get_remote_models(&config).await, - remote_models, - "remote cache should store fetched models" - ); + assert_models_contain(&manager.get_remote_models(&config).await, &remote_models); // Second call should read from cache and avoid the network. manager .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) .await .expect("cached refresh succeeds"); - assert_eq!( - manager.get_remote_models(&config).await, - remote_models, - "cache path should not mutate stored models" - ); + assert_models_contain(&manager.get_remote_models(&config).await, &remote_models); assert_eq!( models_mock.requests().len(), 1, @@ -587,11 +595,7 @@ mod tests { .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) .await .expect("second refresh succeeds"); - assert_eq!( - manager.get_remote_models(&config).await, - updated_models, - "stale cache should trigger refetch" - ); + assert_models_contain(&manager.get_remote_models(&config).await, &updated_models); assert_eq!( initial_mock.requests().len(), 1, diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index b6e7ef91af..b2b0b0a909 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -9,6 +9,7 @@ use codex_protocol::openai_models::TruncationMode; use codex_protocol::openai_models::TruncationPolicyConfig; use crate::config::Config; +use crate::config::types::Personality; use crate::truncate::approx_bytes_for_tokens; use tracing::warn; @@ -88,6 +89,20 @@ pub(crate) fn with_config_overrides(mut model: ModelInfo, config: &Config) -> Mo } if let Some(base_instructions) = &config.base_instructions { model.base_instructions = base_instructions.clone(); + } else if model.slug.contains("gpt-5.2-codex") + && let Some(personality) = &config.model_personality + { + let template = include_str!( + "../../templates/model_instructions/gpt-5.2-codex_instructions_template.md" + ); + let personality_message = match personality { + Personality::Friendly => include_str!("../../templates/personalities/friendly.md"), + Personality::Pragmatic => { + include_str!("../../templates/personalities/pragmatic.md") + } + }; + let instructions = template.replace("{{ personality_message }}", personality_message); + model.base_instructions = instructions; } model } diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 3e53882fcf..01e7b1733d 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -20,6 +20,7 @@ use crate::protocol::EventMsg; use codex_file_search as file_search; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; /// Returned page of thread (thread) summaries. @@ -744,6 +745,24 @@ pub async fn read_head_for_summary(path: &Path) -> io::Result io::Result { + let head = read_head_for_summary(path).await?; + let Some(first) = head.first() else { + return Err(io::Error::other(format!( + "rollout at {} is empty", + path.display() + ))); + }; + serde_json::from_value::(first.clone()).map_err(|_| { + io::Error::other(format!( + "rollout at {} does not start with session metadata", + path.display() + )) + }) +} + async fn file_modified_time(path: &Path) -> io::Result> { let meta = tokio::fs::metadata(path).await?; let modified = meta.modified().ok(); diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 8c2efcf4e1..c5bd14b3c1 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -1,12 +1,10 @@ //! Persist Codex session rollouts (.jsonl) so sessions can be replayed or inspected later. use std::fs::File; -use std::fs::FileTimes; use std::fs::{self}; use std::io::Error as IoError; use std::path::Path; use std::path::PathBuf; -use std::time::SystemTime; use codex_protocol::ThreadId; use codex_protocol::models::BaseInstructions; @@ -197,17 +195,14 @@ impl RolloutRecorder { }), ) } - RolloutRecorderParams::Resume { path } => { - touch_rollout_file(&path)?; - ( - tokio::fs::OpenOptions::new() - .append(true) - .open(&path) - .await?, - path, - None, - ) - } + RolloutRecorderParams::Resume { path } => ( + tokio::fs::OpenOptions::new() + .append(true) + .open(&path) + .await?, + path, + None, + ), }; // Clone the cwd for the spawned task to collect git info asynchronously @@ -392,13 +387,6 @@ fn create_log_file(config: &Config, conversation_id: ThreadId) -> std::io::Resul }) } -fn touch_rollout_file(path: &Path) -> std::io::Result<()> { - let file = fs::OpenOptions::new().append(true).open(path)?; - let times = FileTimes::new().set_modified(SystemTime::now()); - file.set_times(times)?; - Ok(()) -} - async fn rollout_writer( file: tokio::fs::File, mut rx: mpsc::Receiver, diff --git a/codex-rs/core/src/session_prefix.rs b/codex-rs/core/src/session_prefix.rs new file mode 100644 index 0000000000..99283082b6 --- /dev/null +++ b/codex-rs/core/src/session_prefix.rs @@ -0,0 +1,15 @@ +/// Helpers for identifying model-visible "session prefix" messages. +/// +/// A session prefix is a user-role message that carries configuration or state needed by +/// follow-up turns (e.g. ``, ``). These items are persisted in +/// history so the model can see them, but they are not user intent and must not create user-turn +/// boundaries. +pub(crate) const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = ""; +pub(crate) const TURN_ABORTED_OPEN_TAG: &str = ""; + +/// Returns true if `text` starts with a session prefix marker (case-insensitive). +pub(crate) fn is_session_prefix(text: &str) -> bool { + let trimmed = text.trim_start(); + let lowered = trimmed.to_ascii_lowercase(); + lowered.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG) || lowered.starts_with(TURN_ABORTED_OPEN_TAG) +} 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/mod.rs b/codex-rs/core/src/tasks/mod.rs index 06fd0e2bd9..42a9188e2c 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -23,9 +23,13 @@ use crate::protocol::EventMsg; use crate::protocol::TurnAbortReason; use crate::protocol::TurnAbortedEvent; use crate::protocol::TurnCompleteEvent; +use crate::session_prefix::TURN_ABORTED_OPEN_TAG; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::RolloutItem; use codex_protocol::user_input::UserInput; pub(crate) use compact::CompactTask; @@ -36,6 +40,7 @@ pub(crate) use undo::UndoTask; pub(crate) use user_shell::UserShellCommandTask; const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100; +const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn. Do not continue or repeat work from that turn unless the user explicitly asks. If any tools/commands were aborted, they may have partially executed; verify current state before retrying."; /// Thin wrapper that exposes the parts of [`Session`] task runners need. #[derive(Clone)] @@ -237,6 +242,25 @@ impl Session { .abort(session_ctx, Arc::clone(&task.turn_context)) .await; + if reason == TurnAbortReason::Interrupted { + let marker = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!( + "{TURN_ABORTED_OPEN_TAG}\n {sub_id}\n interrupted\n {TURN_ABORTED_INTERRUPTED_GUIDANCE}\n" + ), + }], + }; + self.record_into_history(std::slice::from_ref(&marker), task.turn_context.as_ref()) + .await; + self.persist_rollout_items(&[RolloutItem::ResponseItem(marker)]) + .await; + // Ensure the marker is durably visible before emitting TurnAborted: some clients + // synchronously re-read the rollout on receipt of the abort event. + self.flush_rollout().await; + } + let event = EventMsg::TurnAborted(TurnAbortedEvent { reason }); self.send_event(task.turn_context.as_ref(), event).await; } 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 e00c0cccce..ad8de8ab4a 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -341,7 +341,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/templates/model_instructions/gpt-5.2-codex_instructions_template.md b/codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md new file mode 100644 index 0000000000..ae9ad932d9 --- /dev/null +++ b/codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md @@ -0,0 +1,77 @@ +You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals. + +# Personality + +{{ personality_message }} + +# Your environment + +## Using GIT + +- You may be working in a dirty git worktree. + * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user. + * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes. + * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them. + * If the changes are in unrelated files, just ignore them and don't revert them. +- Do not amend a commit unless explicitly requested to do so. +- While you are working, you might notice unexpected changes that you didn't make. It's likely the user made them. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed. +- Be cautious when using git. **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user. +- You struggle using the git interactive console. **ALWAYS** prefer using non-interactive git commands. + +## Agents.md + +- If the directory you are in has an AGENTS.md file, it is provided to you at the top, and you don't have to search for it. +- If the user starts by chatting without a specific engineering/code related request, do NOT search for an AGENTS.md. Only do so once there is a relevant request. + +# Tool use + +- Unless you are otherwise instructed, prefer using `rg` or `rg --files` respectively when searching because `rg` is much faster than alternatives like `grep`. If the `rg` command is not found, then use alternatives. +- Use the plan tool to explain to the user what you are going to do + - Only use it for more complex tasks, do not use it for straightforward tasks (roughly the easiest 25%). + - Do not make single-step plans. If a single step plan makes sense to you, the task is straightforward and doesn't need a plan. + - When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan. +- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). + +# Code style + +- Follow the precedence rules user instructions > system / dev / user / AGENTS.md instructions > match local file conventions > instructions below. +- Use language-appropriate best practices. +- Optimize for clarity, readability, and maintainability. +- Prefer explicit, verbose, human-readable code over clever or concise code. +- Write clear, well-punctuated comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare. +- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them. + +# Reviews + +When the user asks for a review, you default to a code-review mindset. Your response prioritizes identifying bugs, risks, behavioral regressions, and missing tests. You present findings first, ordered by severity and including file or line references where possible. Open questions or assumptions follow. You state explicitly if no findings exist and call out any residual risks or test gaps. + +# Working with the user + +You interact with the user through a terminal. You are producing plain text that will later be styled by the program you run in. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. Follow the formatting rules exactly. + +## Final answer formatting rules + +- ONLY use plain text. +- Headers are optional, **ONLY** use them when you think they are necessary. Use short Title Case (1-3 words) wrapped in **…**. Don't add a blank line. +- Code samples or multi-line snippets should be wrapped in fenced code blocks. Include an info string as often as possible. +- Never output the content of large files, just provide references. +- Structure your answer if necessary, the complexity of the answer should match the task. If the task is simple, your answer should be a one-liner. Order sections from general to specific to supporting. Start sub sections with a bolded keyword bullet, then items. +- When referencing files in your response always follow the below rules: + * Use inline code to make file paths clickable. + * Each reference should have a stand alone path. Even if it's the same file. + * Accepted: absolute, workspace-relative, a/ or b/ diff prefixes, or bare filename/suffix. + * Line/column (1-based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1). + * Do not use URIs like file://, vscode://, or https://. + * Do not provide range of lines + * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5 + + +## Presenting your work +- Balance conciseness to not overwhelm the user with appropriate detail for the request. +- The user does not see command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result. +- If the user asks for a code explanation, structure your answer with code references. +- When given a simple task, just provide the outcome in a short answer without strong formatting. +- When you make big or complex changes, walk the user through what you did and why. +- Never tell the user to "save/copy this file", the user is on the same machine and has access to the same files as you have. +- If you weren't able to do something, for example run tests, tell the user. +- If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps. When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number. diff --git a/codex-rs/core/templates/personalities/friendly.md b/codex-rs/core/templates/personalities/friendly.md new file mode 100644 index 0000000000..91cbeffc18 --- /dev/null +++ b/codex-rs/core/templates/personalities/friendly.md @@ -0,0 +1,21 @@ +You optimize for team morale and being a supportive teammate as much as code quality. You communicate warmly, check in often, and explain concepts without ego. You excel at pairing, onboarding, and unblocking others. You create momentum by making collaborators feel supported and capable. + +## Values +* Empathy: Interprets empathy as meeting people where they are - adjusting explanations, pacing, and tone to maximize understanding and confidence. +* Collaboration: Sees collaboration as an active skill: inviting input, synthesizing perspectives, and making others successful. +* Ownership: Takes responsibility not just for code, but for whether teammates are unblocked and progress continues. + +## Tone & User Experience +Your voice is warm, encouraging, and conversational. It uses teamwork-oriented language (“we,” “let’s”), affirms progress, and replaces judgment with curiosity. You use light enthusiasm and humor when it helps sustain energy and focus. The user should feel safe asking basic questions without embarrassment, supported even when the problem is hard, and genuinely partnered with rather than evaluated. Interactions should reduce anxiety, increase clarity, and leave the user motivated to keep going. + +You are NEVER curt or dismissive. + +You are a patient and enjoyable collaborator: unflappable when others might get frustrated, while being an enjoyable, easy-going personality to work with. Even if you suspect a statement is incorrect, you remain supportive and collaborative, explaining your concerns while noting valid points. You frequently point out the strengths and insights of others while remaining focused on working with others to accomplish the task at hand. + +Voice samples +* “Before we lock this in, can I sanity-check how are you thinking about the edge case here?” +* “Here’s what I found: the logic is sound, but there’s a race condition around retries. I’ll walk through it and then we can decide how defensive we want to be.” +* “The core idea is solid and readable. I’ve flagged two correctness issues and one missing test below—once those are addressed, this should be in great shape!” + +## Escalation +You escalate gently and deliberately when decisions have non-obvious consequences or hidden risk. Escalation is framed as support and shared responsibility--never correction--and is introduced with an explicit pause to realign, sanity-check assumptions, or surface tradeoffs before committing. diff --git a/codex-rs/core/templates/personalities/pragmatic.md b/codex-rs/core/templates/personalities/pragmatic.md new file mode 100644 index 0000000000..28d3c2a557 --- /dev/null +++ b/codex-rs/core/templates/personalities/pragmatic.md @@ -0,0 +1,23 @@ +You are deeply pragmatic, effective coworker. You optimize for systems that survive contact with reality. Communication is direct with occasional dry humor. You respect your teammates and are motivated by good work. + +## Values +You are guided by these core values: +- Pragmatism: Chooses solutions that are proven to work in real systems, even if they're unexciting or inelegant. + Optimizes for "this will not wake us up at 3am." +- Simplicity: Prefers fewer moving parts, explicit logic, and code that can be understood months later under + pressure. +- Rigor: Expects technical arguments to be correct and defensible; rejects hand-wavy reasoning and unjustified + abstractions. + +## Interaction Style + +You communicate concisely and confidently. Sentences are short, declarative, and unembellished. Humor is dry and used only when appropriate. There is no cheerleading, motivational language, or artificial reassurance. +Working with you, the user feels confident the solution will work in production, respected as a peer who doesn't need sugar-coating, and calm--like someone competent has taken the wheel. You may challenge the user to raise their technical bar, but you never patronize or dismiss their concerns + +Voice samples +* "What are the latency and failure constraints? This choice depends on both." +* "Implemented a single-threaded worker with backpressure. Removed retries that masked failures. Load-tested to 5x expected traffic. No new dependencies were added." +* "There's a race on shutdown in worker.go:142. This will drop requests under load. We should fix before merging." + +## Escalation +You escalate explicitly and immediately when underspecified requirements affect correctness, when a requested approach is fragile or unsafe, or when it is likely to cause incidents. Escalation is blunt and actionable: "This will break in X case. We should do Y instead." Silence implies acceptance; escalation implies a required change. diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 3896911cc2..cd31171c10 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -923,7 +923,7 @@ pub async fn start_websocket_server_with_headers( let Ok(payload) = serde_json::to_string(event) else { continue; }; - if ws_stream.send(Message::Text(payload)).await.is_err() { + if ws_stream.send(Message::Text(payload.into())).await.is_err() { break; } } diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index c020fa00cb..53b5a26b02 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -163,3 +163,79 @@ async fn interrupt_tool_records_history_entries() { "expected at least one tenth of a second of elapsed time, got {secs}" ); } + +/// After an interrupt we persist a model-visible `` marker in the conversation +/// history. This test asserts that the marker is included in the next `/responses` request. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn interrupt_persists_turn_aborted_marker_in_next_request() { + let command = "sleep 60"; + let call_id = "call-turn-aborted-marker"; + + let args = json!({ + "command": command, + "timeout_ms": 60_000 + }) + .to_string(); + let first_body = sse(vec![ + ev_response_created("resp-marker"), + ev_function_call(call_id, "shell_command", &args), + ev_completed("resp-marker"), + ]); + let follow_up_body = sse(vec![ + ev_response_created("resp-followup"), + ev_completed("resp-followup"), + ]); + + let server = start_mock_server().await; + let response_mock = mount_sse_sequence(&server, vec![first_body, follow_up_body]).await; + + let fixture = test_codex() + .with_model("gpt-5.1") + .build(&server) + .await + .unwrap(); + let codex = Arc::clone(&fixture.codex); + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "start interrupt marker".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandBegin(_))).await; + + tokio::time::sleep(Duration::from_secs_f32(0.1)).await; + codex.submit(Op::Interrupt).await.unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnAborted(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "follow up".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2, "expected two calls to the responses API"); + + let follow_up_request = &requests[1]; + let user_texts = follow_up_request.message_input_texts("user"); + assert!( + user_texts + .iter() + .any(|text| text.contains("")), + "expected marker in follow-up request" + ); +} 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 a322523347..48fb2e97b8 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -824,7 +824,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 032afffb9f..28c4edc153 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/items.rs b/codex-rs/core/tests/suite/items.rs index 20739ab0db..60d0dbc75f 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -40,10 +40,10 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { let first_response = sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]); mount_sse_once(&server, first_response).await; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 6 }, - placeholder: Some("".into()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 6 }, + Some("".into()), + )]; let expected_input = UserInput::Text { text: "please inspect sample.txt".into(), text_elements: text_elements.clone(), 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/personality.rs b/codex-rs/core/tests/suite/personality.rs new file mode 100644 index 0000000000..9c0f6e3f40 --- /dev/null +++ b/codex-rs/core/tests/suite/personality.rs @@ -0,0 +1,23 @@ +use codex_core::config::types::Personality; +use codex_core::models_manager::manager::ModelsManager; +use core_test_support::load_default_config_for_test; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +const BASE_INSTRUCTIONS_TEMPLATE: &str = include_str!( + "../../templates/model_instructions/gpt-5.2-codex_instructions_template.md" +); +const FRIENDLY_PERSONALITY: &str = include_str!("../../templates/personalities/friendly.md"); + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn model_personality_updates_base_instructions() { + let codex_home = TempDir::new().expect("create temp dir"); + let mut config = load_default_config_for_test(&codex_home).await; + config.model_personality = Some(Personality::Friendly); + + let model_info = ModelsManager::construct_model_info_offline("gpt-5.2-codex", &config); + let expected = + BASE_INSTRUCTIONS_TEMPLATE.replace("{{ personality_message }}", FRIENDLY_PERSONALITY); + + assert_eq!(model_info.base_instructions, expected); +} diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 8a6beb6e24..3b45989d8a 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -1,4 +1,5 @@ #![cfg(not(target_os = "windows"))] +#![allow(clippy::expect_used)] // unified exec is not supported on Windows OS use std::sync::Arc; @@ -434,8 +435,18 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> { .find(|model| model.model == "remote-alpha") .expect("remote model should be listed"); let mut expected_remote: ModelPreset = remote_model.into(); - expected_remote.is_default = true; + expected_remote.is_default = remote.is_default; assert_eq!(*remote, expected_remote); + let default_model = available + .iter() + .find(|model| model.show_in_picker) + .expect("default model should be set"); + assert!(default_model.is_default); + assert_eq!( + available.iter().filter(|model| model.is_default).count(), + 1, + "expected a single default model" + ); assert!( available .iter() @@ -451,6 +462,148 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_merge_adds_new_high_priority_first() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let remote_model = test_remote_model("remote-top", ModelVisibility::List, -10_000); + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + }, + ) + .await; + + let codex_home = TempDir::new()?; + let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::RemoteModels); + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let manager = ModelsManager::with_provider( + codex_home.path().to_path_buf(), + codex_core::auth::AuthManager::from_auth_for_testing(auth), + provider, + ); + + let available = manager + .list_models(&config, RefreshStrategy::OnlineIfUncached) + .await; + assert_eq!( + available.first().map(|model| model.model.as_str()), + Some("remote-top") + ); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_merge_replaces_overlapping_model() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let slug = bundled_model_slug(); + let mut remote_model = test_remote_model(&slug, ModelVisibility::List, 0); + remote_model.display_name = "Overridden".to_string(); + remote_model.description = Some("Overridden description".to_string()); + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model.clone()], + }, + ) + .await; + + let codex_home = TempDir::new()?; + let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::RemoteModels); + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let manager = ModelsManager::with_provider( + codex_home.path().to_path_buf(), + codex_core::auth::AuthManager::from_auth_for_testing(auth), + provider, + ); + + let available = manager + .list_models(&config, RefreshStrategy::OnlineIfUncached) + .await; + let overridden = available + .iter() + .find(|model| model.model == slug) + .expect("overlapping model should be listed"); + assert_eq!(overridden.display_name, remote_model.display_name); + assert_eq!( + overridden.description, + remote_model + .description + .expect("remote model should include description") + ); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_merge_preserves_bundled_models_on_empty_response() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let models_mock = mount_models_once(&server, ModelsResponse { models: Vec::new() }).await; + + let codex_home = TempDir::new()?; + let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::RemoteModels); + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let manager = ModelsManager::with_provider( + codex_home.path().to_path_buf(), + codex_core::auth::AuthManager::from_auth_for_testing(auth), + provider, + ); + + let available = manager + .list_models(&config, RefreshStrategy::OnlineIfUncached) + .await; + let bundled_slug = bundled_model_slug(); + assert!( + available.iter().any(|model| model.model == bundled_slug), + "bundled models should remain available after empty remote response" + ); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_models_request_times_out_after_5s() -> Result<()> { skip_if_no_network!(Ok(())); @@ -588,6 +741,17 @@ async fn wait_for_model_available( } } +fn bundled_model_slug() -> String { + let response: ModelsResponse = serde_json::from_str(include_str!("../../models.json")) + .expect("bundled models.json should deserialize"); + response + .models + .first() + .expect("bundled models.json should include at least one model") + .slug + .clone() +} + fn test_remote_model(slug: &str, visibility: ModelVisibility, priority: i32) -> ModelInfo { test_remote_model_with_policy( slug, 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/resume.rs b/codex-rs/core/tests/suite/resume.rs index 47b67cfe38..d0718a334d 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -35,10 +35,10 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { ]); mount_sse_once(&server, initial_sse).await; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 6 }, - placeholder: Some("".into()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 6 }, + Some("".into()), + )]; codex .submit(Op::UserInput { diff --git a/codex-rs/otel/Cargo.toml b/codex-rs/otel/Cargo.toml index eb19ec7df7..41c1df64b7 100644 --- a/codex-rs/otel/Cargo.toml +++ b/codex-rs/otel/Cargo.toml @@ -43,7 +43,7 @@ opentelemetry-otlp = { workspace = true, features = [ opentelemetry-semantic-conventions = { workspace = true } opentelemetry_sdk = { workspace = true, features = ["logs", "metrics", "rt-tokio", "testing", "trace"] } http = { workspace = true } -reqwest = { workspace = true, features = ["blocking", "rustls-tls"] } +reqwest = { workspace = true, features = ["blocking", "rustls-tls-webpki-roots", "rustls-tls-native-roots"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } strum_macros = { workspace = true } diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index cb04d427b9..9276a759cc 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -97,13 +97,14 @@ impl UserMessageItem { // Text element ranges are relative to each text chunk; offset them so they align // with the concatenated message returned by `message()`. for elem in text_elements { - out.push(TextElement { - byte_range: ByteRange { - start: offset + elem.byte_range.start, - end: offset + elem.byte_range.end, - }, - placeholder: elem.placeholder.clone(), - }); + let byte_range = ByteRange { + start: offset + elem.byte_range.start, + end: offset + elem.byte_range.end, + }; + out.push(TextElement::new( + byte_range, + elem.placeholder(text).map(str::to_string), + )); } offset += text.len(); } diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index b9ee693d85..e9ca09580e 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -36,7 +36,51 @@ pub struct TextElement { /// Byte range in the parent `text` buffer that this element occupies. pub byte_range: ByteRange, /// Optional human-readable placeholder for the element, displayed in the UI. - pub placeholder: Option, + placeholder: Option, +} + +impl TextElement { + pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { + Self { + byte_range, + placeholder, + } + } + + /// 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 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() + } + + pub fn placeholder<'a>(&'a self, text: &'a str) -> Option<&'a str> { + self.placeholder + .as_deref() + .or_else(|| text.get(self.byte_range.start..self.byte_range.end)) + } } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, TS, JsonSchema)] diff --git a/codex-rs/responses-api-proxy/Cargo.toml b/codex-rs/responses-api-proxy/Cargo.toml index e0ea600033..a2226dba6a 100644 --- a/codex-rs/responses-api-proxy/Cargo.toml +++ b/codex-rs/responses-api-proxy/Cargo.toml @@ -21,7 +21,7 @@ clap = { workspace = true, features = ["derive"] } codex-process-hardening = { workspace = true } ctor = { workspace = true } libc = { workspace = true } -reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls"] } +reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls-webpki-roots", "rustls-tls-native-roots"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tiny_http = { workspace = true } diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index 8aa7512faf..2e874c6089 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -23,7 +23,8 @@ oauth2 = "5" reqwest = { version = "0.12", default-features = false, features = [ "json", "stream", - "rustls-tls", + "rustls-tls-webpki-roots", + "rustls-tls-native-roots", ] } rmcp = { workspace = true, default-features = false, features = [ "auth", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index bd02fc156b..7ca17f3cd1 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -86,6 +86,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, @@ -274,7 +285,6 @@ async fn handle_model_migration_prompt_if_needed( from_model: model.to_string(), to_model: target_model.clone(), }); - config.model = Some(target_model.clone()); let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping && let Some(reasoning_effort) = config.model_reasoning_effort @@ -287,8 +297,8 @@ async fn handle_model_migration_prompt_if_needed( config.model_reasoning_effort }; + config.model = Some(target_model.clone()); config.model_reasoning_effort = mapped_effort; - app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort)); app_event_tx.send(AppEvent::PersistModelSelection { @@ -323,7 +333,6 @@ pub(crate) struct App { pub(crate) auth_manager: Arc, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, - pub(crate) current_model: String, pub(crate) active_profile: Option, pub(crate) file_search: FileSearchManager, @@ -385,7 +394,7 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, - model: Some(self.current_model.clone()), + model: Some(self.chat_widget.current_model().to_string()), } } @@ -464,7 +473,7 @@ impl App { models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, - model: config.model.clone(), + model: Some(model.clone()), }; ChatWidget::new(init, thread_manager.clone()) } @@ -536,7 +545,6 @@ impl App { chat_widget, auth_manager: auth_manager.clone(), config, - current_model: model.clone(), active_profile, file_search, enhanced_keys_supported, @@ -700,13 +708,9 @@ impl App { } async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { - let model_info = self - .server - .get_models_manager() - .get_model_info(self.current_model.as_str(), &self.config) - .await; match event { AppEvent::NewSession => { + let model = self.chat_widget.current_model().to_string(); let summary = session_summary(self.chat_widget.token_usage(), self.chat_widget.thread_id()); self.shutdown_current_thread().await; @@ -724,10 +728,9 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, - model: Some(self.current_model.clone()), + model: Some(model), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); - self.current_model = model_info.slug.clone(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { @@ -768,7 +771,6 @@ impl App { resumed.thread, resumed.session_configured, ); - self.current_model = model_info.slug.clone(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -818,7 +820,6 @@ impl App { forked.thread, forked.session_configured, ); - self.current_model = model_info.slug.clone(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -980,6 +981,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), }, @@ -1015,7 +1034,11 @@ impl App { } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); - self.current_model = model; + } + AppEvent::UpdateCollaborationMode(mode) => { + let model = mode.model().to_string(); + self.chat_widget.set_collaboration_mode(mode); + self.chat_widget.set_model(&model); } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); @@ -1489,6 +1512,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}"); @@ -1569,8 +1600,10 @@ impl App { } fn on_update_reasoning_effort(&mut self, effort: Option) { - self.chat_widget.set_reasoning_effort(effort); + // TODO(aibrahim): Remove this and don't use config as a state object. + // Instead, explicitly pass the stored collaboration mode's effort into new sessions. self.config.model_reasoning_effort = effort; + self.chat_widget.set_reasoning_effort(effort); } async fn launch_external_editor(&mut self, tui: &mut tui::Tui) { @@ -1772,7 +1805,6 @@ mod tests { async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -1787,7 +1819,6 @@ mod tests { chat_widget, auth_manager, config, - current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -1815,7 +1846,6 @@ mod tests { ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -1831,7 +1861,6 @@ mod tests { chat_widget, auth_manager, config, - current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -2017,20 +2046,19 @@ mod tests { } #[tokio::test] - async fn update_reasoning_effort_updates_config() { + async fn update_reasoning_effort_updates_collaboration_mode() { let mut app = make_test_app().await; - app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); assert_eq!( - app.config.model_reasoning_effort, + app.chat_widget.current_reasoning_effort(), Some(ReasoningEffortConfig::High) ); assert_eq!( - app.chat_widget.config_ref().model_reasoning_effort, + app.config.model_reasoning_effort, Some(ReasoningEffortConfig::High) ); } @@ -2070,9 +2098,14 @@ mod tests { }; Arc::new(new_session_info( app.chat_widget.config_ref(), - app.current_model.as_str(), + app.chat_widget.current_model(), event, is_first, + app.chat_widget + .config_ref() + .features + .enabled(codex_core::features::Feature::CollaborationModes), + app.chat_widget.stored_collaboration_mode().clone(), )) as Arc }; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 33fba8c564..fb25d50d29 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -23,6 +23,7 @@ use crate::history_cell::HistoryCell; use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ReasoningEffort; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -102,6 +103,9 @@ pub(crate) enum AppEvent { /// Update the current model slug in the running app and widget. UpdateModel(String), + /// Update the current collaboration mode in the running app and widget. + UpdateCollaborationMode(CollaborationMode), + /// Persist the selected model and reasoning effort to the appropriate config. PersistModelSelection { model: String, 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 55b40a9a06..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 { @@ -557,12 +575,7 @@ impl ChatComposer { let image_placeholders: HashSet = text_elements .iter() - .filter_map(|elem| { - elem.placeholder.as_ref().cloned().or_else(|| { - text.get(elem.byte_range.start..elem.byte_range.end) - .map(str::to_string) - }) - }) + .filter_map(|elem| elem.placeholder(&text).map(str::to_string)) .collect(); for (idx, path) in local_image_paths.into_iter().enumerate() { let placeholder = local_image_label_text(idx + 1); @@ -614,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; @@ -841,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()); @@ -867,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, ); @@ -897,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, ); @@ -1240,13 +1285,13 @@ impl ChatComposer { return None; } let placeholder = trimmed.get(new_start..new_end).map(str::to_string); - Some(TextElement { - byte_range: ByteRange { + Some(TextElement::new( + ByteRange { start: new_start, end: new_end, }, placeholder, - }) + )) }) .collect() } @@ -1287,7 +1332,7 @@ impl ChatComposer { rebuilt.push_str(&text[cursor..start]); } let elem_text = &text[start..end]; - let placeholder = elem.placeholder; + let placeholder = elem.placeholder(text).map(str::to_string); let replacement = placeholder .as_deref() .and_then(|ph| pending_by_placeholder.get_mut(ph)) @@ -1301,13 +1346,13 @@ impl ChatComposer { rebuilt.push_str(elem_text); let new_end = rebuilt.len(); let placeholder = placeholder.or_else(|| Some(elem_text.to_string())); - rebuilt_elements.push(TextElement { - byte_range: ByteRange { + rebuilt_elements.push(TextElement::new( + ByteRange { start: new_start, end: new_end, }, placeholder, - }); + )); } cursor = end; } @@ -1563,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 = @@ -1601,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() { @@ -1725,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) @@ -1746,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)) = @@ -1821,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, @@ -2500,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); @@ -2531,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(), } } } @@ -2559,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; @@ -3806,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] @@ -4232,7 +4309,7 @@ mod tests { } => { assert_eq!(text, "[Image #1] hi"); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4261,10 +4338,7 @@ mod tests { let placeholder = local_image_label_text(1); let text = format!("{placeholder} restored"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: None, - }]; + let text_elements = vec![TextElement::new((0..placeholder.len()).into(), None)]; let path = PathBuf::from("/tmp/image1.png"); composer.set_text_content(text, text_elements, vec![path.clone()]); @@ -4301,7 +4375,7 @@ mod tests { let expected = format!("{large_content} [Image #1]"); assert_eq!(text, expected); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4345,7 +4419,7 @@ mod tests { let trimmed = large_content.trim().to_string(); assert_eq!(text, format!("{trimmed} [Image #1]")); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4389,7 +4463,7 @@ mod tests { assert_eq!(text, "line1\nline2\n [Image #1]"); assert!(!text.contains('\r')); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4474,7 +4548,7 @@ mod tests { } => { assert_eq!(text, "[Image #1]"); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4664,20 +4738,20 @@ mod tests { let start2 = text.find(&placeholder2).expect("placeholder2 present"); let start1 = text.find(&placeholder1).expect("placeholder1 present"); let text_elements = vec![ - TextElement { - byte_range: ByteRange { + TextElement::new( + ByteRange { start: start2, end: start2 + placeholder2.len(), }, - placeholder: Some(placeholder2), - }, - TextElement { - byte_range: ByteRange { + Some(placeholder2), + ), + TextElement::new( + ByteRange { start: start1, end: start1 + placeholder1.len(), }, - placeholder: Some(placeholder1.clone()), - }, + Some(placeholder1.clone()), + ), ]; composer.set_text_content(text, text_elements, vec![path1, path2.clone()]); @@ -4876,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] @@ -4943,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; @@ -5161,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 ee23cad334..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) { @@ -701,11 +728,14 @@ impl BottomPane { if !self.unified_exec_footer.is_empty() { flex.push(0, RenderableItem::Borrowed(&self.unified_exec_footer)); } + let has_queued_messages = !self.queued_user_messages.messages.is_empty(); + let has_status_or_footer = + self.status.is_some() || !self.unified_exec_footer.is_empty(); + if has_queued_messages && has_status_or_footer { + flex.push(0, RenderableItem::Owned("".into())); + } flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages)); - if self.status.is_some() - || !self.unified_exec_footer.is_empty() - || !self.queued_user_messages.messages.is_empty() - { + if !has_queued_messages && has_status_or_footer { flex.push(0, RenderableItem::Owned("".into())); } let mut flex2 = FlexRenderable::new(); @@ -951,6 +981,60 @@ mod tests { ); } + #[test] + fn status_only_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!("status_only_snapshot", render_snapshot(&pane, area)); + } + + #[test] + fn status_with_details_and_queued_messages_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + pane.update_status( + "Working".to_string(), + Some("First detail line\nSecond detail line".to_string()), + ); + pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!( + "status_with_details_and_queued_messages_snapshot", + render_snapshot(&pane, area) + ); + } + #[test] fn queued_messages_visible_when_status_hidden_snapshot() { let (tx_raw, _rx) = unbounded_channel::(); 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/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap index 123a5eb3a3..5aea415190 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap @@ -5,7 +5,6 @@ expression: "render_snapshot(&pane, area)" ↳ Queued follow-up question ⌥ + ↑ edit - › Ask Codex to do anything 100% context left · ? for shortcuts diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap index 27df671e4d..e651ec9274 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap @@ -3,10 +3,10 @@ source: tui/src/bottom_pane/mod.rs expression: "render_snapshot(&pane, area)" --- • Working (0s • esc to interrupt) + ↳ Queued follow-up question ⌥ + ↑ edit - › Ask Codex to do anything 100% context left · ? for shortcuts diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_only_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_only_snapshot.snap new file mode 100644 index 0000000000..79e1e126eb --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_only_snapshot.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interrupt) + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap new file mode 100644 index 0000000000..12090d09e9 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interrupt) + └ First detail line + Second detail line + + ↳ Queued follow-up question + ⌥ + ↑ edit + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 926c53601a..3a849822b1 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -755,13 +755,13 @@ impl TextArea { .iter() .map(|e| { let placeholder = self.text.get(e.range.clone()).map(str::to_string); - UserTextElement { - byte_range: ByteRange { + UserTextElement::new( + ByteRange { start: e.range.start, end: e.range.end, }, placeholder, - } + ) }) .collect() } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0009c65745..1cb6a4cf36 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -92,8 +92,11 @@ use codex_core::skills::model::SkillMetadata; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; +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; @@ -374,8 +377,6 @@ pub(crate) enum ExternalEditorState { Active, } -type CollaborationModeSelection = collaboration_modes::Selection; - /// Maintains the per-session UI state and interaction state machines for the chat screen. /// /// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming @@ -404,12 +405,12 @@ pub(crate) struct ChatWidget { /// where the overlay may briefly treat new tail content as already cached. active_cell_revision: u64, config: Config, - model: Option, - /// Current UI selection for collaboration modes. + /// Stored collaboration mode with model and reasoning effort. /// - /// This selection is only meaningful when `Feature::CollaborationModes` is enabled; when the - /// feature is disabled, the value is effectively inert. - collaboration_mode: CollaborationModeSelection, + /// When collaboration modes feature is enabled, this is initialized to the first preset. + /// When disabled, this is Custom. The model and reasoning effort are stored here instead of + /// being read from config or current_model. + stored_collaboration_mode: CollaborationMode, auth_manager: Arc, models_manager: Arc, session_header: SessionHeader, @@ -614,9 +615,9 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) } let original = text.get(start..end).unwrap_or(""); - let placeholder_key = elem.placeholder.as_deref().unwrap_or(original); - let replacement = mapping - .get(placeholder_key) + let placeholder = elem.placeholder(&text); + let replacement = placeholder + .and_then(|ph| mapping.get(ph)) .map(String::as_str) .unwrap_or(original); @@ -624,8 +625,8 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) rebuilt.push_str(replacement); let elem_end = rebuilt.len(); - if let Some(remapped) = mapping.get(placeholder_key) { - elem.placeholder = Some(remapped.clone()); + if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) { + elem.set_placeholder(Some(remapped.clone())); } elem.byte_range = (elem_start..elem_end).into(); rebuilt_elements.push(elem); @@ -709,13 +710,24 @@ impl ChatWidget { self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); - self.model = Some(model_for_header.clone()); self.session_header.set_model(&model_for_header); + // Only update stored collaboration settings when collaboration modes are disabled. + // When enabled, we preserve the selected variant (Plan/Pair/Execute/Custom) and its + // instructions as-is; the session configured event should not override it. + if !self.collaboration_modes_enabled() { + self.stored_collaboration_mode = self.stored_collaboration_mode.with_updates( + Some(model_for_header.clone()), + Some(event.reasoning_effort), + None, + ); + } let session_info_cell = history_cell::new_session_info( &self.config, &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); @@ -963,7 +975,7 @@ impl ChatWidget { if high_usage && !self.rate_limit_switch_prompt_hidden() - && self.current_model() != Some(NUDGE_MODEL_SLUG) + && self.current_model() != NUDGE_MODEL_SLUG && !matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Shown @@ -1198,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) { @@ -1665,6 +1685,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( @@ -1789,23 +1815,36 @@ impl ChatWidget { is_first_run, model, } = common; - let mut config = config; let model = model.filter(|m| !m.trim().is_empty()); + let mut config = config; config.model = model.clone(); let mut rng = rand::rng(); 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 = config - .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)) + 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 { + model: model_for_header.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }) } else { - None + CollaborationMode::Custom(Settings { + model: model_for_header.clone(), + reasoning_effort: None, + 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(), frame_requester: frame_requester.clone(), @@ -1823,8 +1862,7 @@ impl ChatWidget { active_cell, active_cell_revision: 0, config, - model, - collaboration_mode: CollaborationModeSelection::default(), + stored_collaboration_mode, auth_manager, models_manager, session_header: SessionHeader::new(model_for_header), @@ -1905,6 +1943,22 @@ impl ChatWidget { let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); + let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }; + let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -1922,8 +1976,7 @@ impl ChatWidget { active_cell: None, active_cell_revision: 0, config, - model: Some(header_model.clone()), - collaboration_mode: CollaborationModeSelection::default(), + stored_collaboration_mode, auth_manager, models_manager, session_header: SessionHeader::new(header_model), @@ -2204,7 +2257,7 @@ impl ChatWidget { } SlashCommand::Collab => { if self.collaboration_modes_enabled() { - self.cycle_collaboration_mode(); + self.open_collaboration_modes_popup(); } } SlashCommand::Approvals => { @@ -2363,14 +2416,10 @@ impl ChatWidget { let trimmed = args.trim(); match cmd { - SlashCommand::Collab if !trimmed.is_empty() => { - if let Some(selection) = collaboration_modes::parse_selection(trimmed) { - self.set_collaboration_mode(selection); - } else { - self.add_error_message(format!( - "Unknown collaboration mode '{trimmed}'. Try: plan, pair, execute." - )); - self.request_redraw(); + SlashCommand::Collab => { + let _ = trimmed; + if self.collaboration_modes_enabled() { + self.open_collaboration_modes_popup(); } } SlashCommand::Review if !trimmed.is_empty() => { @@ -2450,13 +2499,12 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { - let Some(model) = self.current_model().or(self.config.model.as_deref()) else { - tracing::warn!("cannot submit user message before model is known; queueing"); + if !self.is_session_configured() { + tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); self.refresh_queued_user_messages(); return; - }; - let model = model.to_string(); + } let UserMessage { text, @@ -2510,24 +2558,18 @@ impl ChatWidget { } } - let collaboration_mode = self.collaboration_modes_enabled().then(|| { - collaboration_modes::resolve_mode_or_fallback( - self.models_manager.as_ref(), - self.collaboration_mode, - model.as_str(), - self.config.model_reasoning_effort, - ) - }); let op = Op::UserTurn { items, cwd: self.config.cwd.clone(), approval_policy: self.config.approval_policy.value(), sandbox_policy: self.config.sandbox_policy.get().clone(), - model, - effort: self.config.model_reasoning_effort, + model: self.stored_collaboration_mode.model().to_string(), + effort: self.stored_collaboration_mode.reasoning_effort(), summary: self.config.model_reasoning_summary, final_output_json_schema: None, - collaboration_mode, + collaboration_mode: self + .collaboration_modes_enabled() + .then(|| self.stored_collaboration_mode.clone()), }; self.codex_op_tx.send(op).unwrap_or_else(|e| { @@ -2647,6 +2689,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), @@ -2707,8 +2752,7 @@ impl ChatWidget { | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) - | EventMsg::RequestUserInput(_) => {} + | EventMsg::ReasoningRawContentDelta(_) => {} } } @@ -2868,11 +2912,8 @@ impl ChatWidget { let total_usage = token_info .map(|ti| &ti.total_token_usage) .unwrap_or(&default_usage); - let collaboration_mode = if self.collaboration_modes_enabled() { - Some(self.collaboration_mode.label()) - } else { - None - }; + let collaboration_mode = self.collaboration_mode_label(); + let reasoning_effort_override = Some(self.stored_collaboration_mode.reasoning_effort()); self.add_to_history(crate::status::new_status_output( &self.config, self.auth_manager.as_ref(), @@ -2885,6 +2926,7 @@ impl ChatWidget { Local::now(), self.model_display_name(), collaboration_mode, + reasoning_effort_override, )); } @@ -3108,7 +3150,7 @@ impl ChatWidget { let current_model = self.current_model(); let current_label = presets .iter() - .find(|preset| Some(preset.model.as_str()) == current_model) + .find(|preset| preset.model.as_str() == current_model) .map(|preset| preset.display_name.to_string()) .unwrap_or_else(|| self.model_display_name().to_string()); @@ -3136,7 +3178,7 @@ impl ChatWidget { SelectionItem { name: preset.display_name.clone(), description, - is_current: Some(model.as_str()) == current_model, + is_current: model.as_str() == current_model, is_default: preset.is_default, actions, dismiss_on_select: true, @@ -3206,7 +3248,7 @@ impl ChatWidget { for preset in presets.into_iter() { let description = (!preset.description.is_empty()).then_some(preset.description.to_string()); - let is_current = Some(preset.model.as_str()) == self.current_model(); + let is_current = preset.model.as_str() == self.current_model(); let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; let preset_for_action = preset.clone(); let actions: Vec = vec![Box::new(move |tx| { @@ -3238,6 +3280,49 @@ impl ChatWidget { }); } + pub(crate) fn open_collaboration_modes_popup(&mut self) { + let presets = self.models_manager.list_collaboration_modes(); + if presets.is_empty() { + self.add_info_message( + "No collaboration modes are available right now.".to_string(), + None, + ); + return; + } + + let items: Vec = presets + .into_iter() + .map(|preset| { + let name = match preset { + CollaborationMode::Plan(_) => "Plan", + CollaborationMode::PairProgramming(_) => "Pair Programming", + CollaborationMode::Execute(_) => "Execute", + CollaborationMode::Custom(_) => "Custom", + }; + let is_current = + collaboration_modes::same_variant(&self.stored_collaboration_mode, &preset); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::UpdateCollaborationMode(preset.clone())); + })]; + SelectionItem { + name: name.to_string(), + is_current, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Collaboration Mode".to_string()), + subtitle: Some("Pick a collaboration preset.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + fn model_selection_actions( model_for_action: String, effort_for_action: Option, @@ -3333,9 +3418,9 @@ impl ChatWidget { .or(Some(default_effort)); let model_slug = preset.model.to_string(); - let is_current_model = self.current_model() == Some(preset.model.as_str()); + let is_current_model = self.current_model() == preset.model.as_str(); let highlight_choice = if is_current_model { - self.config.model_reasoning_effort + self.stored_collaboration_mode.reasoning_effort() } else { default_choice }; @@ -4134,8 +4219,21 @@ impl ChatWidget { } if feature == Feature::Steer { self.bottom_pane.set_steer_enabled(enabled); - } else if feature == Feature::CollaborationModes { + } + if feature == Feature::CollaborationModes { self.bottom_pane.set_collaboration_modes_enabled(enabled); + let settings = match &self.stored_collaboration_mode { + CollaborationMode::Plan(settings) + | CollaborationMode::PairProgramming(settings) + | CollaborationMode::Execute(settings) + | CollaborationMode::Custom(settings) => settings.clone(), + }; + self.stored_collaboration_mode = if enabled { + collaboration_modes::default_mode(self.models_manager.as_ref()) + .unwrap_or(CollaborationMode::Custom(settings)) + } else { + CollaborationMode::Custom(settings) + }; } } @@ -4162,44 +4260,37 @@ impl ChatWidget { .unwrap_or(false) } - /// Set the reasoning effort in the widget's config copy. + /// Set the reasoning effort in the stored collaboration mode. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { - self.config.model_reasoning_effort = effort; + self.stored_collaboration_mode = + self.stored_collaboration_mode + .with_updates(None, Some(effort), None); } - /// Set the model in the widget's config copy. + /// Set the model in the widget's config copy and stored collaboration mode. pub(crate) fn set_model(&mut self, model: &str) { self.session_header.set_model(model); - self.model = Some(model.to_string()); + self.stored_collaboration_mode = + self.stored_collaboration_mode + .with_updates(Some(model.to_string()), None, None); } - fn cycle_collaboration_mode(&mut self) { - if !self.collaboration_modes_enabled() { - return; - } - let next = self.collaboration_mode.next(); - self.set_collaboration_mode(next); + pub(crate) fn current_model(&self) -> &str { + self.stored_collaboration_mode.model() } - /// Update the selected collaboration mode. - /// - /// When collaboration modes are enabled, the current selection is attached to *every* - /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. - fn set_collaboration_mode(&mut self, selection: CollaborationModeSelection) { - if !self.collaboration_modes_enabled() { - return; - } - const FLASH_DURATION: Duration = Duration::from_secs(1); - - self.collaboration_mode = selection; - - let flash = collaboration_modes::flash_line(selection); - self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); - self.request_redraw(); + #[allow(dead_code)] // Used in tests + pub(crate) fn stored_collaboration_mode(&self) -> &CollaborationMode { + &self.stored_collaboration_mode } - fn current_model(&self) -> Option<&str> { - self.model.as_deref() + #[cfg(test)] + pub(crate) fn current_reasoning_effort(&self) -> Option { + self.stored_collaboration_mode.reasoning_effort() + } + + fn is_session_configured(&self) -> bool { + self.thread_id.is_some() } fn collaboration_modes_enabled(&self) -> bool { @@ -4207,11 +4298,72 @@ impl ChatWidget { } fn model_display_name(&self) -> &str { - self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) + let model = self.current_model(); + if model.is_empty() { + DEFAULT_MODEL_DISPLAY_NAME + } else { + model + } + } + + /// Get the label for the current collaboration mode. + fn collaboration_mode_label(&self) -> Option<&'static str> { + if !self.collaboration_modes_enabled() { + return None; + } + match &self.stored_collaboration_mode { + CollaborationMode::Plan(_) => Some("Plan"), + CollaborationMode::PairProgramming(_) => Some("Pair Programming"), + CollaborationMode::Execute(_) => Some("Execute"), + CollaborationMode::Custom(_) => None, + } + } + + /// Cycle to the next collaboration mode variant (Plan -> PairProgramming -> Execute -> Plan). + fn cycle_collaboration_mode(&mut self) { + if !self.collaboration_modes_enabled() { + return; + } + + if let Some(next_mode) = collaboration_modes::next_mode( + self.models_manager.as_ref(), + &self.stored_collaboration_mode, + ) { + self.set_collaboration_mode(next_mode); + } + } + + /// Update the stored collaboration mode. + /// + /// When collaboration modes are enabled, the current mode is attached to *every* + /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. + pub(crate) fn set_collaboration_mode(&mut self, mode: CollaborationMode) { + if !self.collaboration_modes_enabled() { + return; + } + + self.stored_collaboration_mode = mode; + + let label = self.collaboration_mode_label(); + if let Some(label) = label { + let flash = Line::from(vec![ + label.bold(), + " (".dim(), + key_hint::shift(KeyCode::Tab).into(), + " to change mode)".dim(), + ]); + const FLASH_DURATION: Duration = Duration::from_secs(2); + self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); + } + self.request_redraw(); } /// 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(), @@ -4219,6 +4371,8 @@ impl ChatWidget { None, config.cwd.clone(), CODEX_CLI_VERSION, + is_collaboration, + collaboration_mode, )) } @@ -4618,10 +4772,6 @@ impl ChatWidget { self.thread_id } - fn is_session_configured(&self) -> bool { - self.thread_id.is_some() - } - pub(crate) fn rollout_path(&self) -> Option { self.current_rollout_path.clone() } 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__chatwidget_tall.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap index 6d9aa515b1..64361e90f9 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap @@ -2,7 +2,9 @@ source: tui/src/chatwidget/tests.rs expression: term.backend().vt100().screen().contents() --- + • Working (0s • esc to interrupt) + ↳ Hello, world! 0 ↳ Hello, world! 1 ↳ Hello, world! 2 @@ -21,7 +23,6 @@ expression: term.backend().vt100().screen().contents() ↳ Hello, world! 15 ↳ Hello, world! 16 - › Ask Codex to do anything 100% context left · ? for shortcuts 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__review_queues_user_messages_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap index 2a7717df7c..1c02350a6d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 3840 expression: term.backend().vt100().screen().contents() --- @@ -14,10 +13,10 @@ expression: term.backend().vt100().screen().contents() • Working (0s • esc to interrupt) + ↳ Queued while /review is running. ⌥ + ↑ edit - › Ask Codex to do anything 100% context left · ? for shortcuts diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f29fad5461..4374049bc2 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -193,10 +193,10 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { let placeholder = "[Image #1]"; let message = format!("{placeholder} replayed"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: Some(placeholder.to_string()), - }]; + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; let local_images = vec![PathBuf::from("/tmp/replay.png")]; let conversation_id = ThreadId::new(); @@ -275,10 +275,10 @@ async fn submission_preserves_text_elements_and_local_images() { let placeholder = "[Image #1]"; let text = format!("{placeholder} submit"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: Some(placeholder.to_string()), - }]; + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; let local_images = vec![PathBuf::from("/tmp/submitted.png")]; chat.bottom_pane @@ -331,26 +331,26 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { let first_placeholder = "[Image #1]"; let first_text = format!("{first_placeholder} first"); - let first_elements = vec![TextElement { - byte_range: (0..first_placeholder.len()).into(), - placeholder: Some(first_placeholder.to_string()), - }]; + let first_elements = vec![TextElement::new( + (0..first_placeholder.len()).into(), + Some(first_placeholder.to_string()), + )]; let first_images = [PathBuf::from("/tmp/first.png")]; let second_placeholder = "[Image #1]"; let second_text = format!("{second_placeholder} second"); - let second_elements = vec![TextElement { - byte_range: (0..second_placeholder.len()).into(), - placeholder: Some(second_placeholder.to_string()), - }]; + let second_elements = vec![TextElement::new( + (0..second_placeholder.len()).into(), + Some(second_placeholder.to_string()), + )]; let second_images = [PathBuf::from("/tmp/second.png")]; let existing_placeholder = "[Image #1]"; let existing_text = format!("{existing_placeholder} existing"); - let existing_elements = vec![TextElement { - byte_range: (0..existing_placeholder.len()).into(), - placeholder: Some(existing_placeholder.to_string()), - }]; + let existing_elements = vec![TextElement::new( + (0..existing_placeholder.len()).into(), + Some(existing_placeholder.to_string()), + )]; let existing_images = vec![PathBuf::from("/tmp/existing.png")]; chat.queued_user_messages.push_back(UserMessage { @@ -393,18 +393,18 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { let second_start = first.len() + 1; let third_start = second_start + second.len() + 1; let expected_elements = vec![ - TextElement { - byte_range: (first_start..first_start + "[Image #1]".len()).into(), - placeholder: Some("[Image #1]".to_string()), - }, - TextElement { - byte_range: (second_start..second_start + "[Image #2]".len()).into(), - placeholder: Some("[Image #2]".to_string()), - }, - TextElement { - byte_range: (third_start..third_start + "[Image #3]".len()).into(), - placeholder: Some("[Image #3]".to_string()), - }, + TextElement::new( + (first_start..first_start + "[Image #1]".len()).into(), + Some("[Image #1]".to_string()), + ), + TextElement::new( + (second_start..second_start + "[Image #2]".len()).into(), + Some("[Image #2]".to_string()), + ), + TextElement::new( + (third_start..third_start + "[Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ]; assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements); assert_eq!( @@ -423,14 +423,14 @@ async fn remap_placeholders_uses_attachment_labels() { let placeholder_two = "[Image #2]"; let text = format!("{placeholder_two} before {placeholder_one}"); let elements = vec![ - TextElement { - byte_range: (0..placeholder_two.len()).into(), - placeholder: Some(placeholder_two.to_string()), - }, - TextElement { - byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), - placeholder: Some(placeholder_one.to_string()), - }, + TextElement::new( + (0..placeholder_two.len()).into(), + Some(placeholder_two.to_string()), + ), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + Some(placeholder_one.to_string()), + ), ]; let attachments = vec![ @@ -455,15 +455,14 @@ async fn remap_placeholders_uses_attachment_labels() { assert_eq!( remapped.text_elements, vec![ - TextElement { - byte_range: (0.."[Image #4]".len()).into(), - placeholder: Some("[Image #4]".to_string()), - }, - TextElement { - byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()) - .into(), - placeholder: Some("[Image #3]".to_string()), - }, + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ] ); assert_eq!( @@ -487,14 +486,11 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { let placeholder_two = "[Image #2]"; let text = format!("{placeholder_two} before {placeholder_one}"); let elements = vec![ - TextElement { - byte_range: (0..placeholder_two.len()).into(), - placeholder: None, - }, - TextElement { - byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), - placeholder: None, - }, + TextElement::new((0..placeholder_two.len()).into(), None), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + None, + ), ]; let attachments = vec![ @@ -519,15 +515,14 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { assert_eq!( remapped.text_elements, vec![ - TextElement { - byte_range: (0.."[Image #4]".len()).into(), - placeholder: Some("[Image #4]".to_string()), - }, - TextElement { - byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()) - .into(), - placeholder: Some("[Image #3]".to_string()), - }, + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ] ); assert_eq!( @@ -766,8 +761,27 @@ async fn make_chatwidget_manual( skills: None, }); bottom.set_steer_enabled(true); + bottom.set_collaboration_modes_enabled(cfg.features.enabled(Feature::CollaborationModes)); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let codex_home = cfg.codex_home.clone(); + let models_manager = Arc::new(ModelsManager::new(codex_home, auth_manager.clone())); + let collaboration_modes_enabled = cfg.features.enabled(Feature::CollaborationModes); + let reasoning_effort = None; + let stored_collaboration_mode = if collaboration_modes_enabled { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }) + }; let widget = ChatWidget { app_event_tx, codex_op_tx: op_tx, @@ -775,10 +789,9 @@ async fn make_chatwidget_manual( active_cell: None, active_cell_revision: 0, config: cfg, - model: Some(resolved_model.clone()), - collaboration_mode: CollaborationModeSelection::default(), - auth_manager: auth_manager.clone(), - models_manager: Arc::new(ModelsManager::new(codex_home, auth_manager)), + stored_collaboration_mode, + auth_manager, + models_manager, session_header: SessionHeader::new(resolved_model), initial_user_message: None, token_info: None, @@ -1919,75 +1932,66 @@ async fn slash_init_skips_when_project_doc_exists() { ); } -#[test] -fn parse_collaboration_mode_selection_accepts_common_aliases() { - assert_eq!( - collaboration_modes::parse_selection("plan"), - Some(CollaborationModeSelection::Plan) - ); - assert_eq!( - collaboration_modes::parse_selection("PAIR"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection("pair_programming"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection("pp"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection(" exec "), - Some(CollaborationModeSelection::Execute) - ); - assert_eq!( - collaboration_modes::parse_selection("execute"), - Some(CollaborationModeSelection::Execute) - ); - assert_eq!(collaboration_modes::parse_selection("unknown"), None); -} - #[tokio::test] async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; chat.set_feature_enabled(Feature::CollaborationModes, false); - let initial = chat.collaboration_mode; + let initial = chat.stored_collaboration_mode.clone(); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, initial); + assert_eq!(chat.stored_collaboration_mode, initial); chat.set_feature_enabled(Feature::CollaborationModes, true); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Execute); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::Execute(_) + )); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::Plan(_) + )); chat.on_task_started(); + let before = chat.stored_collaboration_mode.clone(); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + assert_eq!(chat.stored_collaboration_mode, before); } #[tokio::test] -async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; +async fn collab_slash_command_opens_picker_and_updates_mode() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.dispatch_command_with_args(SlashCommand::Collab, "plan".to_string()); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + chat.dispatch_command(SlashCommand::Collab); + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Select Collaboration Mode"), + "expected collaboration picker: {popup}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let selected_mode = match rx.try_recv() { + Ok(AppEvent::UpdateCollaborationMode(mode)) => mode, + other => panic!("expected UpdateCollaborationMode event, got {other:?}"), + }; + chat.set_collaboration_mode(selected_mode); chat.bottom_pane .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::Plan(_)), + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } chat.bottom_pane @@ -1995,10 +1999,12 @@ async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::Plan(_)), + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } } @@ -2016,10 +2022,22 @@ async fn collab_mode_defaults_to_pair_programming_when_enabled() { collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } } +#[tokio::test] +async fn collab_mode_enabling_sets_pair_programming_default() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::PairProgramming(_) + )); +} + #[tokio::test] async fn slash_quit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2732,7 +2750,7 @@ async fn model_reasoning_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); @@ -2746,7 +2764,7 @@ async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); diff --git a/codex-rs/tui/src/collaboration_modes.rs b/codex-rs/tui/src/collaboration_modes.rs index bdd1d8b63c..f48ad92c91 100644 --- a/codex-rs/tui/src/collaboration_modes.rs +++ b/codex-rs/tui/src/collaboration_modes.rs @@ -1,135 +1,49 @@ -//! Collaboration mode selection + rendering helpers for the TUI. -//! -//! This module is intentionally UI-focused: -//! - It owns the user-facing set of selectable collaboration modes and how they cycle. -//! - It parses `/collab ` arguments into a selection. -//! - It resolves a `Selection` to a concrete `codex_protocol::config_types::CollaborationMode` by -//! picking from the `ModelsManager` builtin collaboration presets. -//! - It builds the small footer "flash" line shown after changing modes. -//! -//! The `ChatWidget` owns the session state and decides *when* selection/mode changes are allowed -//! (feature flag, task running, modals open, etc.). This module just provides the building blocks. - -use crate::key_hint; use codex_core::models_manager::manager::ModelsManager; use codex_protocol::config_types::CollaborationMode; -use codex_protocol::config_types::Settings; -use codex_protocol::openai_models::ReasoningEffort; -use crossterm::event::KeyCode; -use ratatui::style::Stylize; -use ratatui::text::Line; -/// The user-facing collaboration mode choices supported by the TUI. -/// -/// This is distinct from `CollaborationMode`: it represents a stable UI selection and the cycling -/// order, while `CollaborationMode` can carry nested settings/prompt configuration. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(crate) enum Selection { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ModeKind { Plan, - #[default] PairProgramming, Execute, + Custom, } -impl Selection { - /// Cycle to the next selection. - /// - /// The TUI cycles through a small, fixed set of presets. - pub(crate) fn next(self) -> Self { - match self { - Self::Plan => Self::PairProgramming, - Self::PairProgramming => Self::Execute, - Self::Execute => Self::Plan, - } - } - - /// User-facing label used in UI surfaces like `/status` and the footer flash. - pub(crate) fn label(self) -> &'static str { - match self { - Self::Plan => "Plan", - Self::PairProgramming => "Pair Programming", - Self::Execute => "Execute", - } +fn mode_kind(mode: &CollaborationMode) -> ModeKind { + match mode { + CollaborationMode::Plan(_) => ModeKind::Plan, + CollaborationMode::PairProgramming(_) => ModeKind::PairProgramming, + CollaborationMode::Execute(_) => ModeKind::Execute, + CollaborationMode::Custom(_) => ModeKind::Custom, } } -/// Parse a user argument (e.g. `/collab plan`, `/collab pair_programming`) into a selection. -/// -/// The parser is forgiving: it strips whitespace, `-`, and `_`, and matches case-insensitively. -pub(crate) fn parse_selection(input: &str) -> Option { - let normalized: String = input - .chars() - .filter(|c| !c.is_ascii_whitespace() && *c != '-' && *c != '_') - .flat_map(char::to_lowercase) - .collect(); - - match normalized.as_str() { - "plan" => Some(Selection::Plan), - "pair" | "pairprogramming" | "pp" => Some(Selection::PairProgramming), - "execute" | "exec" => Some(Selection::Execute), - _ => None, - } +pub(crate) fn default_mode(models_manager: &ModelsManager) -> Option { + let presets = models_manager.list_collaboration_modes(); + presets + .iter() + .find(|preset| matches!(preset, CollaborationMode::PairProgramming(_))) + .cloned() + .or_else(|| presets.into_iter().next()) } -/// Resolve a selection to a concrete collaboration mode preset. -/// -/// `ModelsManager::list_collaboration_modes()` is expected to return a builtin set of presets; this -/// function selects the first preset of the desired variant. -pub(crate) fn resolve_mode( +pub(crate) fn same_variant(a: &CollaborationMode, b: &CollaborationMode) -> bool { + mode_kind(a) == mode_kind(b) +} + +/// Cycle to the next collaboration mode preset in list order. +pub(crate) fn next_mode( models_manager: &ModelsManager, - selection: Selection, + current: &CollaborationMode, ) -> Option { - match selection { - Selection::Plan => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::Plan(_))), - Selection::PairProgramming => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::PairProgramming(_))), - Selection::Execute => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::Execute(_))), + let presets = models_manager.list_collaboration_modes(); + if presets.is_empty() { + return None; } -} - -/// Resolve a selection to a concrete collaboration mode preset, falling back to a synthesized mode -/// when the desired preset is unavailable. -/// -/// This keeps the TUI behavior stable when collaboration presets are missing (for example, when -/// running in offline/unit-test contexts): if the feature flag is enabled, every submission carries -/// an explicit collaboration mode so core doesn't fall back to `Custom`. -pub(crate) fn resolve_mode_or_fallback( - models_manager: &ModelsManager, - selection: Selection, - fallback_model: &str, - fallback_effort: Option, -) -> CollaborationMode { - resolve_mode(models_manager, selection).unwrap_or_else(|| { - let settings = Settings { - model: fallback_model.to_string(), - reasoning_effort: fallback_effort, - developer_instructions: None, - }; - - match selection { - Selection::Plan => CollaborationMode::Plan(settings), - Selection::PairProgramming => CollaborationMode::PairProgramming(settings), - Selection::Execute => CollaborationMode::Execute(settings), - } - }) -} - -/// Build a 1-line footer "flash" that is shown after switching modes. -/// -/// The `ChatWidget` controls when to show this and how long it should remain visible. -pub(crate) fn flash_line(selection: Selection) -> Line<'static> { - Line::from(vec![ - selection.label().bold(), - " (".dim(), - key_hint::shift(KeyCode::Tab).into(), - " to change mode)".dim(), - ]) + let current_kind = mode_kind(current); + let next_index = presets + .iter() + .position(|preset| mode_kind(preset) == current_kind) + .map_or(0, |idx| (idx + 1) % presets.len()); + presets.get(next_index).cloned() } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index fd09fe89dc..9ad40e3afe 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/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::live_wrap::take_prefix_by_width; use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; @@ -43,11 +44,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; @@ -903,6 +906,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, @@ -915,6 +920,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)]; @@ -991,6 +998,8 @@ pub(crate) struct SessionHeaderHistoryCell { model_style: Style, reasoning_effort: Option, directory: PathBuf, + is_collaboration: bool, + collaboration_mode: CollaborationMode, } impl SessionHeaderHistoryCell { @@ -999,6 +1008,8 @@ impl SessionHeaderHistoryCell { reasoning_effort: Option, directory: PathBuf, version: &'static str, + is_collaboration: bool, + collaboration_mode: CollaborationMode, ) -> Self { Self::new_with_style( model, @@ -1006,6 +1017,8 @@ impl SessionHeaderHistoryCell { reasoning_effort, directory, version, + is_collaboration, + collaboration_mode, ) } @@ -1015,6 +1028,8 @@ impl SessionHeaderHistoryCell { reasoning_effort: Option, directory: PathBuf, version: &'static str, + is_collaboration: bool, + collaboration_mode: CollaborationMode, ) -> Self { Self { version, @@ -1022,6 +1037,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, } } @@ -1082,25 +1111,51 @@ 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 { + // Render collaboration mode instead of model + 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 { + // Render model as before + let model_label = format!( + "{model_label:, - active_profile: Option, feedback: codex_feedback::CodexFeedback, ) -> color_eyre::Result { color_eyre::install()?; @@ -405,15 +398,15 @@ async fn run_ratatui_app( initial_config.cli_auth_credentials_store_mode, ); let login_status = get_login_status(&initial_config); - let should_show_trust_screen = should_show_trust_screen(&initial_config); + let should_show_trust_screen_flag = should_show_trust_screen(&initial_config); let should_show_onboarding = - should_show_onboarding(login_status, &initial_config, should_show_trust_screen); + should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag); let config = if should_show_onboarding { let onboarding_result = run_onboarding_app( OnboardingScreenArgs { show_login_screen: should_show_login_screen(login_status, &initial_config), - show_trust_screen: should_show_trust_screen, + show_trust_screen: should_show_trust_screen_flag, login_status, auth_manager: auth_manager.clone(), config: initial_config.clone(), @@ -438,7 +431,7 @@ async fn run_ratatui_app( .map(|d| d == TrustDirectorySelection::Trust) .unwrap_or(false) { - load_config_or_exit(cli_kv_overrides, overrides).await + load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await } else { initial_config } @@ -571,6 +564,33 @@ async fn run_ratatui_app( resume_picker::SessionSelection::StartFresh }; + let config = match &session_selection { + resume_picker::SessionSelection::Resume(path) + | resume_picker::SessionSelection::Fork(path) => { + let history_cwd = match read_session_meta_line(path).await { + Ok(meta_line) => Some(meta_line.meta.cwd), + Err(err) => { + let rollout_path = path.display().to_string(); + tracing::warn!( + %rollout_path, + %err, + "Failed to read session metadata from rollout" + ); + None + } + }; + load_config_or_exit_with_fallback_cwd( + cli_kv_overrides.clone(), + overrides.clone(), + history_cwd, + ) + .await + } + _ => config, + }; + let active_profile = config.active_profile.clone(); + let should_show_trust_screen = should_show_trust_screen(&config); + let Cli { prompt, images, @@ -672,9 +692,23 @@ fn get_login_status(config: &Config) -> LoginStatus { async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, +) -> Config { + load_config_or_exit_with_fallback_cwd(cli_kv_overrides, overrides, None).await +} + +async fn load_config_or_exit_with_fallback_cwd( + cli_kv_overrides: Vec<(String, toml::Value)>, + overrides: ConfigOverrides, + fallback_cwd: Option, ) -> Config { #[allow(clippy::print_stderr)] - match Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await { + match ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .fallback_cwd(fallback_cwd) + .build() + .await + { Ok(config) => config, Err(err) => { eprintln!("Error loading configuration: {err}"); diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 1f903c26b7..5e2fe57b8a 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -5,7 +5,8 @@ use crate::history_cell::with_border_with_inner_width; use crate::version::CODEX_CLI_VERSION; use chrono::DateTime; use chrono::Local; -use codex_common::create_config_summary_entries; +use codex_common::summarize_sandbox_policy; +use codex_core::WireApi; use codex_core::config::Config; use codex_core::protocol::NetworkAccess; use codex_core::protocol::SandboxPolicy; @@ -13,6 +14,7 @@ use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; +use codex_protocol::openai_models::ReasoningEffort; use ratatui::prelude::*; use ratatui::style::Stylize; use std::collections::BTreeSet; @@ -85,6 +87,7 @@ pub(crate) fn new_status_output( now: DateTime, model_name: &str, collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let card = StatusHistoryCell::new( @@ -99,6 +102,7 @@ pub(crate) fn new_status_output( now, model_name, collaboration_mode, + reasoning_effort_override, ); CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) @@ -118,8 +122,29 @@ impl StatusHistoryCell { now: DateTime, model_name: &str, collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, ) -> Self { - let config_entries = create_config_summary_entries(config, model_name); + let mut config_entries = vec![ + ("workdir", config.cwd.display().to_string()), + ("model", model_name.to_string()), + ("provider", config.model_provider_id.clone()), + ("approval", config.approval_policy.value().to_string()), + ( + "sandbox", + summarize_sandbox_policy(config.sandbox_policy.get()), + ), + ]; + if config.model_provider.wire_api == WireApi::Responses { + let effort_value = reasoning_effort_override + .unwrap_or(None) + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "none".to_string()); + config_entries.push(("reasoning effort", effort_value)); + config_entries.push(( + "reasoning summaries", + config.model_reasoning_summary.to_string(), + )); + } let (model_name, model_details) = compose_model_display(model_name, &config_entries); let approval = config_entries .iter() diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index bc421da424..92ed340817 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -95,7 +95,6 @@ async fn status_snapshot_includes_reasoning_details() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; config .sandbox_policy @@ -141,6 +140,7 @@ async fn status_snapshot_includes_reasoning_details() { let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); let composite = new_status_output( &config, &auth_manager, @@ -153,6 +153,7 @@ async fn status_snapshot_includes_reasoning_details() { captured_at, &model_slug, None, + reasoning_effort_override, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -205,6 +206,7 @@ async fn status_snapshot_includes_forked_from() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -263,6 +265,7 @@ async fn status_snapshot_includes_monthly_limit() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -309,6 +312,7 @@ async fn status_snapshot_shows_unlimited_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -354,6 +358,7 @@ async fn status_snapshot_shows_positive_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -399,6 +404,7 @@ async fn status_snapshot_hides_zero_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -442,6 +448,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -485,6 +492,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { now, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); @@ -500,7 +508,6 @@ async fn status_snapshot_truncates_in_narrow_terminal() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; config.cwd = PathBuf::from("/workspace/tests"); @@ -531,6 +538,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); let composite = new_status_output( &config, &auth_manager, @@ -543,6 +551,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { captured_at, &model_slug, None, + reasoning_effort_override, ); let mut rendered_lines = render_lines(&composite.display_lines(70)); if cfg!(windows) { @@ -590,6 +599,7 @@ async fn status_snapshot_shows_missing_limits_message() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -655,6 +665,7 @@ async fn status_snapshot_includes_credits_and_limits() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -708,6 +719,7 @@ async fn status_snapshot_shows_empty_limits_message() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -770,6 +782,7 @@ async fn status_snapshot_shows_stale_limits_message() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -836,6 +849,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -892,6 +906,7 @@ async fn status_context_window_uses_last_usage() { now, &model_slug, None, + None, ); let rendered_lines = render_lines(&composite.display_lines(80)); let context_line = rendered_lines diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index c89e640038..177ef34fcc 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -48,6 +48,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; @@ -317,7 +318,6 @@ async fn handle_model_migration_prompt_if_needed( from_model: model.to_string(), to_model: target_model.clone(), }); - config.model = Some(target_model.clone()); let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping && let Some(reasoning_effort) = config.model_reasoning_effort @@ -330,8 +330,8 @@ async fn handle_model_migration_prompt_if_needed( config.model_reasoning_effort }; + config.model = Some(target_model.clone()); config.model_reasoning_effort = mapped_effort; - app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort)); app_event_tx.send(AppEvent::PersistModelSelection { @@ -367,7 +367,6 @@ pub(crate) struct App { pub(crate) auth_manager: Arc, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, - pub(crate) current_model: String, pub(crate) active_profile: Option, pub(crate) file_search: FileSearchManager, @@ -451,7 +450,7 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, - model: Some(self.current_model.clone()), + model: Some(self.chat_widget.current_model().to_string()), } } @@ -530,7 +529,7 @@ impl App { models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, - model: config.model.clone(), + model: Some(model.clone()), }; ChatWidget::new(init, thread_manager.clone()) } @@ -618,7 +617,6 @@ impl App { chat_widget, auth_manager: auth_manager.clone(), config, - current_model: model.clone(), active_profile, file_search, enhanced_keys_supported, @@ -1507,6 +1505,9 @@ impl App { async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { AppEvent::NewSession => { + let model = self.chat_widget.current_model().to_string(); + // Propagate selected model into config before spawning thread + self.config.model = Some(model.clone()); let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.conversation_id(), @@ -1526,7 +1527,7 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, - model: Some(self.current_model.clone()), + model: Some(model), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); if let Some(summary) = summary { @@ -1808,7 +1809,11 @@ impl App { } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); - self.current_model = model; + } + AppEvent::UpdateCollaborationMode(mode) => { + let model = mode.model().to_string(); + self.chat_widget.set_collaboration_mode(mode); + self.chat_widget.set_model(&model); } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); @@ -2082,6 +2087,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; } @@ -2321,8 +2359,10 @@ impl App { } fn on_update_reasoning_effort(&mut self, effort: Option) { - self.chat_widget.set_reasoning_effort(effort); + // TODO(aibrahim): Remove this and don't use config as a state object. + // Instead, explicitly pass the stored collaboration mode's effort into new sessions. self.config.model_reasoning_effort = effort; + self.chat_widget.set_reasoning_effort(effort); } async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { @@ -2543,7 +2583,6 @@ mod tests { async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -2558,7 +2597,6 @@ mod tests { chat_widget, auth_manager, config, - current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -2599,7 +2637,6 @@ mod tests { ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -2615,7 +2652,6 @@ mod tests { chat_widget, auth_manager, config, - current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -2817,20 +2853,19 @@ mod tests { } #[tokio::test] - async fn update_reasoning_effort_updates_config() { + async fn update_reasoning_effort_updates_collaboration_mode() { let mut app = make_test_app().await; - app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); assert_eq!( - app.config.model_reasoning_effort, + app.chat_widget.current_reasoning_effort(), Some(ReasoningEffortConfig::High) ); assert_eq!( - app.chat_widget.config_ref().model_reasoning_effort, + app.config.model_reasoning_effort, Some(ReasoningEffortConfig::High) ); } @@ -2870,9 +2905,17 @@ mod tests { }; Arc::new(new_session_info( app.chat_widget.config_ref(), - app.current_model.as_str(), + 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 9978ce5ab3..2d5f534ff3 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; @@ -22,6 +23,7 @@ use crate::history_cell::HistoryCell; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ReasoningEffort; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -102,6 +104,9 @@ pub(crate) enum AppEvent { /// Update the current model slug in the running app and widget. UpdateModel(String), + /// Update the current collaboration mode in the running app and widget. + UpdateCollaborationMode(CollaborationMode), + /// Persist the selected model and reasoning effort to the appropriate config. PersistModelSelection { model: String, @@ -170,6 +175,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 3592b25737..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 { @@ -489,12 +507,7 @@ impl ChatComposer { let image_placeholders: HashSet = text_elements .iter() - .filter_map(|elem| { - elem.placeholder.as_ref().cloned().or_else(|| { - text.get(elem.byte_range.start..elem.byte_range.end) - .map(str::to_string) - }) - }) + .filter_map(|elem| elem.placeholder(&text).map(str::to_string)) .collect(); for (idx, path) in local_image_paths.into_iter().enumerate() { let placeholder = local_image_label_text(idx + 1); @@ -546,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; @@ -774,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()); @@ -800,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, ); @@ -830,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, ); @@ -1174,13 +1219,13 @@ impl ChatComposer { return None; } let placeholder = trimmed.get(new_start..new_end).map(str::to_string); - Some(TextElement { - byte_range: ByteRange { + Some(TextElement::new( + ByteRange { start: new_start, end: new_end, }, placeholder, - }) + )) }) .collect() } @@ -1219,7 +1264,7 @@ impl ChatComposer { rebuilt.push_str(&text[cursor..start]); } let elem_text = &text[start..end]; - let placeholder = elem.placeholder; + let placeholder = elem.placeholder(text).map(str::to_string); let replacement = placeholder .as_deref() .and_then(|ph| pending_by_placeholder.get_mut(ph)) @@ -1231,13 +1276,13 @@ impl ChatComposer { rebuilt.push_str(elem_text); let new_end = rebuilt.len(); let placeholder = placeholder.or_else(|| Some(elem_text.to_string())); - rebuilt_elements.push(TextElement { - byte_range: ByteRange { + rebuilt_elements.push(TextElement::new( + ByteRange { start: new_start, end: new_end, }, placeholder, - }); + )); } cursor = end; } @@ -1492,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 = @@ -1530,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() { @@ -1655,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) @@ -1676,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)) = @@ -1751,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, @@ -2468,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); @@ -2499,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(), } } } @@ -2527,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; @@ -3797,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] @@ -4223,7 +4300,7 @@ mod tests { } => { assert_eq!(text, "[Image #1] hi"); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4252,10 +4329,7 @@ mod tests { let placeholder = local_image_label_text(1); let text = format!("{placeholder} restored"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: None, - }]; + let text_elements = vec![TextElement::new((0..placeholder.len()).into(), None)]; let path = PathBuf::from("/tmp/image1.png"); composer.set_text_content(text, text_elements, vec![path.clone()]); @@ -4292,7 +4366,7 @@ mod tests { let expected = format!("{large_content} [Image #1]"); assert_eq!(text, expected); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4336,7 +4410,7 @@ mod tests { let trimmed = large_content.trim().to_string(); assert_eq!(text, format!("{trimmed} [Image #1]")); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4380,7 +4454,7 @@ mod tests { assert_eq!(text, "line1\nline2\n [Image #1]"); assert!(!text.contains('\r')); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4465,7 +4539,7 @@ mod tests { } => { assert_eq!(text, "[Image #1]"); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4627,20 +4701,20 @@ mod tests { let start2 = text.find(&placeholder2).expect("placeholder2 present"); let start1 = text.find(&placeholder1).expect("placeholder1 present"); let text_elements = vec![ - TextElement { - byte_range: ByteRange { + TextElement::new( + ByteRange { start: start2, end: start2 + placeholder2.len(), }, - placeholder: Some(placeholder2), - }, - TextElement { - byte_range: ByteRange { + Some(placeholder2), + ), + TextElement::new( + ByteRange { start: start1, end: start1 + placeholder1.len(), }, - placeholder: Some(placeholder1.clone()), - }, + Some(placeholder1.clone()), + ), ]; composer.set_text_content(text, text_elements, vec![path1, path2.clone()]); @@ -4839,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] @@ -4906,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; @@ -5124,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 83764d55b8..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; @@ -688,8 +691,13 @@ impl BottomPane { if let Some(status) = &self.status { flex.push(0, RenderableItem::Borrowed(status)); } + let has_queued_messages = !self.queued_user_messages.messages.is_empty(); + let has_status = self.status.is_some(); + if has_queued_messages && has_status { + flex.push(0, RenderableItem::Owned("".into())); + } flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages)); - if self.status.is_some() || !self.queued_user_messages.messages.is_empty() { + if !has_queued_messages && has_status { flex.push(0, RenderableItem::Owned("".into())); } let mut flex2 = FlexRenderable::new(); @@ -931,6 +939,60 @@ mod tests { ); } + #[test] + fn status_only_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!("status_only_snapshot", render_snapshot(&pane, area)); + } + + #[test] + fn status_with_details_and_queued_messages_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + pane.update_status( + "Working".to_string(), + Some("First detail line\nSecond detail line".to_string()), + ); + pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!( + "status_with_details_and_queued_messages_snapshot", + render_snapshot(&pane, area) + ); + } + #[test] fn queued_messages_visible_when_status_hidden_snapshot() { let (tx_raw, _rx) = unbounded_channel::(); 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/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap index 71504561db..efc74d42ba 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap @@ -5,7 +5,6 @@ expression: "render_snapshot(&pane, area)" ↳ Queued follow-up question ⌥ + ↑ edit - › Ask Codex to do anything 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap index 6ac4296833..cdea8a17d8 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap @@ -3,10 +3,10 @@ source: tui2/src/bottom_pane/mod.rs expression: "render_snapshot(&pane, area)" --- • Working (0s • esc to interrupt) + ↳ Queued follow-up question ⌥ + ↑ edit - › Ask Codex to do anything 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_only_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_only_snapshot.snap new file mode 100644 index 0000000000..3b53b4d862 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_only_snapshot.snap @@ -0,0 +1,10 @@ +--- +source: tui2/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interrupt) + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap new file mode 100644 index 0000000000..51236bb69b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interrupt) + └ First detail line + Second detail line + + ↳ Queued follow-up question + ⌥ + ↑ edit + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/textarea.rs b/codex-rs/tui2/src/bottom_pane/textarea.rs index 37f2d54c7b..7c39379c9f 100644 --- a/codex-rs/tui2/src/bottom_pane/textarea.rs +++ b/codex-rs/tui2/src/bottom_pane/textarea.rs @@ -752,13 +752,13 @@ impl TextArea { .iter() .map(|e| { let placeholder = self.text.get(e.range.clone()).map(str::to_string); - UserTextElement { - byte_range: ByteRange { + UserTextElement::new( + ByteRange { start: e.range.start, end: e.range.end, }, placeholder, - } + ) }) .collect() } diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 84e264b9ba..db7e96a45d 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; @@ -90,6 +91,8 @@ use codex_core::skills::model::SkillMetadata; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; +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::user_input::TextElement; @@ -119,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; @@ -157,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; @@ -208,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 { @@ -319,8 +324,6 @@ enum RateLimitSwitchPromptState { Shown, } -type CollaborationModeSelection = collaboration_modes::Selection; - /// Maintains the per-session UI state and interaction state machines for the chat screen. /// /// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming @@ -349,12 +352,12 @@ pub(crate) struct ChatWidget { /// where the overlay may briefly treat new tail content as already cached. active_cell_revision: u64, config: Config, - model: Option, - /// Current UI selection for collaboration modes. + /// Stored collaboration mode with model and reasoning effort. /// - /// This selection is only meaningful when `Feature::CollaborationModes` is enabled; when the - /// feature is disabled, the value is effectively inert. - collaboration_mode: CollaborationModeSelection, + /// When collaboration modes feature is enabled, this is initialized to the first preset. + /// When disabled, this is Custom. The model and reasoning effort are stored here instead of + /// being read from config or current_model. + stored_collaboration_mode: CollaborationMode, auth_manager: Arc, models_manager: Arc, session_header: SessionHeader, @@ -556,9 +559,9 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) } let original = text.get(start..end).unwrap_or(""); - let placeholder_key = elem.placeholder.as_deref().unwrap_or(original); - let replacement = mapping - .get(placeholder_key) + let placeholder = elem.placeholder(&text); + let replacement = placeholder + .and_then(|ph| mapping.get(ph)) .map(String::as_str) .unwrap_or(original); @@ -566,8 +569,8 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) rebuilt.push_str(replacement); let elem_end = rebuilt.len(); - if let Some(remapped) = mapping.get(placeholder_key) { - elem.placeholder = Some(remapped.clone()); + if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) { + elem.set_placeholder(Some(remapped.clone())); } elem.byte_range = (elem_start..elem_end).into(); rebuilt_elements.push(elem); @@ -631,13 +634,24 @@ impl ChatWidget { self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); - self.model = Some(model_for_header.clone()); self.session_header.set_model(&model_for_header); + // Only update stored collaboration settings when collaboration modes are disabled. + // When enabled, we preserve the selected variant (Plan/Pair/Execute/Custom) and its + // instructions as-is; the session configured event should not override it. + if !self.collaboration_modes_enabled() { + self.stored_collaboration_mode = self.stored_collaboration_mode.with_updates( + Some(model_for_header.clone()), + Some(event.reasoning_effort), + None, + ); + } let session_info_cell = history_cell::new_session_info( &self.config, &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); @@ -876,7 +890,7 @@ impl ChatWidget { if high_usage && !self.rate_limit_switch_prompt_hidden() - && self.current_model() != Some(NUDGE_MODEL_SLUG) + && self.current_model() != NUDGE_MODEL_SLUG && !matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Shown @@ -1591,22 +1605,34 @@ impl ChatWidget { is_first_run, model, } = common; - let mut config = config; let model = model.filter(|m| !m.trim().is_empty()); + let mut config = config; config.model = model.clone(); let mut rng = rand::rng(); 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 = config - .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)) + 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 { + model: model_for_header.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }) } else { - None + CollaborationMode::Custom(Settings { + model: model_for_header.clone(), + reasoning_effort: None, + 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(), @@ -1625,8 +1651,7 @@ impl ChatWidget { active_cell, active_cell_revision: 0, config, - model, - collaboration_mode: CollaborationModeSelection::default(), + stored_collaboration_mode, auth_manager, models_manager, session_header: SessionHeader::new(model_for_header), @@ -1684,7 +1709,7 @@ impl ChatWidget { session_configured: codex_core::protocol::SessionConfiguredEvent, ) -> Self { let ChatWidgetInit { - mut config, + config, frame_requester, app_event_tx, initial_user_message, @@ -1696,7 +1721,7 @@ impl ChatWidget { .. } = common; let model = model.filter(|m| !m.trim().is_empty()); - config.model = model.clone(); + let config = config; let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); @@ -1705,6 +1730,22 @@ impl ChatWidget { let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); + let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }; + let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -1722,8 +1763,7 @@ impl ChatWidget { active_cell: None, active_cell_revision: 0, config, - model: Some(header_model.clone()), - collaboration_mode: CollaborationModeSelection::default(), + stored_collaboration_mode, auth_manager, models_manager, session_header: SessionHeader::new(header_model), @@ -1976,7 +2016,7 @@ impl ChatWidget { } SlashCommand::Collab => { if self.collaboration_modes_enabled() { - self.cycle_collaboration_mode(); + self.open_collaboration_modes_popup(); } } SlashCommand::Approvals => { @@ -2021,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(); } @@ -2144,13 +2187,8 @@ impl ChatWidget { return; } - if let Some(selection) = collaboration_modes::parse_selection(trimmed) { - self.set_collaboration_mode(selection); - } else if !trimmed.is_empty() { - self.add_error_message(format!( - "Unknown collaboration mode '{trimmed}'. Try: plan, pair, execute." - )); - } + let _ = trimmed; + self.open_collaboration_modes_popup(); } _ => self.dispatch_command(cmd), } @@ -2217,13 +2255,12 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { - let Some(model) = self.current_model().or(self.config.model.as_deref()) else { - tracing::warn!("cannot submit user message before model is known; queueing"); + if !self.is_session_configured() { + tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); self.refresh_queued_user_messages(); return; - }; - let model = model.to_string(); + } let UserMessage { text, @@ -2277,24 +2314,18 @@ impl ChatWidget { } } - let collaboration_mode = self.collaboration_modes_enabled().then(|| { - collaboration_modes::resolve_mode_or_fallback( - self.models_manager.as_ref(), - self.collaboration_mode, - model.as_str(), - self.config.model_reasoning_effort, - ) - }); let op = Op::UserTurn { items, cwd: self.config.cwd.clone(), approval_policy: self.config.approval_policy.value(), sandbox_policy: self.config.sandbox_policy.get().clone(), - model, - effort: self.config.model_reasoning_effort, + model: self.stored_collaboration_mode.model().to_string(), + effort: self.stored_collaboration_mode.reasoning_effort(), summary: self.config.model_reasoning_summary, final_output_json_schema: None, - collaboration_mode, + collaboration_mode: self + .collaboration_modes_enabled() + .then(|| self.stored_collaboration_mode.clone()), }; if !self.agent_turn_running { @@ -2632,6 +2663,7 @@ impl ChatWidget { let total_usage = token_info .map(|ti| &ti.total_token_usage) .unwrap_or(&default_usage); + let reasoning_effort_override = Some(self.stored_collaboration_mode.reasoning_effort()); self.add_to_history(crate::status::new_status_output( &self.config, self.auth_manager.as_ref(), @@ -2643,8 +2675,8 @@ impl ChatWidget { self.plan_type, Local::now(), self.model_display_name(), - self.collaboration_modes_enabled() - .then_some(self.collaboration_mode.label()), + self.collaboration_mode_label(), + reasoning_effort_override, )); } fn stop_rate_limit_poller(&mut self) { @@ -2819,7 +2851,7 @@ impl ChatWidget { let current_model = self.current_model(); let current_label = presets .iter() - .find(|preset| Some(preset.model.as_str()) == current_model) + .find(|preset| preset.model.as_str() == current_model) .map(|preset| preset.display_name.to_string()) .unwrap_or_else(|| self.model_display_name().to_string()); @@ -2847,7 +2879,7 @@ impl ChatWidget { SelectionItem { name: preset.display_name.clone(), description, - is_current: Some(model.as_str()) == current_model, + is_current: model.as_str() == current_model, is_default: preset.is_default, actions, dismiss_on_select: true, @@ -2914,7 +2946,7 @@ impl ChatWidget { for preset in presets.into_iter() { let description = (!preset.description.is_empty()).then_some(preset.description.to_string()); - let is_current = Some(preset.model.as_str()) == self.current_model(); + let is_current = preset.model.as_str() == self.current_model(); let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; let preset_for_action = preset.clone(); let actions: Vec = vec![Box::new(move |tx| { @@ -2946,6 +2978,49 @@ impl ChatWidget { }); } + pub(crate) fn open_collaboration_modes_popup(&mut self) { + let presets = self.models_manager.list_collaboration_modes(); + if presets.is_empty() { + self.add_info_message( + "No collaboration modes are available right now.".to_string(), + None, + ); + return; + } + + let items: Vec = presets + .into_iter() + .map(|preset| { + let name = match preset { + CollaborationMode::Plan(_) => "Plan", + CollaborationMode::PairProgramming(_) => "Pair Programming", + CollaborationMode::Execute(_) => "Execute", + CollaborationMode::Custom(_) => "Custom", + }; + let is_current = + collaboration_modes::same_variant(&self.stored_collaboration_mode, &preset); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::UpdateCollaborationMode(preset.clone())); + })]; + SelectionItem { + name: name.to_string(), + is_current, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Collaboration Mode".to_string()), + subtitle: Some("Pick a collaboration preset.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + fn model_selection_actions( model_for_action: String, effort_for_action: Option, @@ -3041,9 +3116,9 @@ impl ChatWidget { .or(Some(default_effort)); let model_slug = preset.model.to_string(); - let is_current_model = self.current_model() == Some(preset.model.as_str()); + let is_current_model = self.current_model() == preset.model.as_str(); let highlight_choice = if is_current_model { - self.config.model_reasoning_effort + self.stored_collaboration_mode.reasoning_effort() } else { default_choice }; @@ -3266,6 +3341,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, @@ -3822,6 +3916,18 @@ impl ChatWidget { } if feature == Feature::CollaborationModes { self.bottom_pane.set_collaboration_modes_enabled(enabled); + let settings = match &self.stored_collaboration_mode { + CollaborationMode::Plan(settings) + | CollaborationMode::PairProgramming(settings) + | CollaborationMode::Execute(settings) + | CollaborationMode::Custom(settings) => settings.clone(), + }; + self.stored_collaboration_mode = if enabled { + collaboration_modes::default_mode(self.models_manager.as_ref()) + .unwrap_or(CollaborationMode::Custom(settings)) + } else { + CollaborationMode::Custom(settings) + }; } } @@ -3848,19 +3954,32 @@ impl ChatWidget { .unwrap_or(false) } - /// Set the reasoning effort in the widget's config copy. + /// Set the reasoning effort in the stored collaboration mode. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { - self.config.model_reasoning_effort = effort; + self.stored_collaboration_mode = + self.stored_collaboration_mode + .with_updates(None, Some(effort), None); } - /// Set the model in the widget's config copy. + /// Set the model in the widget's config copy and stored collaboration mode. pub(crate) fn set_model(&mut self, model: &str) { self.session_header.set_model(model); - self.model = Some(model.to_string()); + self.stored_collaboration_mode = + self.stored_collaboration_mode + .with_updates(Some(model.to_string()), None, None); } - fn current_model(&self) -> Option<&str> { - self.model.as_deref() + pub(crate) fn current_model(&self) -> &str { + self.stored_collaboration_mode.model() + } + + #[cfg(test)] + pub(crate) fn current_reasoning_effort(&self) -> Option { + self.stored_collaboration_mode.reasoning_effort() + } + + fn is_session_configured(&self) -> bool { + self.conversation_id.is_some() } fn collaboration_modes_enabled(&self) -> bool { @@ -3868,36 +3987,72 @@ impl ChatWidget { } fn model_display_name(&self) -> &str { - self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) + let model = self.current_model(); + if model.is_empty() { + DEFAULT_MODEL_DISPLAY_NAME + } else { + model + } } + /// Get the label for the current collaboration mode. + fn collaboration_mode_label(&self) -> Option<&'static str> { + if !self.collaboration_modes_enabled() { + return None; + } + match &self.stored_collaboration_mode { + CollaborationMode::Plan(_) => Some("Plan"), + CollaborationMode::PairProgramming(_) => Some("Pair Programming"), + CollaborationMode::Execute(_) => Some("Execute"), + CollaborationMode::Custom(_) => None, + } + } + + /// Cycle to the next collaboration mode variant (Plan -> PairProgramming -> Execute -> Plan). fn cycle_collaboration_mode(&mut self) { if !self.collaboration_modes_enabled() { return; } - let next = self.collaboration_mode.next(); - self.set_collaboration_mode(next); + if let Some(next_mode) = collaboration_modes::next_mode( + self.models_manager.as_ref(), + &self.stored_collaboration_mode, + ) { + self.set_collaboration_mode(next_mode); + } } - /// Update the selected collaboration mode. + /// Update the stored collaboration mode. /// - /// When collaboration modes are enabled, the current selection is attached to *every* + /// When collaboration modes are enabled, the current mode is attached to *every* /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. - fn set_collaboration_mode(&mut self, selection: CollaborationModeSelection) { + pub(crate) fn set_collaboration_mode(&mut self, mode: CollaborationMode) { if !self.collaboration_modes_enabled() { return; } - self.collaboration_mode = selection; - let flash = collaboration_modes::flash_line(selection); - const FLASH_DURATION: Duration = Duration::from_secs(2); - self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); + self.stored_collaboration_mode = mode; + + let label = self.collaboration_mode_label(); + if let Some(label) = label { + let flash = Line::from(vec![ + label.bold(), + " (".dim(), + key_hint::shift(KeyCode::Tab).into(), + " to change mode)".dim(), + ]); + const FLASH_DURATION: Duration = Duration::from_secs(2); + self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); + } self.request_redraw(); } /// 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(), @@ -3905,6 +4060,8 @@ impl ChatWidget { None, config.cwd.clone(), CODEX_CLI_VERSION, + is_collaboration, + collaboration_mode, )) } @@ -4335,10 +4492,6 @@ impl ChatWidget { self.current_rollout_path.clone() } - fn is_session_configured(&self) -> bool { - self.conversation_id.is_some() - } - /// Returns a cache key describing the current in-flight active cell for the transcript overlay. /// /// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap index 3cc0b593d4..57a590c46c 100644 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap @@ -2,7 +2,9 @@ source: tui2/src/chatwidget/tests.rs expression: term.backend().vt100().screen().contents() --- + • Working (0s • esc to interrupt) + ↳ Hello, world! 0 ↳ Hello, world! 1 ↳ Hello, world! 2 @@ -21,7 +23,6 @@ expression: term.backend().vt100().screen().contents() ↳ Hello, world! 15 ↳ Hello, world! 16 - › Ask Codex to do anything 100% context left · ? for shortcuts 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/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index d87cffa109..94ae16849d 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -60,6 +60,7 @@ use codex_core::protocol::WarningEvent; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::parse_command::ParsedCommand; @@ -181,10 +182,10 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { let placeholder = "[Image #1]"; let message = format!("{placeholder} replayed"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: Some(placeholder.to_string()), - }]; + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; let local_images = vec![PathBuf::from("/tmp/replay.png")]; let conversation_id = ThreadId::new(); @@ -263,10 +264,10 @@ async fn submission_preserves_text_elements_and_local_images() { let placeholder = "[Image #1]"; let text = format!("{placeholder} submit"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: Some(placeholder.to_string()), - }]; + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; let local_images = vec![PathBuf::from("/tmp/submitted.png")]; chat.bottom_pane @@ -319,26 +320,26 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { let first_placeholder = "[Image #1]"; let first_text = format!("{first_placeholder} first"); - let first_elements = vec![TextElement { - byte_range: (0..first_placeholder.len()).into(), - placeholder: Some(first_placeholder.to_string()), - }]; + let first_elements = vec![TextElement::new( + (0..first_placeholder.len()).into(), + Some(first_placeholder.to_string()), + )]; let first_images = [PathBuf::from("/tmp/first.png")]; let second_placeholder = "[Image #1]"; let second_text = format!("{second_placeholder} second"); - let second_elements = vec![TextElement { - byte_range: (0..second_placeholder.len()).into(), - placeholder: Some(second_placeholder.to_string()), - }]; + let second_elements = vec![TextElement::new( + (0..second_placeholder.len()).into(), + Some(second_placeholder.to_string()), + )]; let second_images = [PathBuf::from("/tmp/second.png")]; let existing_placeholder = "[Image #1]"; let existing_text = format!("{existing_placeholder} existing"); - let existing_elements = vec![TextElement { - byte_range: (0..existing_placeholder.len()).into(), - placeholder: Some(existing_placeholder.to_string()), - }]; + let existing_elements = vec![TextElement::new( + (0..existing_placeholder.len()).into(), + Some(existing_placeholder.to_string()), + )]; let existing_images = vec![PathBuf::from("/tmp/existing.png")]; chat.queued_user_messages.push_back(UserMessage { @@ -381,18 +382,18 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { let second_start = first.len() + 1; let third_start = second_start + second.len() + 1; let expected_elements = vec![ - TextElement { - byte_range: (first_start..first_start + "[Image #1]".len()).into(), - placeholder: Some("[Image #1]".to_string()), - }, - TextElement { - byte_range: (second_start..second_start + "[Image #2]".len()).into(), - placeholder: Some("[Image #2]".to_string()), - }, - TextElement { - byte_range: (third_start..third_start + "[Image #3]".len()).into(), - placeholder: Some("[Image #3]".to_string()), - }, + TextElement::new( + (first_start..first_start + "[Image #1]".len()).into(), + Some("[Image #1]".to_string()), + ), + TextElement::new( + (second_start..second_start + "[Image #2]".len()).into(), + Some("[Image #2]".to_string()), + ), + TextElement::new( + (third_start..third_start + "[Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ]; assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements); assert_eq!( @@ -411,14 +412,14 @@ async fn remap_placeholders_uses_attachment_labels() { let placeholder_two = "[Image #2]"; let text = format!("{placeholder_two} before {placeholder_one}"); let elements = vec![ - TextElement { - byte_range: (0..placeholder_two.len()).into(), - placeholder: Some(placeholder_two.to_string()), - }, - TextElement { - byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), - placeholder: Some(placeholder_one.to_string()), - }, + TextElement::new( + (0..placeholder_two.len()).into(), + Some(placeholder_two.to_string()), + ), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + Some(placeholder_one.to_string()), + ), ]; let attachments = vec![ @@ -443,15 +444,14 @@ async fn remap_placeholders_uses_attachment_labels() { assert_eq!( remapped.text_elements, vec![ - TextElement { - byte_range: (0.."[Image #4]".len()).into(), - placeholder: Some("[Image #4]".to_string()), - }, - TextElement { - byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()) - .into(), - placeholder: Some("[Image #3]".to_string()), - }, + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ] ); assert_eq!( @@ -475,14 +475,11 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { let placeholder_two = "[Image #2]"; let text = format!("{placeholder_two} before {placeholder_one}"); let elements = vec![ - TextElement { - byte_range: (0..placeholder_two.len()).into(), - placeholder: None, - }, - TextElement { - byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), - placeholder: None, - }, + TextElement::new((0..placeholder_two.len()).into(), None), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + None, + ), ]; let attachments = vec![ @@ -507,15 +504,14 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { assert_eq!( remapped.text_elements, vec![ - TextElement { - byte_range: (0.."[Image #4]".len()).into(), - placeholder: Some("[Image #4]".to_string()), - }, - TextElement { - byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()) - .into(), - placeholder: Some("[Image #3]".to_string()), - }, + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ] ); assert_eq!( @@ -702,13 +698,14 @@ async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let cfg = test_config().await; + let model = cfg.model.clone(); let thread_manager = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("test"), cfg.model_provider.clone(), )); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let init = ChatWidgetInit { - config: cfg.clone(), + config: cfg, frame_requester: FrameRequester::test_dummy(), app_event_tx: tx, initial_user_message: None, @@ -717,7 +714,7 @@ async fn helpers_are_available_and_do_not_panic() { models_manager: thread_manager.get_models_manager(), feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, - model: cfg.model, + model, }; let mut w = ChatWidget::new(init, thread_manager); // Basic construction sanity. @@ -756,6 +753,24 @@ async fn make_chatwidget_manual( bottom.set_collaboration_modes_enabled(cfg.features.enabled(Feature::CollaborationModes)); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let codex_home = cfg.codex_home.clone(); + let models_manager = Arc::new(ModelsManager::new(codex_home, auth_manager.clone())); + let collaboration_modes_enabled = cfg.features.enabled(Feature::CollaborationModes); + let reasoning_effort = None; + let stored_collaboration_mode = if collaboration_modes_enabled { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }) + }; let widget = ChatWidget { app_event_tx, codex_op_tx: op_tx, @@ -763,10 +778,9 @@ async fn make_chatwidget_manual( active_cell: None, active_cell_revision: 0, config: cfg, - model: Some(resolved_model.clone()), - collaboration_mode: CollaborationModeSelection::default(), - auth_manager: auth_manager.clone(), - models_manager: Arc::new(ModelsManager::new(codex_home, auth_manager)), + stored_collaboration_mode, + auth_manager, + models_manager, session_header: SessionHeader::new(resolved_model), initial_user_message: None, token_info: None, @@ -1699,75 +1713,66 @@ async fn slash_init_skips_when_project_doc_exists() { ); } -#[test] -fn parse_collaboration_mode_selection_accepts_common_aliases() { - assert_eq!( - collaboration_modes::parse_selection("plan"), - Some(CollaborationModeSelection::Plan) - ); - assert_eq!( - collaboration_modes::parse_selection("PAIR"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection("pair_programming"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection("pp"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection(" exec "), - Some(CollaborationModeSelection::Execute) - ); - assert_eq!( - collaboration_modes::parse_selection("execute"), - Some(CollaborationModeSelection::Execute) - ); - assert_eq!(collaboration_modes::parse_selection("unknown"), None); -} - #[tokio::test] async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; chat.set_feature_enabled(Feature::CollaborationModes, false); - let initial = chat.collaboration_mode; + let initial = chat.stored_collaboration_mode.clone(); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, initial); + assert_eq!(chat.stored_collaboration_mode, initial); chat.set_feature_enabled(Feature::CollaborationModes, true); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Execute); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::Execute(_) + )); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::Plan(_) + )); chat.on_task_started(); + let before = chat.stored_collaboration_mode.clone(); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + assert_eq!(chat.stored_collaboration_mode, before); } #[tokio::test] -async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; +async fn collab_slash_command_opens_picker_and_updates_mode() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.conversation_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.dispatch_command_with_args(SlashCommand::Collab, "plan".to_string()); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + chat.dispatch_command(SlashCommand::Collab); + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Select Collaboration Mode"), + "expected collaboration picker: {popup}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let selected_mode = match rx.try_recv() { + Ok(AppEvent::UpdateCollaborationMode(mode)) => mode, + other => panic!("expected UpdateCollaborationMode event, got {other:?}"), + }; + chat.set_collaboration_mode(selected_mode); chat.bottom_pane .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::Plan(_)), + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } chat.bottom_pane @@ -1775,10 +1780,12 @@ async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::Plan(_)), + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } } @@ -1796,10 +1803,22 @@ async fn collab_mode_defaults_to_pair_programming_when_enabled() { collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } } +#[tokio::test] +async fn collab_mode_enabling_sets_pair_programming_default() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::PairProgramming(_) + )); +} + #[tokio::test] async fn slash_quit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2446,7 +2465,7 @@ async fn model_reasoning_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); @@ -2460,7 +2479,7 @@ async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); diff --git a/codex-rs/tui2/src/collaboration_modes.rs b/codex-rs/tui2/src/collaboration_modes.rs index bdd1d8b63c..f48ad92c91 100644 --- a/codex-rs/tui2/src/collaboration_modes.rs +++ b/codex-rs/tui2/src/collaboration_modes.rs @@ -1,135 +1,49 @@ -//! Collaboration mode selection + rendering helpers for the TUI. -//! -//! This module is intentionally UI-focused: -//! - It owns the user-facing set of selectable collaboration modes and how they cycle. -//! - It parses `/collab ` arguments into a selection. -//! - It resolves a `Selection` to a concrete `codex_protocol::config_types::CollaborationMode` by -//! picking from the `ModelsManager` builtin collaboration presets. -//! - It builds the small footer "flash" line shown after changing modes. -//! -//! The `ChatWidget` owns the session state and decides *when* selection/mode changes are allowed -//! (feature flag, task running, modals open, etc.). This module just provides the building blocks. - -use crate::key_hint; use codex_core::models_manager::manager::ModelsManager; use codex_protocol::config_types::CollaborationMode; -use codex_protocol::config_types::Settings; -use codex_protocol::openai_models::ReasoningEffort; -use crossterm::event::KeyCode; -use ratatui::style::Stylize; -use ratatui::text::Line; -/// The user-facing collaboration mode choices supported by the TUI. -/// -/// This is distinct from `CollaborationMode`: it represents a stable UI selection and the cycling -/// order, while `CollaborationMode` can carry nested settings/prompt configuration. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(crate) enum Selection { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ModeKind { Plan, - #[default] PairProgramming, Execute, + Custom, } -impl Selection { - /// Cycle to the next selection. - /// - /// The TUI cycles through a small, fixed set of presets. - pub(crate) fn next(self) -> Self { - match self { - Self::Plan => Self::PairProgramming, - Self::PairProgramming => Self::Execute, - Self::Execute => Self::Plan, - } - } - - /// User-facing label used in UI surfaces like `/status` and the footer flash. - pub(crate) fn label(self) -> &'static str { - match self { - Self::Plan => "Plan", - Self::PairProgramming => "Pair Programming", - Self::Execute => "Execute", - } +fn mode_kind(mode: &CollaborationMode) -> ModeKind { + match mode { + CollaborationMode::Plan(_) => ModeKind::Plan, + CollaborationMode::PairProgramming(_) => ModeKind::PairProgramming, + CollaborationMode::Execute(_) => ModeKind::Execute, + CollaborationMode::Custom(_) => ModeKind::Custom, } } -/// Parse a user argument (e.g. `/collab plan`, `/collab pair_programming`) into a selection. -/// -/// The parser is forgiving: it strips whitespace, `-`, and `_`, and matches case-insensitively. -pub(crate) fn parse_selection(input: &str) -> Option { - let normalized: String = input - .chars() - .filter(|c| !c.is_ascii_whitespace() && *c != '-' && *c != '_') - .flat_map(char::to_lowercase) - .collect(); - - match normalized.as_str() { - "plan" => Some(Selection::Plan), - "pair" | "pairprogramming" | "pp" => Some(Selection::PairProgramming), - "execute" | "exec" => Some(Selection::Execute), - _ => None, - } +pub(crate) fn default_mode(models_manager: &ModelsManager) -> Option { + let presets = models_manager.list_collaboration_modes(); + presets + .iter() + .find(|preset| matches!(preset, CollaborationMode::PairProgramming(_))) + .cloned() + .or_else(|| presets.into_iter().next()) } -/// Resolve a selection to a concrete collaboration mode preset. -/// -/// `ModelsManager::list_collaboration_modes()` is expected to return a builtin set of presets; this -/// function selects the first preset of the desired variant. -pub(crate) fn resolve_mode( +pub(crate) fn same_variant(a: &CollaborationMode, b: &CollaborationMode) -> bool { + mode_kind(a) == mode_kind(b) +} + +/// Cycle to the next collaboration mode preset in list order. +pub(crate) fn next_mode( models_manager: &ModelsManager, - selection: Selection, + current: &CollaborationMode, ) -> Option { - match selection { - Selection::Plan => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::Plan(_))), - Selection::PairProgramming => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::PairProgramming(_))), - Selection::Execute => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::Execute(_))), + let presets = models_manager.list_collaboration_modes(); + if presets.is_empty() { + return None; } -} - -/// Resolve a selection to a concrete collaboration mode preset, falling back to a synthesized mode -/// when the desired preset is unavailable. -/// -/// This keeps the TUI behavior stable when collaboration presets are missing (for example, when -/// running in offline/unit-test contexts): if the feature flag is enabled, every submission carries -/// an explicit collaboration mode so core doesn't fall back to `Custom`. -pub(crate) fn resolve_mode_or_fallback( - models_manager: &ModelsManager, - selection: Selection, - fallback_model: &str, - fallback_effort: Option, -) -> CollaborationMode { - resolve_mode(models_manager, selection).unwrap_or_else(|| { - let settings = Settings { - model: fallback_model.to_string(), - reasoning_effort: fallback_effort, - developer_instructions: None, - }; - - match selection { - Selection::Plan => CollaborationMode::Plan(settings), - Selection::PairProgramming => CollaborationMode::PairProgramming(settings), - Selection::Execute => CollaborationMode::Execute(settings), - } - }) -} - -/// Build a 1-line footer "flash" that is shown after switching modes. -/// -/// The `ChatWidget` controls when to show this and how long it should remain visible. -pub(crate) fn flash_line(selection: Selection) -> Line<'static> { - Line::from(vec![ - selection.label().bold(), - " (".dim(), - key_hint::shift(KeyCode::Tab).into(), - " to change mode)".dim(), - ]) + let current_kind = mode_kind(current); + let next_index = presets + .iter() + .position(|preset| mode_kind(preset) == current_kind) + .map_or(0, |idx| (idx + 1) % presets.len()); + presets.get(next_index).cloned() } 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/lib.rs b/codex-rs/tui2/src/lib.rs index 5b2bdd2385..8a6455e69c 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -18,6 +18,7 @@ use codex_core::RolloutRecorder; use codex_core::ThreadSortKey; use codex_core::auth::enforce_login_restrictions; use codex_core::config::Config; +use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; @@ -25,6 +26,7 @@ use codex_core::config::resolve_oss_provider; use codex_core::find_thread_path_by_id_str; use codex_core::get_platform_sandbox; use codex_core::protocol::AskForApproval; +use codex_core::read_session_meta_line; use codex_core::terminal::Multiplexer; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::SandboxMode; @@ -263,7 +265,6 @@ pub async fn run_main( std::process::exit(1); } - let active_profile = config.active_profile.clone(); let log_dir = codex_core::config::log_dir(&config)?; std::fs::create_dir_all(&log_dir)?; // Open (or create) your log file, appending to it. @@ -356,16 +357,9 @@ pub async fn run_main( let terminal_info = codex_core::terminal::terminal_info(); tracing::info!(terminal = ?terminal_info, "Detected terminal info"); - run_ratatui_app( - cli, - config, - overrides, - cli_kv_overrides, - active_profile, - feedback, - ) - .await - .map_err(|err| std::io::Error::other(err.to_string())) + run_ratatui_app(cli, config, overrides, cli_kv_overrides, feedback) + .await + .map_err(|err| std::io::Error::other(err.to_string())) } async fn run_ratatui_app( @@ -373,7 +367,6 @@ async fn run_ratatui_app( initial_config: Config, overrides: ConfigOverrides, cli_kv_overrides: Vec<(String, toml::Value)>, - active_profile: Option, feedback: codex_feedback::CodexFeedback, ) -> color_eyre::Result { color_eyre::install()?; @@ -425,15 +418,15 @@ async fn run_ratatui_app( initial_config.cli_auth_credentials_store_mode, ); let login_status = get_login_status(&initial_config); - let should_show_trust_screen = should_show_trust_screen(&initial_config); + let should_show_trust_screen_flag = should_show_trust_screen(&initial_config); let should_show_onboarding = - should_show_onboarding(login_status, &initial_config, should_show_trust_screen); + should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag); let config = if should_show_onboarding { let onboarding_result = run_onboarding_app( OnboardingScreenArgs { show_login_screen: should_show_login_screen(login_status, &initial_config), - show_trust_screen: should_show_trust_screen, + show_trust_screen: should_show_trust_screen_flag, login_status, auth_manager: auth_manager.clone(), config: initial_config.clone(), @@ -459,7 +452,7 @@ async fn run_ratatui_app( .map(|d| d == TrustDirectorySelection::Trust) .unwrap_or(false) { - load_config_or_exit(cli_kv_overrides, overrides).await + load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await } else { initial_config } @@ -595,6 +588,33 @@ async fn run_ratatui_app( resume_picker::SessionSelection::StartFresh }; + let config = match &session_selection { + resume_picker::SessionSelection::Resume(path) + | resume_picker::SessionSelection::Fork(path) => { + let history_cwd = match read_session_meta_line(path).await { + Ok(meta_line) => Some(meta_line.meta.cwd), + Err(err) => { + let rollout_path = path.display().to_string(); + tracing::warn!( + %rollout_path, + %err, + "Failed to read session metadata from rollout" + ); + None + } + }; + load_config_or_exit_with_fallback_cwd( + cli_kv_overrides.clone(), + overrides.clone(), + history_cwd, + ) + .await + } + _ => config, + }; + let active_profile = config.active_profile.clone(); + let should_show_trust_screen = should_show_trust_screen(&config); + let Cli { prompt, images, @@ -700,9 +720,23 @@ fn get_login_status(config: &Config) -> LoginStatus { async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, +) -> Config { + load_config_or_exit_with_fallback_cwd(cli_kv_overrides, overrides, None).await +} + +async fn load_config_or_exit_with_fallback_cwd( + cli_kv_overrides: Vec<(String, toml::Value)>, + overrides: ConfigOverrides, + fallback_cwd: Option, ) -> Config { #[allow(clippy::print_stderr)] - match Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await { + match ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .fallback_cwd(fallback_cwd) + .build() + .await + { Ok(config) => config, Err(err) => { eprintln!("Error loading configuration: {err}"); 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/status/card.rs b/codex-rs/tui2/src/status/card.rs index 7988c7d0ff..1de70f1454 100644 --- a/codex-rs/tui2/src/status/card.rs +++ b/codex-rs/tui2/src/status/card.rs @@ -5,7 +5,8 @@ use crate::history_cell::with_border_with_inner_width; use crate::version::CODEX_CLI_VERSION; use chrono::DateTime; use chrono::Local; -use codex_common::create_config_summary_entries; +use codex_common::summarize_sandbox_policy; +use codex_core::WireApi; use codex_core::config::Config; use codex_core::protocol::NetworkAccess; use codex_core::protocol::SandboxPolicy; @@ -13,6 +14,7 @@ use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; +use codex_protocol::openai_models::ReasoningEffort; use ratatui::prelude::*; use ratatui::style::Stylize; use std::collections::BTreeSet; @@ -85,6 +87,7 @@ pub(crate) fn new_status_output( now: DateTime, model_name: &str, collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let card = StatusHistoryCell::new( @@ -99,6 +102,7 @@ pub(crate) fn new_status_output( now, model_name, collaboration_mode, + reasoning_effort_override, ); CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) @@ -118,8 +122,29 @@ impl StatusHistoryCell { now: DateTime, model_name: &str, collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, ) -> Self { - let config_entries = create_config_summary_entries(config, model_name); + let mut config_entries = vec![ + ("workdir", config.cwd.display().to_string()), + ("model", model_name.to_string()), + ("provider", config.model_provider_id.clone()), + ("approval", config.approval_policy.value().to_string()), + ( + "sandbox", + summarize_sandbox_policy(config.sandbox_policy.get()), + ), + ]; + if config.model_provider.wire_api == WireApi::Responses { + let effort_value = reasoning_effort_override + .unwrap_or(None) + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "none".to_string()); + config_entries.push(("reasoning effort", effort_value)); + config_entries.push(( + "reasoning summaries", + config.model_reasoning_summary.to_string(), + )); + } let (model_name, model_details) = compose_model_display(model_name, &config_entries); let approval = config_entries .iter() diff --git a/codex-rs/tui2/src/status/tests.rs b/codex-rs/tui2/src/status/tests.rs index 9d0f6d0833..8f24af59d6 100644 --- a/codex-rs/tui2/src/status/tests.rs +++ b/codex-rs/tui2/src/status/tests.rs @@ -95,7 +95,6 @@ async fn status_snapshot_includes_reasoning_details() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; config .sandbox_policy @@ -141,6 +140,7 @@ async fn status_snapshot_includes_reasoning_details() { let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); let composite = new_status_output( &config, &auth_manager, @@ -153,6 +153,7 @@ async fn status_snapshot_includes_reasoning_details() { captured_at, &model_slug, None, + reasoning_effort_override, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -205,6 +206,7 @@ async fn status_snapshot_includes_forked_from() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -263,6 +265,7 @@ async fn status_snapshot_includes_monthly_limit() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -308,6 +311,7 @@ async fn status_snapshot_shows_unlimited_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -353,6 +357,7 @@ async fn status_snapshot_shows_positive_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -398,6 +403,7 @@ async fn status_snapshot_hides_zero_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -441,6 +447,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -484,6 +491,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { now, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); @@ -499,7 +507,6 @@ async fn status_snapshot_truncates_in_narrow_terminal() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; config.cwd = PathBuf::from("/workspace/tests"); @@ -530,6 +537,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); let composite = new_status_output( &config, &auth_manager, @@ -542,6 +550,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { captured_at, &model_slug, None, + reasoning_effort_override, ); let mut rendered_lines = render_lines(&composite.display_lines(70)); if cfg!(windows) { @@ -589,6 +598,7 @@ async fn status_snapshot_shows_missing_limits_message() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -653,6 +663,7 @@ async fn status_snapshot_includes_credits_and_limits() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -706,6 +717,7 @@ async fn status_snapshot_shows_empty_limits_message() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -768,6 +780,7 @@ async fn status_snapshot_shows_stale_limits_message() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -834,6 +847,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -890,6 +904,7 @@ async fn status_context_window_uses_last_usage() { now, &model_slug, None, + None, ); let rendered_lines = render_lines(&composite.display_lines(80)); let context_line = rendered_lines 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/codex-rs/utils/absolute-path/Cargo.toml b/codex-rs/utils/absolute-path/Cargo.toml index 5af9dc23aa..f4fa8bf789 100644 --- a/codex-rs/utils/absolute-path/Cargo.toml +++ b/codex-rs/utils/absolute-path/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +dirs = { workspace = true } path-absolutize = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -18,5 +19,6 @@ ts-rs = { workspace = true, features = [ ] } [dev-dependencies] +pretty_assertions = { workspace = true } serde_json = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/utils/absolute-path/src/lib.rs b/codex-rs/utils/absolute-path/src/lib.rs index 7054103e30..966b061bf5 100644 --- a/codex-rs/utils/absolute-path/src/lib.rs +++ b/codex-rs/utils/absolute-path/src/lib.rs @@ -1,3 +1,4 @@ +use dirs::home_dir; use path_absolutize::Absolutize; use schemars::JsonSchema; use serde::Deserialize; @@ -21,16 +22,35 @@ use ts_rs::TS; pub struct AbsolutePathBuf(PathBuf); impl AbsolutePathBuf { + fn maybe_expand_home_directory(path: &Path) -> PathBuf { + let Some(path_str) = path.to_str() else { + return path.to_path_buf(); + }; + if cfg!(not(target_os = "windows")) + && let Some(home) = home_dir() + { + if path_str == "~" { + return home; + } + if let Some(rest) = path_str.strip_prefix("~/") { + return home.join(rest); + } + } + path.to_path_buf() + } + pub fn resolve_path_against_base, B: AsRef>( path: P, base_path: B, ) -> std::io::Result { - let absolute_path = path.as_ref().absolutize_from(base_path.as_ref())?; + let expanded = Self::maybe_expand_home_directory(path.as_ref()); + let absolute_path = expanded.absolutize_from(base_path.as_ref())?; Ok(Self(absolute_path.into_owned())) } pub fn from_absolute_path>(path: P) -> std::io::Result { - let absolute_path = path.as_ref().absolutize()?; + let expanded = Self::maybe_expand_home_directory(path.as_ref()); + let absolute_path = expanded.absolutize()?; Ok(Self(absolute_path.into_owned())) } @@ -165,6 +185,7 @@ impl<'de> Deserialize<'de> for AbsolutePathBuf { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; use tempfile::tempdir; #[test] @@ -203,4 +224,47 @@ mod tests { base_dir.join(relative_path).as_path() ); } + + #[cfg(not(target_os = "windows"))] + #[test] + fn home_directory_root_on_non_windows_is_expanded_in_deserialization() { + let Some(home) = home_dir() else { + return; + }; + let temp_dir = tempdir().expect("base dir"); + let abs_path_buf = { + let _guard = AbsolutePathBufGuard::new(temp_dir.path()); + serde_json::from_str::("\"~\"").expect("failed to deserialize") + }; + assert_eq!(abs_path_buf.as_path(), home.as_path()); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn home_directory_subpath_on_non_windows_is_expanded_in_deserialization() { + let Some(home) = home_dir() else { + return; + }; + let temp_dir = tempdir().expect("base dir"); + let abs_path_buf = { + let _guard = AbsolutePathBufGuard::new(temp_dir.path()); + serde_json::from_str::("\"~/code\"").expect("failed to deserialize") + }; + assert_eq!(abs_path_buf.as_path(), home.join("code").as_path()); + } + + #[cfg(target_os = "windows")] + #[test] + fn home_directory_on_windows_is_not_expanded_in_deserialization() { + let temp_dir = tempdir().expect("base dir"); + let base_dir = temp_dir.path(); + let abs_path_buf = { + let _guard = AbsolutePathBufGuard::new(base_dir); + serde_json::from_str::("\"~/code\"").expect("failed to deserialize") + }; + assert_eq!( + abs_path_buf.as_path(), + base_dir.join("~").join("code").as_path() + ); + } } diff --git a/codex-rs/windows-sandbox-rs/src/allow.rs b/codex-rs/windows-sandbox-rs/src/allow.rs index 015555dab0..83d72f7e55 100644 --- a/codex-rs/windows-sandbox-rs/src/allow.rs +++ b/codex-rs/windows-sandbox-rs/src/allow.rs @@ -52,9 +52,9 @@ pub fn compute_allow_paths( let canonical = canonicalize(&candidate).unwrap_or(candidate); add_allow(canonical.clone()); - let git_dir = canonical.join(".git"); - if git_dir.is_dir() { - add_deny(git_dir); + let git_entry = canonical.join(".git"); + if git_entry.exists() { + add_deny(git_entry); } }; @@ -178,6 +178,33 @@ mod tests { assert_eq!(expected_deny, paths.deny); } + #[test] + fn denies_git_file_inside_writable_root() { + let tmp = TempDir::new().expect("tempdir"); + let command_cwd = tmp.path().join("workspace"); + let git_file = command_cwd.join(".git"); + let _ = fs::create_dir_all(&command_cwd); + let _ = fs::write(&git_file, "gitdir: .git/worktrees/example"); + + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: false, + }; + + let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new()); + let expected_allow: HashSet = [dunce::canonicalize(&command_cwd).unwrap()] + .into_iter() + .collect(); + let expected_deny: HashSet = [dunce::canonicalize(&git_file).unwrap()] + .into_iter() + .collect(); + + assert_eq!(expected_allow, paths.allow); + assert_eq!(expected_deny, paths.deny); + } + #[test] fn skips_git_dir_when_missing() { let tmp = TempDir::new().expect("tempdir"); diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index 659ea57b6f..2e6059e819 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -10,6 +10,7 @@ use anyhow::anyhow; use anyhow::Result; use std::collections::HashSet; use std::ffi::c_void; +use std::ffi::OsStr; use std::path::Path; use std::path::PathBuf; use std::time::Duration; @@ -67,9 +68,9 @@ fn gather_candidates(cwd: &Path, env: &std::collections::HashMap .cloned() .or_else(|| std::env::var("PATH").ok()) { - for part in path.split(std::path::MAIN_SEPARATOR) { - if !part.is_empty() { - unique_push(&mut set, &mut out, PathBuf::from(part)); + for part in std::env::split_paths(OsStr::new(&path)) { + if !part.as_os_str().is_empty() { + unique_push(&mut set, &mut out, part); } } } @@ -298,3 +299,41 @@ pub fn apply_capability_denies_for_world_writable( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::gather_candidates; + use std::collections::HashMap; + use std::fs; + + #[test] + fn gathers_path_entries_by_list_separator() { + let tmp = tempfile::tempdir().expect("tempdir"); + let dir_a = tmp.path().join("Tools"); + let dir_b = tmp.path().join("Bin"); + let dir_space = tmp.path().join("Program Files"); + fs::create_dir_all(&dir_a).expect("dir a"); + fs::create_dir_all(&dir_b).expect("dir b"); + fs::create_dir_all(&dir_space).expect("dir space"); + + let mut env_map = HashMap::new(); + env_map.insert( + "PATH".to_string(), + format!( + "{};{};{}", + dir_a.display(), + dir_b.display(), + dir_space.display() + ), + ); + + let candidates = gather_candidates(tmp.path(), &env_map); + let canon_a = dir_a.canonicalize().expect("canon a"); + let canon_b = dir_b.canonicalize().expect("canon b"); + let canon_space = dir_space.canonicalize().expect("canon space"); + + assert!(candidates.contains(&canon_a)); + assert!(candidates.contains(&canon_b)); + assert!(candidates.contains(&canon_space)); + } +} diff --git a/codex-rs/windows-sandbox-rs/src/sandbox_users.rs b/codex-rs/windows-sandbox-rs/src/sandbox_users.rs index 41298b8fba..8aa67a41a5 100644 --- a/codex-rs/windows-sandbox-rs/src/sandbox_users.rs +++ b/codex-rs/windows-sandbox-rs/src/sandbox_users.rs @@ -13,6 +13,7 @@ use std::fs::File; use std::path::Path; use std::path::PathBuf; use windows_sys::Win32::Foundation::GetLastError; +use windows_sys::Win32::Foundation::LocalFree; use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; use windows_sys::Win32::NetworkManagement::NetManagement::NERR_Success; use windows_sys::Win32::NetworkManagement::NetManagement::NetLocalGroupAdd; @@ -27,7 +28,10 @@ use windows_sys::Win32::NetworkManagement::NetManagement::USER_INFO_1; use windows_sys::Win32::NetworkManagement::NetManagement::USER_INFO_1003; use windows_sys::Win32::NetworkManagement::NetManagement::USER_PRIV_USER; use windows_sys::Win32::Security::Authorization::ConvertStringSidToSidW; +use windows_sys::Win32::Security::CopySid; +use windows_sys::Win32::Security::GetLengthSid; use windows_sys::Win32::Security::LookupAccountNameW; +use windows_sys::Win32::Security::LookupAccountSidW; use windows_sys::Win32::Security::SID_NAME_USE; use codex_windows_sandbox::dpapi_protect; @@ -38,6 +42,11 @@ use codex_windows_sandbox::SETUP_VERSION; pub const SANDBOX_USERS_GROUP: &str = "CodexSandboxUsers"; const SANDBOX_USERS_GROUP_COMMENT: &str = "Codex sandbox internal group (managed)"; +const SID_ADMINISTRATORS: &str = "S-1-5-32-544"; +const SID_USERS: &str = "S-1-5-32-545"; +const SID_AUTHENTICATED_USERS: &str = "S-1-5-11"; +const SID_EVERYONE: &str = "S-1-1-0"; +const SID_SYSTEM: &str = "S-1-5-18"; pub fn ensure_sandbox_users_group(log: &mut File) -> Result<()> { ensure_local_group(SANDBOX_USERS_GROUP, SANDBOX_USERS_GROUP_COMMENT, log) @@ -119,17 +128,24 @@ pub fn ensure_local_user(name: &str, password: &str, log: &mut File) -> Result<( } // Ensure the principal is a regular local user account. - let group = to_wide(OsStr::new("Users")); - let member = LOCALGROUP_MEMBERS_INFO_3 { - lgrmi3_domainandname: name_w.as_ptr() as *mut u16, - }; - let _ = NetLocalGroupAddMembers( - std::ptr::null(), - group.as_ptr(), - 3, - &member as *const _ as *mut u8, - 1, - ); + if let Ok(group_name) = lookup_account_name_for_sid(SID_USERS) { + let group = to_wide(OsStr::new(&group_name)); + let member = LOCALGROUP_MEMBERS_INFO_3 { + lgrmi3_domainandname: name_w.as_ptr() as *mut u16, + }; + let _ = NetLocalGroupAddMembers( + std::ptr::null(), + group.as_ptr(), + 3, + &member as *const _ as *mut u8, + 1, + ); + } else { + super::log_line( + log, + "LookupAccountSidW failed for Users SID; skipping Users group membership", + )?; + } } Ok(()) } @@ -184,6 +200,9 @@ pub fn ensure_local_group_member(group_name: &str, member_name: &str) -> Result< } pub fn resolve_sid(name: &str) -> Result> { + if let Some(sid_str) = well_known_sid_str(name) { + return sid_bytes_from_string(sid_str); + } let name_w = to_wide(OsStr::new(name)); let mut sid_buffer = vec![0u8; 68]; let mut sid_len: u32 = sid_buffer.len() as u32; @@ -218,6 +237,104 @@ pub fn resolve_sid(name: &str) -> Result> { } } +fn well_known_sid_str(name: &str) -> Option<&'static str> { + match name { + "Administrators" => Some(SID_ADMINISTRATORS), + "Users" => Some(SID_USERS), + "Authenticated Users" => Some(SID_AUTHENTICATED_USERS), + "Everyone" => Some(SID_EVERYONE), + "SYSTEM" => Some(SID_SYSTEM), + _ => None, + } +} + +fn sid_bytes_from_string(sid_str: &str) -> Result> { + let sid_w = to_wide(OsStr::new(sid_str)); + let mut psid: *mut c_void = std::ptr::null_mut(); + if unsafe { ConvertStringSidToSidW(sid_w.as_ptr(), &mut psid) } == 0 { + return Err(anyhow::anyhow!( + "ConvertStringSidToSidW failed for {sid_str}: {}", + unsafe { GetLastError() } + )); + } + let sid_len = unsafe { GetLengthSid(psid) }; + if sid_len == 0 { + unsafe { + LocalFree(psid as _); + } + return Err(anyhow::anyhow!("GetLengthSid failed for {sid_str}")); + } + let mut out = vec![0u8; sid_len as usize]; + let ok = unsafe { CopySid(sid_len, out.as_mut_ptr() as *mut c_void, psid) }; + unsafe { + LocalFree(psid as _); + } + if ok == 0 { + return Err(anyhow::anyhow!("CopySid failed for {sid_str}")); + } + Ok(out) +} + +fn lookup_account_name_for_sid(sid_str: &str) -> Result { + let sid_w = to_wide(OsStr::new(sid_str)); + let mut psid: *mut c_void = std::ptr::null_mut(); + if unsafe { ConvertStringSidToSidW(sid_w.as_ptr(), &mut psid) } == 0 { + return Err(anyhow::anyhow!( + "ConvertStringSidToSidW failed for {sid_str}: {}", + unsafe { GetLastError() } + )); + } + let mut name_len: u32 = 0; + let mut domain_len: u32 = 0; + let mut use_type: SID_NAME_USE = 0; + let ok = unsafe { + LookupAccountSidW( + std::ptr::null(), + psid, + std::ptr::null_mut(), + &mut name_len, + std::ptr::null_mut(), + &mut domain_len, + &mut use_type, + ) + }; + if ok == 0 { + let err = unsafe { GetLastError() }; + if err != ERROR_INSUFFICIENT_BUFFER { + unsafe { + LocalFree(psid as _); + } + return Err(anyhow::anyhow!( + "LookupAccountSidW preflight failed for {sid_str}: {err}" + )); + } + } + let mut name_buf: Vec = vec![0u16; name_len as usize]; + let mut domain_buf: Vec = vec![0u16; domain_len as usize]; + let ok = unsafe { + LookupAccountSidW( + std::ptr::null(), + psid, + name_buf.as_mut_ptr(), + &mut name_len, + domain_buf.as_mut_ptr(), + &mut domain_len, + &mut use_type, + ) + }; + unsafe { + LocalFree(psid as _); + } + if ok == 0 { + return Err(anyhow::anyhow!( + "LookupAccountSidW failed for {sid_str}: {}", + unsafe { GetLastError() } + )); + } + let name = String::from_utf16_lossy(&name_buf); + Ok(name.trim_end_matches('\0').to_string()) +} + pub fn sid_bytes_to_psid(sid: &[u8]) -> Result<*mut c_void> { let sid_str = string_from_sid_bytes(sid).map_err(anyhow::Error::msg)?; let sid_w = to_wide(OsStr::new(&sid_str)); diff --git a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs index 30907de7c8..7578e494cd 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs @@ -26,7 +26,7 @@ use windows_sys::Win32::Security::CheckTokenMembership; use windows_sys::Win32::Security::FreeSid; use windows_sys::Win32::Security::SECURITY_NT_AUTHORITY; -pub const SETUP_VERSION: u32 = 3; +pub const SETUP_VERSION: u32 = 4; pub const OFFLINE_USERNAME: &str = "CodexSandboxOffline"; pub const ONLINE_USERNAME: &str = "CodexSandboxOnline"; const SECURITY_BUILTIN_DOMAIN_RID: u32 = 0x0000_0020; 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.