Merge remote-tracking branch 'origin/main' into jif/review-as-collab

# Conflicts:
#	codex-rs/core/src/agent/control.rs
#	codex-rs/tui/src/app.rs
This commit is contained in:
jif-oai
2026-01-21 10:31:57 +00:00
127 changed files with 8091 additions and 1397 deletions
+2 -2
View File
@@ -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
+1 -2
View File
@@ -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 <project>` (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
Regular → Executable
View File
+29 -15
View File
@@ -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"
+6 -2
View File
@@ -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" }
@@ -503,17 +503,14 @@ impl From<CoreTextElement> 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<V1TextElement> 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)
}
}
@@ -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<String>,
placeholder: Option<String>,
}
impl TextElement {
pub fn new(byte_range: ByteRange, placeholder: Option<String>) -> Self {
Self {
byte_range,
placeholder,
}
}
pub fn set_placeholder(&mut self, placeholder: Option<String>) {
self.placeholder = placeholder;
}
pub fn placeholder(&self) -> Option<&str> {
self.placeholder.as_deref()
}
}
impl From<CoreTextElement> 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<TextElement> 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)
}
}
@@ -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?;
@@ -62,7 +62,9 @@ async fn thread_resume_returns_original_thread() -> Result<()> {
let ThreadResumeResponse {
thread: resumed, ..
} = to_response::<ThreadResumeResponse>(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("<note>".into()),
}];
let text_elements = vec![TextElement::new(
ByteRange { start: 0, end: 5 },
Some("<note>".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::<ThreadResumeResponse>(resume_resp)?;
assert_eq!(resumed, thread);
let mut expected = thread;
expected.updated_at = resumed.updated_at;
assert_eq!(resumed, expected);
Ok(())
}
@@ -142,10 +142,10 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> {
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let text_elements = vec![TextElement {
byte_range: ByteRange { start: 0, end: 5 },
placeholder: Some("<note>".to_string()),
}];
let text_elements = vec![TextElement::new(
ByteRange { start: 0, end: 5 },
Some("<note>".to_string()),
)];
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
+1 -1
View File
@@ -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 }
+30
View File
@@ -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<bool> {
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
@@ -113,7 +113,9 @@ impl<A: AuthProvider> ResponsesWebsocketClient<A> {
extra_headers: HeaderMap,
turn_state: Option<Arc<OnceLock<String>>>,
) -> Result<ResponsesWebsocketConnection, ApiError> {
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<Arc<OnceLock<String>>>,
) -> 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}"
)));
+14
View File
@@ -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<Url, url::ParseError> {
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 {
+1
View File
@@ -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 = [
+64 -15
View File
@@ -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": {
+141 -10
View File
@@ -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<ThreadManagerState>,
state: Arc<Guards>,
}
impl AgentControl {
/// Construct a new `AgentControl` that can spawn/message agents via the given manager state.
pub(crate) fn new(manager: Weak<ThreadManagerState>) -> 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<SessionSource>,
) -> CodexResult<ThreadId> {
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");
}
}
+193
View File
@@ -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<HashSet<ThreadId>>,
total_count: AtomicUsize,
}
impl Guards {
pub(crate) fn reserve_spawn_slot(
self: &Arc<Self>,
max_threads: Option<usize>,
) -> Result<SpawnReservation> {
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<Guards>,
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);
}
}
+2
View File
@@ -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;
+4
View File
@@ -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<Personality>,
/// Optional the output schema for the model's response.
pub output_schema: Option<Value>,
}
+83 -29
View File
@@ -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 `<turn_aborted>` 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 `<turn_aborted>` 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]
@@ -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<String> = iter.cloned().collect();
if remaining.is_empty() {
return false;
}
let remaining: Vec<String> = iter.cloned().collect();
args_have_url(&remaining)
let cmd_tokens: Vec<String> = 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<String> = 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<String> {
// 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<String>> = 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"
])));
}
}
+60 -1
View File
@@ -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<String> {
Some(user.message())
}
}
_ => None,
_ => collect_turn_aborted_marker(item),
})
.collect()
}
fn collect_turn_aborted_marker(item: &ResponseItem) -> Option<String> {
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_id>turn-1</turn_id>\n <reason>interrupted</reason>\n</turn_aborted>"
);
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 <turn_aborted> marker"
);
}
}
+1
View File
@@ -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,
};
+170 -9
View File
@@ -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<usize> = 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<Personality>,
/// Approval policy for executing commands.
pub approval_policy: Constrained<AskForApproval>,
@@ -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<usize>,
/// Maximum number of agent threads that can be open concurrently.
pub agent_max_threads: Option<usize>,
/// 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<Vec<(String, TomlValue)>>,
harness_overrides: Option<ConfigOverrides>,
loader_overrides: Option<LoaderOverrides>,
fallback_cwd: Option<PathBuf>,
}
impl ConfigBuilder {
@@ -418,21 +427,29 @@ impl ConfigBuilder {
self
}
pub fn fallback_cwd(mut self, fallback_cwd: Option<PathBuf>) -> Self {
self.fallback_cwd = fallback_cwd;
self
}
pub async fn build(self) -> std::io::Result<Config> {
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<String>,
/// 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<AbsolutePathBuf>,
/// Compact prompt used for history compaction.
pub compact_prompt: Option<String>,
@@ -890,6 +913,10 @@ pub struct ConfigToml {
/// Override to force-enable reasoning summaries for the configured model.
pub model_supports_reasoning_summaries: Option<bool>,
/// EXPERIMENTAL
/// Optionally specify a personality for the model
pub model_personality: Option<Personality>,
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
pub chatgpt_base_url: Option<String>,
@@ -901,6 +928,9 @@ pub struct ConfigToml {
/// Nested tools section for feature toggles
pub tools: Option<ToolsToml>,
/// Agent-related settings (thread limits, etc.).
pub agents: Option<AgentsToml>,
/// User-level skill config entries keyed by SKILL.md path.
pub skills: Option<SkillsConfig>,
@@ -948,6 +978,8 @@ pub struct ConfigToml {
pub notice: Option<Notice>,
/// Legacy, now use features
/// Deprecated: ignored. Use `model_instructions_file`.
#[schemars(skip)]
pub experimental_instructions_file: Option<AbsolutePathBuf>,
pub experimental_compact_prompt_file: Option<AbsolutePathBuf>,
pub experimental_use_unified_exec_tool: Option<bool>,
@@ -1008,6 +1040,15 @@ pub struct ToolsToml {
pub view_image: Option<bool>,
}
#[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<usize>,
}
impl From<ToolsToml> 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,
+6
View File
@@ -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<ReasoningEffort>,
pub model_reasoning_summary: Option<ReasoningSummary>,
pub model_verbosity: Option<Verbosity>,
pub model_personality: Option<Personality>,
pub chatgpt_base_url: Option<String>,
/// Optional path to a file containing model instructions.
pub model_instructions_file: Option<AbsolutePathBuf>,
/// Deprecated: ignored. Use `model_instructions_file`.
#[schemars(skip)]
pub experimental_instructions_file: Option<AbsolutePathBuf>,
pub experimental_compact_prompt_file: Option<AbsolutePathBuf>,
pub include_apply_patch_tool: Option<bool>,
+8
View File
@@ -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::*;
+2 -2
View File
@@ -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()
+3 -3
View File
@@ -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()),
)];
+1 -6
View File
@@ -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("<environment_context>")
}
pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool {
let ResponseItem::Message { role, content, .. } = item else {
return false;
+2 -1
View File
@@ -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();
}
+7 -3
View File
@@ -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,
}
+1 -6
View File
@@ -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("<environment_context>")
}
fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
if UserInstructions::is_user_instructions(message)
|| SkillInstructions::is_skill_instructions(message)
+59 -29
View File
@@ -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<Vec<u8>> {
}
#[inline]
fn append_all(dst: &mut Vec<u8>, src: &[u8]) {
dst.extend_from_slice(src);
fn append_capped(dst: &mut Vec<u8>, 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::<Vec<u8>>();
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<R: AsyncRead + Unpin + Send + 'static>(
mut reader: R,
stream: Option<StdoutStream>,
is_stderr: bool,
aggregate_tx: Option<Sender<Vec<u8>>>,
) -> io::Result<StreamOutput<Vec<u8>>> {
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<R: AsyncRead + Unpin + Send + 'static>(
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() {
+10 -2
View File
@@ -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,
},
];
+2
View File
@@ -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;
+37 -33
View File
@@ -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<ModelInfo>) {
*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<Vec<ModelInfo>, 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,
@@ -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
}
+19
View File
@@ -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<Vec<serde_json::Va
Ok(summary.head)
}
/// Read the SessionMetaLine from the head of a rollout file for reuse by
/// callers that need the session metadata (e.g. to derive a cwd for config).
pub async fn read_session_meta_line(path: &Path) -> io::Result<SessionMetaLine> {
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::<SessionMetaLine>(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<Option<OffsetDateTime>> {
let meta = tokio::fs::metadata(path).await?;
let modified = meta.modified().ok();
+8 -20
View File
@@ -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<RolloutCmd>,
+15
View File
@@ -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. `<environment_context>`, `<turn_aborted>`). 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 = "<environment_context>";
pub(crate) const TURN_ABORTED_OPEN_TAG: &str = "<turn_aborted>";
/// 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)
}
+16
View File
@@ -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],
+24
View File
@@ -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 <turn_id>{sub_id}</turn_id>\n <reason>interrupted</reason>\n <guidance>{TURN_ABORTED_INTERRUPTED_GUIDANCE}</guidance>\n</turn_aborted>"
),
}],
};
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;
}
+9 -8
View File
@@ -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,
+19 -1
View File
@@ -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<Multiplexer>) -> 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<TerminalName> {
"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!(
+2 -1
View File
@@ -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,
@@ -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)
@@ -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.
@@ -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.
@@ -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.
+1 -1
View File
@@ -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;
}
}
+76
View File
@@ -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 `<turn_aborted>` 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("<turn_aborted>")),
"expected <turn_aborted> marker in follow-up request"
);
}
+6 -7
View File
@@ -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");
+1 -1
View File
@@ -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(())
@@ -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,
@@ -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(())
}
+4 -4
View File
@@ -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("<file>".into()),
}];
let text_elements = vec![TextElement::new(
ByteRange { start: 0, end: 6 },
Some("<file>".into()),
)];
let expected_input = UserInput::Text {
text: "please inspect sample.txt".into(),
text_elements: text_elements.clone(),
+109 -41
View File
@@ -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<ModelPreset> {
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<ModelPreset> {
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<ReasoningEffort, ReasoningEffort>,
) -> 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.
"#}
+23
View File
@@ -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);
}
+165 -1
View File
@@ -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,
+137 -1
View File
@@ -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<bool>) {
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<F>(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
}
+4 -4
View File
@@ -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("<note>".into()),
}];
let text_elements = vec![TextElement::new(
ByteRange { start: 0, end: 6 },
Some("<note>".into()),
)];
codex
.submit(Op::UserInput {
+1 -1
View File
@@ -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 }
+8 -7
View File
@@ -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();
}
+45 -1
View File
@@ -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<String>,
placeholder: Option<String>,
}
impl TextElement {
pub fn new(byte_range: ByteRange, placeholder: Option<String>) -> 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<F>(&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<String>) {
self.placeholder = placeholder;
}
/// Returns the stored placeholder without falling back to the text buffer.
///
/// This must only be used inside `From<TextElement>` 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)]
+1 -1
View File
@@ -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 }
+2 -1
View File
@@ -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",
+59 -26
View File
@@ -86,6 +86,17 @@ pub struct AppExitInfo {
pub exit_reason: ExitReason,
}
impl AppExitInfo {
pub fn fatal(message: impl Into<String>) -> 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<AuthManager>,
/// 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<String>,
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<AppRunControl> {
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<Line<'static>> = 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<Line<'static>> =
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<Line<'static>> =
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<ReasoningEffortConfig>) {
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<dyn HistoryCell>
};
+4
View File
@@ -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,
@@ -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<ApprovalRequest> {
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<RequestUserInputEvent> {
Some(request)
}
}
+561 -73
View File
@@ -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<usize> },
Submit { text: String },
Insert {
text: String,
cursor: Option<usize>,
},
Submit {
text: String,
text_elements: Vec<TextElement>,
},
}
pub(crate) struct ChatComposer {
@@ -557,12 +575,7 @@ impl ChatComposer {
let image_placeholders: HashSet<String> = 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<InputResult> {
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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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
+88 -4
View File
@@ -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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
+513 -65
View File
@@ -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 <rest>`.
/// 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<TextElement>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PromptExpansion {
pub text: String,
pub text_elements: Vec<TextElement>,
}
/// Parse positional arguments using shlex semantics (supports quoted tokens).
pub fn parse_positional_args(rest: &str) -> Vec<String> {
Shlex::new(rest).collect()
///
/// `text_elements` must be relative to `rest`.
pub fn parse_positional_args(rest: &str, text_elements: &[TextElement]) -> Vec<PromptArg> {
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<String> {
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<TextElement> {
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<HashMap<String, String>, PromptArgsError> {
pub fn parse_prompt_inputs(
rest: &str,
text_elements: &[TextElement],
) -> Result<HashMap<String, PromptArg>, 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<HashMap<String, String>, Prompt
/// `Ok(Some(expanded))`; otherwise it returns a descriptive error.
pub fn expand_custom_prompt(
text: &str,
text_elements: &[TextElement],
custom_prompts: &[CustomPrompt],
) -> Result<Option<String>, PromptExpansionError> {
let Some((name, rest)) = parse_slash_name(text) else {
) -> Result<Option<PromptExpansion>, 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<TextElement> = 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<String> = 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: &regex_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<String> = 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<String> {
pub fn extract_positional_args_for_prompt_line(
line: &str,
prompt_name: &str,
text_elements: &[TextElement],
) -> Vec<PromptArg> {
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<TextElement> = 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<String> {
text_elements: &[TextElement],
) -> Option<PromptExpansion> {
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<String> = 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<PromptArg> {
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<String>,
}
/// 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<ElementReplacement>) {
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, PromptArg>,
) -> (String, Vec<TextElement>) {
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<TextElement>,
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<TextElement>,
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(),
})
);
}
}
@@ -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<String>,
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::<Vec<_>>()
})
.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,
}
}
}
@@ -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<TextAreaState>,
}
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<usize>,
// 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<NotesEntry>,
}
pub(crate) struct RequestUserInputOverlay {
app_event_tx: AppEventSender,
request: RequestUserInputEvent,
// Queue of incoming requests to process after the current one.
queue: VecDeque<RequestUserInputEvent>,
answers: Vec<AnswerState>,
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<usize> {
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::<Vec<_>>();
// 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<RequestUserInputEvent> {
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<AppEvent>,
) {
let (tx_raw, rx) = unbounded_channel::<AppEvent>();
(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<RequestUserInputQuestion>,
) -> 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::<String>::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));
}
}
@@ -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::<Vec<_>>()
})
.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: "
}
@@ -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
@@ -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
@@ -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
@@ -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
@@ -5,7 +5,6 @@ expression: "render_snapshot(&pane, area)"
↳ Queued follow-up question
⌥ + ↑ edit
› Ask Codex to do anything
100% context left · ? for shortcuts
@@ -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
@@ -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
@@ -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
+3 -3
View File
@@ -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()
}
+246 -96
View File
@@ -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<String>,
/// 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<AuthManager>,
models_manager: Arc<ModelsManager>,
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<SelectionAction> = 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<SelectionItem> = 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<SelectionAction> = 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<ReasoningEffortConfig>,
@@ -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<ReasoningEffortConfig>) {
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<ReasoningEffortConfig> {
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<dyn HistoryCell> {
fn placeholder_session_header_cell(
config: &Config,
is_collaboration: bool,
collaboration_mode: CollaborationMode,
) -> Box<dyn HistoryCell> {
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<PathBuf> {
self.current_rollout_path.clone()
}
@@ -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),
@@ -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
@@ -6,11 +6,11 @@ expression: popup
Access legacy models by running codex -m <model_name> 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.
@@ -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
+133 -115
View File
@@ -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);
+32 -118
View File
@@ -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 <mode>` 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<Selection> {
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<CollaborationMode> {
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<CollaborationMode> {
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<ReasoningEffort>,
) -> 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()
}
+80 -17
View File
@@ -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<Box<dyn HistoryCell>> = vec![Box::new(header)];
@@ -991,6 +998,8 @@ pub(crate) struct SessionHeaderHistoryCell {
model_style: Style,
reasoning_effort: Option<ReasoningEffortConfig>,
directory: PathBuf,
is_collaboration: bool,
collaboration_mode: CollaborationMode,
}
impl SessionHeaderHistoryCell {
@@ -999,6 +1008,8 @@ impl SessionHeaderHistoryCell {
reasoning_effort: Option<ReasoningEffortConfig>,
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<ReasoningEffortConfig>,
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:<label_width$}",
model_label = "model:",
label_width = label_width
);
let reasoning_label = self.reasoning_label();
let mut model_spans: Vec<Span<'static>> = 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<Span<'static>> = if self.is_collaboration {
// Render collaboration mode instead of model
let collab_label = format!(
"{collab_label:<label_width$}",
collab_label = "mode:",
label_width = label_width
);
let mut spans = vec![Span::from(format!("{collab_label} ")).dim()];
if self.model == "loading" {
spans.push(Span::styled(self.model.clone(), self.model_style));
} else if let Some(mode_label) = self.collaboration_mode_label() {
spans.push(Span::styled(mode_label.to_string(), self.model_style));
} else {
spans.push(Span::styled("Custom", self.model_style));
}
spans.push(" ".dim());
let shift_tab_span: Span<'static> = 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:<label_width$}",
model_label = "model:",
label_width = label_width
);
let reasoning_label = self.reasoning_label();
let mut spans = vec![
Span::from(format!("{model_label} ")).dim(),
Span::styled(self.model.clone(), self.model_style),
];
if let Some(reasoning) = reasoning_label {
spans.push(Span::from(" "));
spans.push(Span::from(reasoning));
}
spans.push(" ".dim());
spans.push(CHANGE_MODEL_HINT_COMMAND.cyan());
spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim());
spans
};
let dir_label = format!("{DIR_LABEL:<label_width$}");
let dir_prefix = format!("{dir_label} ");
@@ -1828,6 +1883,8 @@ mod tests {
use codex_core::config::types::McpServerConfig;
use codex_core::config::types::McpServerTransportConfig;
use codex_core::protocol::McpAuthStatus;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Settings;
use codex_protocol::parse_command::ParsedCommand;
use dirs::home_dir;
use pretty_assertions::assert_eq;
@@ -2284,6 +2341,12 @@ mod tests {
Some(ReasoningEffortConfig::High),
std::env::temp_dir(),
"test",
false,
CollaborationMode::Custom(Settings {
model: "gpt-4o".to_string(),
reasoning_effort: Some(ReasoningEffortConfig::High),
developer_instructions: None,
}),
);
let lines = render_lines(&cell.display_lines(80));
+51 -17
View File
@@ -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;
@@ -247,7 +249,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.
@@ -337,16 +338,9 @@ pub async fn run_main(
.with(otel_tracing_layer)
.try_init();
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(
@@ -354,7 +348,6 @@ async fn run_ratatui_app(
initial_config: Config,
overrides: ConfigOverrides,
cli_kv_overrides: Vec<(String, toml::Value)>,
active_profile: Option<String>,
feedback: codex_feedback::CodexFeedback,
) -> color_eyre::Result<AppExitInfo> {
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<PathBuf>,
) -> 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}");
+27 -2
View File
@@ -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<Local>,
model_name: &str,
collaboration_mode: Option<&str>,
reasoning_effort_override: Option<Option<ReasoningEffort>>,
) -> 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<Local>,
model_name: &str,
collaboration_mode: Option<&str>,
reasoning_effort_override: Option<Option<ReasoningEffort>>,
) -> 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()
+17 -2
View File
@@ -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
+61 -18
View File
@@ -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<AuthManager>,
/// 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<String>,
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<AppRunControl> {
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<ReasoningEffortConfig>) {
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<dyn HistoryCell>
};
+10
View File
@@ -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),
+561 -73
View File
@@ -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<usize> },
Submit { text: String },
Insert {
text: String,
cursor: Option<usize>,
},
Submit {
text: String,
text_elements: Vec<TextElement>,
},
}
pub(crate) struct ChatComposer {
@@ -489,12 +507,7 @@ impl ChatComposer {
let image_placeholders: HashSet<String> = 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<InputResult> {
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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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
+11 -1
View File
@@ -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]

Some files were not shown because too many files have changed in this diff Show More