Merge branch 'main' into jif/shell-snapshot-stable

This commit is contained in:
jif-oai
2026-01-21 12:21:24 +00:00
committed by GitHub
72 changed files with 5980 additions and 484 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
@@ -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(())
}
@@ -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(())
}
+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();
+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 {
+42 -11
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,14 @@
"type": "integer",
"format": "int64"
},
"model_instructions_file": {
"description": "Optional path to a file containing model instructions that will override the built-in instructions for the selected model. Users are STRONGLY DISCOURAGED from using this field, as deviating from the instructions sanctioned by Codex will likely degrade model performance.",
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
]
},
"model_personality": {
"description": "EXPERIMENTAL Optionally specify a personality for the model",
"allOf": [
@@ -433,6 +444,18 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentsToml": {
"type": "object",
"properties": {
"max_threads": {
"description": "Maximum number of agent threads that can be open concurrently. When unset, no limit is enforced.",
"type": "integer",
"format": "uint",
"minimum": 1.0
}
},
"additionalProperties": false
},
"AltScreenMode": {
"description": "Controls whether the TUI uses the terminal's alternate screen buffer.\n\n**Background:** The alternate screen buffer provides a cleaner fullscreen experience without polluting the terminal's scrollback history. However, it conflicts with terminal multiplexers like Zellij that strictly follow the xterm specification, which defines that alternate screen buffers should not have scrollback.\n\n**Zellij's behavior:** Zellij intentionally disables scrollback in alternate screen mode (see https://github.com/zellij-org/zellij/pull/1032) to comply with the xterm spec. This is by design and not configurable in Zellij—there is no option to enable scrollback in alternate screen mode.\n\n**Solution:** This setting provides a pragmatic workaround: - `auto` (default): Automatically detect the terminal multiplexer. If running in Zellij, disable alternate screen to preserve scrollback. Enable it everywhere else. - `always`: Always use alternate screen mode (original behavior before this fix). - `never`: Never use alternate screen mode. Runs in inline mode, preserving scrollback in all multiplexers.\n\nThe CLI flag `--no-alt-screen` can override this setting at runtime.",
"oneOf": [
@@ -545,9 +568,6 @@
"experimental_compact_prompt_file": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"experimental_instructions_file": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"experimental_use_freeform_apply_patch": {
"type": "boolean"
},
@@ -604,6 +624,9 @@
"remote_models": {
"type": "boolean"
},
"responses_websockets": {
"type": "boolean"
},
"shell_snapshot": {
"type": "boolean"
},
@@ -640,6 +663,14 @@
"model": {
"type": "string"
},
"model_instructions_file": {
"description": "Optional path to a file containing model instructions.",
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
]
},
"model_personality": {
"$ref": "#/definitions/Personality"
},
+137 -2
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;
@@ -12,18 +13,25 @@ use tokio::sync::watch;
/// Control-plane handle for multi-agent operations.
/// `AgentControl` is held by each session (via `SessionServices`). It provides capability to
/// spawn new agents and the inter-agent communication layer.
/// An `AgentControl` instance is shared per "user session" which means the same `AgentControl`
/// is used for every sub-agent spawned by Codex. By doing so, we make sure the guards are
/// scoped to a user session.
#[derive(Clone, Default)]
pub(crate) struct AgentControl {
/// Weak handle back to the global thread registry/state.
/// This is `Weak` to avoid reference cycles and shadow persistence of the form
/// `ThreadManagerState -> CodexThread -> Session -> SessionServices -> ThreadManagerState`.
manager: Weak<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.
@@ -33,7 +41,11 @@ impl AgentControl {
prompt: String,
) -> CodexResult<ThreadId> {
let state = self.upgrade()?;
let reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
// The same `AgentControl` is sent to spawn the thread.
let new_thread = state.spawn_new_thread(config, self.clone()).await?;
reservation.commit(new_thread.thread_id);
// Notify a new thread has been created. This notification will be processed by clients
// to subscribe or drain this newly created thread.
@@ -67,6 +79,7 @@ impl AgentControl {
.await;
if matches!(result, Err(CodexErr::InternalAgentDied)) {
let _ = state.remove_thread(&agent_id).await;
self.state.release_spawned_thread(agent_id);
}
result
}
@@ -82,6 +95,7 @@ impl AgentControl {
let state = self.upgrade()?;
let result = state.send_op(agent_id, Op::Shutdown {}).await;
let _ = state.remove_thread(&agent_id).await;
self.state.release_spawned_thread(agent_id);
result
}
@@ -132,17 +146,25 @@ mod tests {
use codex_protocol::protocol::TurnStartedEvent;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use toml::Value as TomlValue;
async fn test_config() -> (TempDir, Config) {
async fn test_config_with_cli_overrides(
cli_overrides: Vec<(String, TomlValue)>,
) -> (TempDir, Config) {
let home = TempDir::new().expect("create temp dir");
let config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.cli_overrides(cli_overrides)
.build()
.await
.expect("load default test config");
(home, config)
}
async fn test_config() -> (TempDir, Config) {
test_config_with_cli_overrides(Vec::new()).await
}
struct AgentControlHarness {
_home: TempDir,
config: Config,
@@ -373,4 +395,117 @@ mod tests {
.find(|entry| *entry == expected);
assert_eq!(captured, Some(expected));
}
#[tokio::test]
async fn spawn_agent_respects_max_threads_limit() {
let max_threads = 1usize;
let (_home, config) = test_config_with_cli_overrides(vec![(
"agents.max_threads".to_string(),
TomlValue::Integer(max_threads as i64),
)])
.await;
let manager = ThreadManager::with_models_provider_and_home(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.clone(),
);
let control = manager.agent_control();
let _ = manager
.start_thread(config.clone())
.await
.expect("start thread");
let first_agent_id = control
.spawn_agent(config.clone(), "hello".to_string())
.await
.expect("spawn_agent should succeed");
let err = control
.spawn_agent(config, "hello again".to_string())
.await
.expect_err("spawn_agent should respect max threads");
let CodexErr::AgentLimitReached {
max_threads: seen_max_threads,
} = err
else {
panic!("expected CodexErr::AgentLimitReached");
};
assert_eq!(seen_max_threads, max_threads);
let _ = control
.shutdown_agent(first_agent_id)
.await
.expect("shutdown agent");
}
#[tokio::test]
async fn spawn_agent_releases_slot_after_shutdown() {
let max_threads = 1usize;
let (_home, config) = test_config_with_cli_overrides(vec![(
"agents.max_threads".to_string(),
TomlValue::Integer(max_threads as i64),
)])
.await;
let manager = ThreadManager::with_models_provider_and_home(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.clone(),
);
let control = manager.agent_control();
let first_agent_id = control
.spawn_agent(config.clone(), "hello".to_string())
.await
.expect("spawn_agent should succeed");
let _ = control
.shutdown_agent(first_agent_id)
.await
.expect("shutdown agent");
let second_agent_id = control
.spawn_agent(config.clone(), "hello again".to_string())
.await
.expect("spawn_agent should succeed after shutdown");
let _ = control
.shutdown_agent(second_agent_id)
.await
.expect("shutdown agent");
}
#[tokio::test]
async fn spawn_agent_limit_shared_across_clones() {
let max_threads = 1usize;
let (_home, config) = test_config_with_cli_overrides(vec![(
"agents.max_threads".to_string(),
TomlValue::Integer(max_threads as i64),
)])
.await;
let manager = ThreadManager::with_models_provider_and_home(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.clone(),
);
let control = manager.agent_control();
let cloned = control.clone();
let first_agent_id = cloned
.spawn_agent(config.clone(), "hello".to_string())
.await
.expect("spawn_agent should succeed");
let err = control
.spawn_agent(config, "hello again".to_string())
.await
.expect_err("spawn_agent should respect shared guard");
let CodexErr::AgentLimitReached { max_threads } = err else {
panic!("expected CodexErr::AgentLimitReached");
};
assert_eq!(max_threads, 1);
let _ = control
.shutdown_agent(first_agent_id)
.await
.expect("shutdown agent");
}
}
+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;
+27 -14
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;
@@ -710,18 +723,12 @@ impl Session {
let mut default_shell = shell::default_user_shell();
// Create the mutable state for the Session.
if config.features.enabled(Feature::ShellSnapshot) {
let timer = otel_manager.start_timer("codex.shell_snapshot.duration_ms", &[]);
default_shell.shell_snapshot =
ShellSnapshot::try_new(&config.codex_home, conversation_id, &default_shell)
.await
.map(Arc::new);
let success = if default_shell.shell_snapshot.is_some() {
"true"
} else {
"false"
};
let _ = timer.map(|timer| timer.record(&[("success", success)]));
otel_manager.counter("codex.shell_snapshot", 1, &[("success", success)])
ShellSnapshot::start_snapshotting(
config.codex_home.clone(),
conversation_id,
&mut default_shell,
otel_manager.clone(),
);
}
let state = SessionState::new(session_configuration.clone());
@@ -1518,6 +1525,11 @@ impl Session {
self.features.clone()
}
pub(crate) async fn collaboration_mode(&self) -> CollaborationMode {
let state = self.state.lock().await;
state.session_configuration.collaboration_mode.clone()
}
async fn send_raw_response_items(&self, turn_context: &TurnContext, items: &[ResponseItem]) {
for item in items {
self.send_event(
@@ -2558,7 +2570,7 @@ mod handlers {
.filter(|item| is_user_turn_boundary(item))
.count();
sess.services.otel_manager.counter(
"conversation.turn.count",
"codex.conversation.turn.count",
i64::try_from(turn_count).unwrap_or(0),
&[],
);
@@ -2794,10 +2806,11 @@ pub(crate) async fn run_turn(
.await,
);
let otel_manager = turn_context.client.get_otel_manager();
let SkillInjections {
items: skill_items,
warnings: skill_warnings,
} = build_skill_injections(&input, skills_outcome.as_ref()).await;
} = build_skill_injections(&input, skills_outcome.as_ref(), Some(&otel_manager)).await;
for message in skill_warnings {
sess.send_event(&turn_context, EventMsg::Warning(WarningEvent { message }))
@@ -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"
])));
}
}
+100 -7
View File
@@ -89,6 +89,7 @@ pub use codex_git::GhostSnapshotConfig;
/// files are *silently truncated* to this size so we do not take up too much of
/// the context window.
pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB
pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option<usize> = None;
pub const CONFIG_TOML_FILE: &str = "config.toml";
@@ -299,6 +300,9 @@ pub struct Config {
/// Token budget applied when storing tool/function outputs in the context manager.
pub tool_output_token_limit: Option<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,
@@ -820,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>,
@@ -918,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>,
@@ -965,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>,
@@ -1025,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 {
@@ -1355,6 +1379,12 @@ impl Config {
|| cfg.sandbox_mode.is_some();
let mut model_providers = built_in_model_providers();
if features.enabled(Feature::ResponsesWebsockets)
&& let Some(provider) = model_providers.get_mut("openai")
&& provider.is_openai()
{
provider.wire_api = crate::model_provider_info::WireApi::ResponsesWebsocket;
}
// Merge user-defined providers into the built-in list.
for (key, provider) in cfg.model_providers.into_iter() {
model_providers.entry(key).or_insert(provider);
@@ -1378,6 +1408,18 @@ impl Config {
let history = cfg.history.unwrap_or_default();
let agent_max_threads = cfg
.agents
.as_ref()
.and_then(|agents| agents.max_threads)
.or(DEFAULT_AGENT_MAX_THREADS);
if agent_max_threads == Some(0) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"agents.max_threads must be at least 1",
));
}
let ghost_snapshot = {
let mut config = GhostSnapshotConfig::default();
if let Some(ghost_snapshot) = cfg.ghost_snapshot.as_ref()
@@ -1432,14 +1474,12 @@ impl Config {
// Load base instructions override from a file if specified. If the
// path is relative, resolve it against the effective cwd so the
// behaviour matches other path-like config values.
let experimental_instructions_path = config_profile
.experimental_instructions_file
let model_instructions_path = config_profile
.model_instructions_file
.as_ref()
.or(cfg.experimental_instructions_file.as_ref());
let file_base_instructions = Self::try_read_non_empty_file(
experimental_instructions_path,
"experimental instructions file",
)?;
.or(cfg.model_instructions_file.as_ref());
let file_base_instructions =
Self::try_read_non_empty_file(model_instructions_path, "model instructions file")?;
let base_instructions = base_instructions.or(file_base_instructions);
let developer_instructions = developer_instructions.or(cfg.developer_instructions);
@@ -1518,6 +1558,7 @@ impl Config {
})
.collect(),
tool_output_token_limit: cfg.tool_output_token_limit,
agent_max_threads,
codex_home,
config_layer_stack,
history,
@@ -1682,6 +1723,30 @@ impl Config {
}
}
pub(crate) fn uses_deprecated_instructions_file(config_layer_stack: &ConfigLayerStack) -> bool {
config_layer_stack
.layers_high_to_low()
.into_iter()
.any(|layer| toml_uses_deprecated_instructions_file(&layer.config))
}
fn toml_uses_deprecated_instructions_file(value: &TomlValue) -> bool {
let Some(table) = value.as_table() else {
return false;
};
if table.contains_key("experimental_instructions_file") {
return true;
}
let Some(profiles) = table.get("profiles").and_then(TomlValue::as_table) else {
return false;
};
profiles.values().any(|profile| {
profile.as_table().is_some_and(|profile_table| {
profile_table.contains_key("experimental_instructions_file")
})
})
}
/// Returns the path to the Codex configuration directory, which can be
/// specified by the `CODEX_HOME` environment variable. If not set, defaults to
/// `~/.codex`.
@@ -2481,6 +2546,30 @@ profile = "project"
Ok(())
}
#[test]
fn responses_websockets_feature_updates_openai_provider() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut entries = BTreeMap::new();
entries.insert("responses_websockets".to_string(), true);
let cfg = ConfigToml {
features: Some(crate::features::FeaturesToml { entries }),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.path().to_path_buf(),
)?;
assert_eq!(
config.model_provider.wire_api,
crate::model_provider_info::WireApi::ResponsesWebsocket
);
Ok(())
}
#[test]
fn config_honors_explicit_file_oauth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -3658,6 +3747,7 @@ model_verbosity = "high"
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
tool_output_token_limit: None,
agent_max_threads: None,
codex_home: fixture.codex_home(),
config_layer_stack: Default::default(),
history: History::default(),
@@ -3746,6 +3836,7 @@ model_verbosity = "high"
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
tool_output_token_limit: None,
agent_max_threads: None,
codex_home: fixture.codex_home(),
config_layer_stack: Default::default(),
history: History::default(),
@@ -3849,6 +3940,7 @@ model_verbosity = "high"
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
tool_output_token_limit: None,
agent_max_threads: None,
codex_home: fixture.codex_home(),
config_layer_stack: Default::default(),
history: History::default(),
@@ -3938,6 +4030,7 @@ model_verbosity = "high"
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
tool_output_token_limit: None,
agent_max_threads: None,
codex_home: fixture.codex_home(),
config_layer_stack: Default::default(),
history: History::default(),
+4
View File
@@ -27,6 +27,10 @@ pub struct ConfigProfile {
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>,
+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()),
)];
+3 -3
View File
@@ -95,7 +95,7 @@ mod tests {
Shell {
shell_type: ShellType::Bash,
shell_path: PathBuf::from("/bin/bash"),
shell_snapshot: None,
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
}
}
@@ -189,7 +189,7 @@ mod tests {
Shell {
shell_type: ShellType::Bash,
shell_path: "/bin/bash".into(),
shell_snapshot: None,
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
},
);
let context2 = EnvironmentContext::new(
@@ -197,7 +197,7 @@ mod tests {
Shell {
shell_type: ShellType::Zsh,
shell_path: "/bin/zsh".into(),
shell_snapshot: None,
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
},
);
+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,
}
+9 -1
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 {
@@ -401,7 +403,7 @@ pub const FEATURES: &[FeatureSpec] = &[
id: Feature::RemoteModels,
key: "remote_models",
stage: Stage::Beta,
default_enabled: false,
default_enabled: true,
},
FeatureSpec {
id: Feature::PowershellUtf8,
@@ -457,4 +459,10 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Beta,
default_enabled: false,
},
FeatureSpec {
id: Feature::ResponsesWebsockets,
key: "responses_websockets",
stage: Stage::Beta,
default_enabled: false,
},
];
+38 -16
View File
@@ -1,9 +1,9 @@
use crate::shell_snapshot::ShellSnapshot;
use serde::Deserialize;
use serde::Serialize;
use std::path::PathBuf;
use std::sync::Arc;
use crate::shell_snapshot::ShellSnapshot;
use tokio::sync::watch;
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub enum ShellType {
@@ -14,12 +14,16 @@ pub enum ShellType {
Cmd,
}
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Shell {
pub(crate) shell_type: ShellType,
pub(crate) shell_path: PathBuf,
#[serde(skip_serializing, skip_deserializing, default)]
pub(crate) shell_snapshot: Option<Arc<ShellSnapshot>>,
#[serde(
skip_serializing,
skip_deserializing,
default = "empty_shell_snapshot_receiver"
)]
pub(crate) shell_snapshot: watch::Receiver<Option<Arc<ShellSnapshot>>>,
}
impl Shell {
@@ -63,8 +67,26 @@ impl Shell {
}
}
}
/// Return the shell snapshot if existing.
pub fn shell_snapshot(&self) -> Option<Arc<ShellSnapshot>> {
self.shell_snapshot.borrow().clone()
}
}
pub(crate) fn empty_shell_snapshot_receiver() -> watch::Receiver<Option<Arc<ShellSnapshot>>> {
let (_tx, rx) = watch::channel(None);
rx
}
impl PartialEq for Shell {
fn eq(&self, other: &Self) -> bool {
self.shell_type == other.shell_type && self.shell_path == other.shell_path
}
}
impl Eq for Shell {}
#[cfg(unix)]
fn get_user_shell_path() -> Option<PathBuf> {
use libc::getpwuid;
@@ -139,7 +161,7 @@ fn get_zsh_shell(path: Option<&PathBuf>) -> Option<Shell> {
shell_path.map(|shell_path| Shell {
shell_type: ShellType::Zsh,
shell_path,
shell_snapshot: None,
shell_snapshot: empty_shell_snapshot_receiver(),
})
}
@@ -149,7 +171,7 @@ fn get_bash_shell(path: Option<&PathBuf>) -> Option<Shell> {
shell_path.map(|shell_path| Shell {
shell_type: ShellType::Bash,
shell_path,
shell_snapshot: None,
shell_snapshot: empty_shell_snapshot_receiver(),
})
}
@@ -159,7 +181,7 @@ fn get_sh_shell(path: Option<&PathBuf>) -> Option<Shell> {
shell_path.map(|shell_path| Shell {
shell_type: ShellType::Sh,
shell_path,
shell_snapshot: None,
shell_snapshot: empty_shell_snapshot_receiver(),
})
}
@@ -175,7 +197,7 @@ fn get_powershell_shell(path: Option<&PathBuf>) -> Option<Shell> {
shell_path.map(|shell_path| Shell {
shell_type: ShellType::PowerShell,
shell_path,
shell_snapshot: None,
shell_snapshot: empty_shell_snapshot_receiver(),
})
}
@@ -185,7 +207,7 @@ fn get_cmd_shell(path: Option<&PathBuf>) -> Option<Shell> {
shell_path.map(|shell_path| Shell {
shell_type: ShellType::Cmd,
shell_path,
shell_snapshot: None,
shell_snapshot: empty_shell_snapshot_receiver(),
})
}
@@ -194,13 +216,13 @@ fn ultimate_fallback_shell() -> Shell {
Shell {
shell_type: ShellType::Cmd,
shell_path: PathBuf::from("cmd.exe"),
shell_snapshot: None,
shell_snapshot: empty_shell_snapshot_receiver(),
}
} else {
Shell {
shell_type: ShellType::Sh,
shell_path: PathBuf::from("/bin/sh"),
shell_snapshot: None,
shell_snapshot: empty_shell_snapshot_receiver(),
}
}
}
@@ -426,7 +448,7 @@ mod tests {
let test_bash_shell = Shell {
shell_type: ShellType::Bash,
shell_path: PathBuf::from("/bin/bash"),
shell_snapshot: None,
shell_snapshot: empty_shell_snapshot_receiver(),
};
assert_eq!(
test_bash_shell.derive_exec_args("echo hello", false),
@@ -440,7 +462,7 @@ mod tests {
let test_zsh_shell = Shell {
shell_type: ShellType::Zsh,
shell_path: PathBuf::from("/bin/zsh"),
shell_snapshot: None,
shell_snapshot: empty_shell_snapshot_receiver(),
};
assert_eq!(
test_zsh_shell.derive_exec_args("echo hello", false),
@@ -454,7 +476,7 @@ mod tests {
let test_powershell_shell = Shell {
shell_type: ShellType::PowerShell,
shell_path: PathBuf::from("pwsh.exe"),
shell_snapshot: None,
shell_snapshot: empty_shell_snapshot_receiver(),
};
assert_eq!(
test_powershell_shell.derive_exec_args("echo hello", false),
@@ -481,7 +503,7 @@ mod tests {
Shell {
shell_type: ShellType::Zsh,
shell_path: PathBuf::from(shell_path),
shell_snapshot: None,
shell_snapshot: empty_shell_snapshot_receiver(),
}
);
}
+31 -4
View File
@@ -1,6 +1,7 @@
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::SystemTime;
@@ -12,9 +13,11 @@ use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
use codex_otel::OtelManager;
use codex_protocol::ThreadId;
use tokio::fs;
use tokio::process::Command;
use tokio::sync::watch;
use tokio::time::timeout;
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -27,7 +30,31 @@ const SNAPSHOT_RETENTION: Duration = Duration::from_secs(60 * 60 * 24 * 7); // 7
const SNAPSHOT_DIR: &str = "shell_snapshots";
impl ShellSnapshot {
pub async fn try_new(codex_home: &Path, session_id: ThreadId, shell: &Shell) -> Option<Self> {
pub fn start_snapshotting(
codex_home: PathBuf,
session_id: ThreadId,
shell: &mut Shell,
otel_manager: OtelManager,
) {
let (shell_snapshot_tx, shell_snapshot_rx) = watch::channel(None);
shell.shell_snapshot = shell_snapshot_rx;
let snapshot_shell = shell.clone();
let snapshot_session_id = session_id;
tokio::spawn(async move {
let timer = otel_manager.start_timer("codex.shell_snapshot.duration_ms", &[]);
let snapshot =
ShellSnapshot::try_new(&codex_home, snapshot_session_id, &snapshot_shell)
.await
.map(Arc::new);
let success = if snapshot.is_some() { "true" } else { "false" };
let _ = timer.map(|timer| timer.record(&[("success", success)]));
otel_manager.counter("codex.shell_snapshot", 1, &[("success", success)]);
let _ = shell_snapshot_tx.send(snapshot);
});
}
async fn try_new(codex_home: &Path, session_id: ThreadId, shell: &Shell) -> Option<Self> {
// File to store the snapshot
let extension = match shell.shell_type {
ShellType::PowerShell => "ps1",
@@ -74,7 +101,7 @@ impl Drop for ShellSnapshot {
}
}
pub async fn write_shell_snapshot(shell_type: ShellType, output_path: &Path) -> Result<PathBuf> {
async fn write_shell_snapshot(shell_type: ShellType, output_path: &Path) -> Result<PathBuf> {
if shell_type == ShellType::PowerShell || shell_type == ShellType::Cmd {
bail!("Shell snapshot not supported yet for {shell_type:?}");
}
@@ -407,7 +434,7 @@ mod tests {
let shell = Shell {
shell_type: ShellType::Bash,
shell_path: PathBuf::from("/bin/bash"),
shell_snapshot: None,
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
};
let snapshot = ShellSnapshot::try_new(dir.path(), ThreadId::new(), &shell)
@@ -449,7 +476,7 @@ mod tests {
let shell = Shell {
shell_type: ShellType::Sh,
shell_path,
shell_snapshot: None,
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
};
let err = run_shell_script_with_timeout(&shell, "ignored", Duration::from_millis(500))
+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],
+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
@@ -366,7 +366,8 @@ impl ThreadManagerState {
codex,
session_configured.rollout_path.clone(),
));
self.threads.write().await.insert(thread_id, thread.clone());
let mut threads = self.threads.write().await;
threads.insert(thread_id, thread.clone());
Ok(NewThread {
thread_id,
@@ -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)
+9 -7
View File
@@ -305,6 +305,7 @@ mod tests {
use crate::shell::ShellType;
use crate::shell_snapshot::ShellSnapshot;
use crate::tools::handlers::ShellCommandHandler;
use tokio::sync::watch;
/// The logic for is_known_safe_command() has heuristics for known shells,
/// so we must ensure the commands generated by [ShellCommandHandler] can be
@@ -314,14 +315,14 @@ mod tests {
let bash_shell = Shell {
shell_type: ShellType::Bash,
shell_path: PathBuf::from("/bin/bash"),
shell_snapshot: None,
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
};
assert_safe(&bash_shell, "ls -la");
let zsh_shell = Shell {
shell_type: ShellType::Zsh,
shell_path: PathBuf::from("/bin/zsh"),
shell_snapshot: None,
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
};
assert_safe(&zsh_shell, "ls -la");
@@ -329,7 +330,7 @@ mod tests {
let powershell = Shell {
shell_type: ShellType::PowerShell,
shell_path: path.to_path_buf(),
shell_snapshot: None,
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
};
assert_safe(&powershell, "ls -Name");
}
@@ -338,7 +339,7 @@ mod tests {
let pwsh = Shell {
shell_type: ShellType::PowerShell,
shell_path: path.to_path_buf(),
shell_snapshot: None,
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
};
assert_safe(&pwsh, "ls -Name");
}
@@ -391,12 +392,13 @@ mod tests {
#[test]
fn shell_command_handler_respects_explicit_login_flag() {
let (_tx, shell_snapshot) = watch::channel(Some(Arc::new(ShellSnapshot {
path: PathBuf::from("/tmp/snapshot.sh"),
})));
let shell = Shell {
shell_type: ShellType::Bash,
shell_path: PathBuf::from("/bin/bash"),
shell_snapshot: Some(Arc::new(ShellSnapshot {
path: PathBuf::from("/tmp/snapshot.sh"),
})),
shell_snapshot,
};
let login_command =
@@ -236,7 +236,7 @@ impl ToolHandler for UnifiedExecHandler {
fn get_command(args: &ExecCommandArgs, session_shell: Arc<Shell>) -> Vec<String> {
let model_shell = args.shell.as_ref().map(|shell_str| {
let mut shell = get_shell_by_model_provided_path(&PathBuf::from(shell_str));
shell.shell_snapshot = None;
shell.shell_snapshot = crate::shell::empty_shell_snapshot_receiver();
shell
});
+1 -1
View File
@@ -54,7 +54,7 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot(
command: &[String],
session_shell: &Shell,
) -> Vec<String> {
let Some(snapshot) = &session_shell.shell_snapshot else {
let Some(snapshot) = session_shell.shell_snapshot() else {
return command.to_vec();
};
+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
@@ -826,7 +826,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> {
.get("reasoning")
.and_then(|t| t.get("effort"))
.and_then(|v| v.as_str()),
None
Some("medium")
);
Ok(())
@@ -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(())
}
+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.
"#}
+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
}
+24 -24
View File
@@ -20,9 +20,11 @@ use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::Path;
use std::path::PathBuf;
use tokio::fs;
use tokio::time::Duration;
use tokio::time::Instant;
use tokio::time::sleep;
#[derive(Debug)]
@@ -34,6 +36,24 @@ struct SnapshotRun {
codex_home: PathBuf,
}
async fn wait_for_snapshot(codex_home: &Path) -> Result<PathBuf> {
let snapshot_dir = codex_home.join("shell_snapshots");
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Ok(mut entries) = fs::read_dir(&snapshot_dir).await
&& let Some(entry) = entries.next_entry().await?
{
return Ok(entry.path());
}
if Instant::now() >= deadline {
anyhow::bail!("timed out waiting for shell snapshot");
}
sleep(Duration::from_millis(25)).await;
}
}
#[allow(clippy::expect_used)]
async fn run_snapshot_command(command: &str) -> Result<SnapshotRun> {
let builder = test_codex().with_config(|config| {
@@ -89,12 +109,7 @@ async fn run_snapshot_command(command: &str) -> Result<SnapshotRun> {
_ => None,
})
.await;
let mut entries = fs::read_dir(codex_home.join("shell_snapshots")).await?;
let snapshot_path = entries
.next_entry()
.await?
.map(|entry| entry.path())
.expect("shell snapshot created");
let snapshot_path = wait_for_snapshot(&codex_home).await?;
let snapshot_content = fs::read_to_string(&snapshot_path).await?;
let end = wait_for_event_match(&codex, |ev| match ev {
@@ -167,12 +182,7 @@ async fn run_shell_command_snapshot(command: &str) -> Result<SnapshotRun> {
_ => None,
})
.await;
let mut entries = fs::read_dir(codex_home.join("shell_snapshots")).await?;
let snapshot_path = entries
.next_entry()
.await?
.map(|entry| entry.path())
.expect("shell snapshot created");
let snapshot_path = wait_for_snapshot(&codex_home).await?;
let snapshot_content = fs::read_to_string(&snapshot_path).await?;
let end = wait_for_event_match(&codex, |ev| match ev {
@@ -305,12 +315,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> {
assert_eq!(fs::read_to_string(&target).await?, "hello from snapshot\n");
let mut entries = fs::read_dir(codex_home.join("shell_snapshots")).await?;
let snapshot_path = entries
.next_entry()
.await?
.map(|entry| entry.path())
.expect("shell snapshot created");
let snapshot_path = wait_for_snapshot(&codex_home).await?;
let snapshot_content = fs::read_to_string(&snapshot_path).await?;
assert_posix_snapshot_sections(&snapshot_content);
@@ -328,12 +333,7 @@ async fn shell_snapshot_deleted_after_shutdown_with_skills() -> Result<()> {
let codex_home = home.path().to_path_buf();
let codex = harness.test().codex.clone();
let mut entries = fs::read_dir(codex_home.join("shell_snapshots")).await?;
let snapshot_path = entries
.next_entry()
.await?
.map(|entry| entry.path())
.expect("shell snapshot created");
let snapshot_path = wait_for_snapshot(&codex_home).await?;
assert!(snapshot_path.exists());
codex.submit(Op::Shutdown {}).await?;
+18 -2
View File
@@ -47,14 +47,30 @@ impl TextElement {
}
}
/// Returns a copy of this element with a remapped byte range.
///
/// The placeholder is preserved as-is; callers must ensure the new range
/// still refers to the same logical element (and same placeholder)
/// within the new text.
pub fn map_range<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 is intended only for protocol conversions where the full text is not
/// available; prefer `placeholder(text)` for UI logic.
/// This must only be used inside `From<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()
+40 -1
View File
@@ -84,6 +84,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,
@@ -950,6 +961,24 @@ impl App {
.submit_op(Op::PatchApproval { id, decision });
}
}
Op::UserInputAnswer { id, response } => {
if let Some((thread_id, original_id)) =
self.external_approval_routes.remove(&id)
{
self.forward_external_op(
thread_id,
Op::UserInputAnswer {
id: original_id,
response,
},
)
.await;
self.finish_external_approval();
} else {
self.chat_widget
.submit_op(Op::UserInputAnswer { id, response });
}
}
// Standard path where this is not an external approval response.
_ => self.chat_widget.submit_op(op),
},
@@ -1454,6 +1483,14 @@ impl App {
/// can be routed back to the correct thread.
fn handle_external_approval_request(&mut self, thread_id: ThreadId, mut event: Event) {
match &mut event.msg {
EventMsg::RequestUserInput(ev) => {
let original_id = ev.turn_id.clone();
let routing_id = format!("{thread_id}:{original_id}");
self.external_approval_routes
.insert(routing_id.clone(), (thread_id, original_id));
ev.turn_id = routing_id.clone();
event.id = routing_id;
}
EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) => {
let original_id = event.id.clone();
let routing_id = format!("{thread_id}:{original_id}");
@@ -1506,7 +1543,9 @@ impl App {
}
};
match event.msg {
EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) => {
EventMsg::ExecApprovalRequest(_)
| EventMsg::ApplyPatchApprovalRequest(_)
| EventMsg::RequestUserInput(_) => {
app_event_tx.send(AppEvent::ExternalApprovalRequest { thread_id, event });
}
_ => {}
@@ -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)
}
}
+539 -43
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 {
@@ -609,6 +627,18 @@ impl ChatComposer {
.collect()
}
fn prune_attached_images_for_submission(&mut self, text: &str, text_elements: &[TextElement]) {
if self.attached_images.is_empty() {
return;
}
let image_placeholders: HashSet<&str> = text_elements
.iter()
.filter_map(|elem| elem.placeholder(text))
.collect();
self.attached_images
.retain(|img| image_placeholders.contains(img.placeholder.as_str()));
}
/// Insert an attachment placeholder and track it for the next submission.
pub fn attach_image(&mut self, path: PathBuf) {
let image_number = self.attached_images.len() + 1;
@@ -836,6 +866,7 @@ impl ChatComposer {
prompt,
first_line,
PromptSelectionMode::Completion,
&self.textarea.text_elements(),
) {
PromptSelectionAction::Insert { text, cursor } => {
let target = cursor.unwrap_or(text.len());
@@ -862,19 +893,31 @@ impl ChatComposer {
// If the current line starts with a custom prompt name and includes
// positional args for a numeric-style template, expand and submit
// immediately regardless of the popup selection.
let first_line = self.textarea.text().lines().next().unwrap_or("");
if let Some((name, _rest)) = parse_slash_name(first_line)
let mut text = self.textarea.text().to_string();
let mut text_elements = self.textarea.text_elements();
if !self.pending_pastes.is_empty() {
let (expanded, expanded_elements) =
Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes);
text = expanded;
text_elements = expanded_elements;
}
let first_line = text.lines().next().unwrap_or("");
if let Some((name, _rest, _rest_offset)) = parse_slash_name(first_line)
&& let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:"))
&& let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == prompt_name)
&& let Some(expanded) =
expand_if_numeric_with_positional_args(prompt, first_line)
expand_if_numeric_with_positional_args(prompt, first_line, &text_elements)
{
self.prune_attached_images_for_submission(
&expanded.text,
&expanded.text_elements,
);
self.pending_pastes.clear();
self.textarea.set_text_clearing_elements("");
return (
InputResult::Submitted {
text: expanded,
// Expanded prompt is plain text; no UI element ranges to preserve.
text_elements: Vec::new(),
text: expanded.text,
text_elements: expanded.text_elements,
},
true,
);
@@ -892,14 +935,21 @@ impl ChatComposer {
prompt,
first_line,
PromptSelectionMode::Submit,
&self.textarea.text_elements(),
) {
PromptSelectionAction::Submit { text } => {
PromptSelectionAction::Submit {
text,
text_elements,
} => {
self.prune_attached_images_for_submission(
&text,
&text_elements,
);
self.textarea.set_text_clearing_elements("");
return (
InputResult::Submitted {
text,
// Submitting a slash/custom prompt generates plain text, so there are no UI element ranges.
text_elements: Vec::new(),
text_elements,
},
true,
);
@@ -1558,11 +1608,10 @@ impl ChatComposer {
let expanded_input = text.clone();
// If there is neither text nor attachments, suppress submission entirely.
let has_attachments = !self.attached_images.is_empty();
text = text.trim().to_string();
text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements);
if let Some((name, _rest)) = parse_slash_name(&text) {
if let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) {
let treat_as_plain_text = input_starts_with_space || name.contains('/');
if !treat_as_plain_text {
let is_builtin =
@@ -1596,29 +1645,31 @@ impl ChatComposer {
}
}
let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) {
Ok(expanded) => expanded,
Err(err) => {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(err.user_message()),
)));
self.set_text_content(
original_input.clone(),
original_text_elements,
original_local_image_paths,
);
self.pending_pastes.clone_from(&original_pending_pastes);
self.textarea.set_cursor(original_input.len());
return None;
}
};
let expanded_prompt =
match expand_custom_prompt(&text, &text_elements, &self.custom_prompts) {
Ok(expanded) => expanded,
Err(err) => {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(err.user_message()),
)));
self.set_text_content(
original_input.clone(),
original_text_elements,
original_local_image_paths,
);
self.pending_pastes.clone_from(&original_pending_pastes);
self.textarea.set_cursor(original_input.len());
return None;
}
};
if let Some(expanded) = expanded_prompt {
text = expanded;
// Expanded prompt (e.g. custom prompt) is plain text; text elements not supported yet.
// TODO: Preserve UI element ranges through prompt expansion in a follow-up PR.
text_elements = Vec::new();
text = expanded.text;
text_elements = expanded.text_elements;
}
if text.is_empty() && !has_attachments {
// Custom prompt expansion can remove or rewrite image placeholders, so prune any
// attachments that no longer have a corresponding placeholder in the expanded text.
self.prune_attached_images_for_submission(&text, &text_elements);
if text.is_empty() && self.attached_images.is_empty() {
return None;
}
if !text.is_empty() {
@@ -1720,7 +1771,7 @@ impl ChatComposer {
/// Returns Some(InputResult) if a command was dispatched, None otherwise.
fn try_dispatch_bare_slash_command(&mut self) -> Option<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)
@@ -1741,7 +1792,7 @@ impl ChatComposer {
if !input_starts_with_space {
let text = self.textarea.text().to_string();
if let Some((name, rest)) = parse_slash_name(&text)
if let Some((name, rest, _rest_offset)) = parse_slash_name(&text)
&& !rest.is_empty()
&& !name.contains('/')
&& let Some((_n, cmd)) =
@@ -1816,7 +1867,7 @@ impl ChatComposer {
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
..
} => self.handle_submission(true),
} if self.is_task_running => self.handle_submission(true),
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
@@ -2495,6 +2546,7 @@ fn prompt_selection_action(
prompt: &CustomPrompt,
first_line: &str,
mode: PromptSelectionMode,
text_elements: &[TextElement],
) -> PromptSelectionAction {
let named_args = prompt_argument_names(&prompt.content);
let has_numeric = prompt_has_numeric_placeholders(&prompt.content);
@@ -2526,14 +2578,21 @@ fn prompt_selection_action(
};
}
if has_numeric {
if let Some(expanded) = expand_if_numeric_with_positional_args(prompt, first_line) {
return PromptSelectionAction::Submit { text: expanded };
if let Some(expanded) =
expand_if_numeric_with_positional_args(prompt, first_line, text_elements)
{
return PromptSelectionAction::Submit {
text: expanded.text,
text_elements: expanded.text_elements,
};
}
let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name);
return PromptSelectionAction::Insert { text, cursor: None };
}
PromptSelectionAction::Submit {
text: prompt.content.clone(),
// By now we know this custom prompt has no args, so no text elements to preserve.
text_elements: Vec::new(),
}
}
}
@@ -2554,6 +2613,7 @@ mod tests {
use crate::bottom_pane::InputResult;
use crate::bottom_pane::chat_composer::AttachedImage;
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
use crate::bottom_pane::prompt_args::PromptArg;
use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line;
use crate::bottom_pane::textarea::TextArea;
use tokio::sync::mpsc::unbounded_channel;
@@ -3801,15 +3861,37 @@ mod tests {
let args = extract_positional_args_for_prompt_line(
"/prompts:review \"docs/My File.md\"",
"review",
&[],
);
assert_eq!(
args,
vec![PromptArg {
text: "docs/My File.md".to_string(),
text_elements: Vec::new(),
}]
);
assert_eq!(args, vec!["docs/My File.md".to_string()]);
}
#[test]
fn extract_args_supports_mixed_quoted_and_unquoted() {
let args =
extract_positional_args_for_prompt_line("/prompts:cmd \"with spaces\" simple", "cmd");
assert_eq!(args, vec!["with spaces".to_string(), "simple".to_string()]);
let args = extract_positional_args_for_prompt_line(
"/prompts:cmd \"with spaces\" simple",
"cmd",
&[],
);
assert_eq!(
args,
vec![
PromptArg {
text: "with spaces".to_string(),
text_elements: Vec::new(),
},
PromptArg {
text: "simple".to_string(),
text_elements: Vec::new(),
}
]
);
}
#[test]
@@ -4868,6 +4950,166 @@ mod tests {
assert!(composer.textarea.is_empty());
}
#[test]
fn custom_prompt_submission_preserves_image_placeholder_unquoted() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<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]
@@ -4935,6 +5177,65 @@ mod tests {
assert!(composer.pending_pastes.is_empty());
}
#[test]
fn custom_prompt_with_large_paste_and_image_preserves_elements() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<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;
@@ -5153,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
+27
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) {
+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
+26 -14
View File
@@ -96,6 +96,7 @@ use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Settings;
use codex_protocol::models::local_image_label_text;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::user_input::TextElement;
use codex_protocol::user_input::UserInput;
use crossterm::event::KeyCode;
@@ -1209,6 +1210,14 @@ impl ChatWidget {
);
}
fn on_request_user_input(&mut self, ev: RequestUserInputEvent) {
let ev2 = ev.clone();
self.defer_or_handle(
|q| q.push_user_input(ev),
|s| s.handle_request_user_input_now(ev2),
);
}
fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) {
self.flush_answer_stream_with_separator();
if is_unified_exec_source(ev.source) {
@@ -1519,6 +1528,7 @@ impl ChatWidget {
#[inline]
fn handle_streaming_delta(&mut self, delta: String) {
// Before streaming agent content, flush any active exec cell group.
self.flush_unified_exec_wait_streak();
self.flush_active_cell();
if self.stream_controller.is_none() {
@@ -1676,6 +1686,12 @@ impl ChatWidget {
self.request_redraw();
}
pub(crate) fn handle_request_user_input_now(&mut self, ev: RequestUserInputEvent) {
self.flush_answer_stream_with_separator();
self.bottom_pane.push_user_input_request(ev);
self.request_redraw();
}
pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) {
// Ensure the status indicator is visible while the command runs.
self.running_commands.insert(
@@ -1807,9 +1823,7 @@ impl ChatWidget {
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager);
let model_for_header = model
.clone()
.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string());
let model_for_header = model.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string());
let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) {
collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| {
CollaborationMode::Custom(Settings {
@@ -1826,15 +1840,11 @@ impl ChatWidget {
})
};
let active_cell = if model.is_none() {
Some(Self::placeholder_session_header_cell(
&config,
config.features.enabled(Feature::CollaborationModes),
stored_collaboration_mode.clone(),
))
} else {
None
};
let active_cell = Some(Self::placeholder_session_header_cell(
&config,
config.features.enabled(Feature::CollaborationModes),
stored_collaboration_mode.clone(),
));
let mut widget = Self {
app_event_tx: app_event_tx.clone(),
@@ -2680,6 +2690,9 @@ impl ChatWidget {
EventMsg::ElicitationRequest(ev) => {
self.on_elicitation_request(ev);
}
EventMsg::RequestUserInput(ev) => {
self.on_request_user_input(ev);
}
EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev),
EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta),
EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta),
@@ -2740,8 +2753,7 @@ impl ChatWidget {
| EventMsg::ItemCompleted(_)
| EventMsg::AgentMessageContentDelta(_)
| EventMsg::ReasoningContentDelta(_)
| EventMsg::ReasoningRawContentDelta(_)
| EventMsg::RequestUserInput(_) => {}
| EventMsg::ReasoningRawContentDelta(_) => {}
}
}
@@ -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),
@@ -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.
@@ -0,0 +1,8 @@
---
source: tui/src/chatwidget/tests.rs
expression: combined
---
↳ Interacted with background terminal · cargo test -p codex-core
└ (waited)
• Final response.
@@ -0,0 +1,8 @@
---
source: tui/src/chatwidget/tests.rs
expression: combined
---
↳ Interacted with background terminal · cargo test -p codex-core
└ (waited)
• Streaming response.
+73
View File
@@ -1754,6 +1754,79 @@ async fn unified_exec_interaction_after_task_complete_is_suppressed() {
);
}
#[tokio::test]
async fn unified_exec_wait_after_final_agent_message_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
model_context_window: None,
}),
});
begin_unified_exec_startup(&mut chat, "call-wait", "proc-1", "cargo test -p codex-core");
terminal_interaction(&mut chat, "call-wait-stdin", "proc-1", "");
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "Final response.".into(),
}),
});
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
last_agent_message: Some("Final response.".into()),
}),
});
let cells = drain_insert_history(&mut rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_snapshot!("unified_exec_wait_after_final_agent_message", combined);
}
#[tokio::test]
async fn unified_exec_wait_before_streamed_agent_message_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
model_context_window: None,
}),
});
begin_unified_exec_startup(
&mut chat,
"call-wait-stream",
"proc-1",
"cargo test -p codex-core",
);
terminal_interaction(&mut chat, "call-wait-stream-stdin", "proc-1", "");
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "Streaming response.".into(),
}),
});
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
last_agent_message: None,
}),
});
let cells = drain_insert_history(&mut rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_snapshot!("unified_exec_wait_before_streamed_agent_message", combined);
}
#[tokio::test]
async fn unified_exec_wait_status_header_updates_on_late_command_display() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
+162 -37
View File
@@ -7,123 +7,122 @@ use codex_core::protocol::CollabAgentSpawnEndEvent;
use codex_core::protocol::CollabCloseEndEvent;
use codex_core::protocol::CollabWaitingBeginEvent;
use codex_core::protocol::CollabWaitingEndEvent;
use codex_protocol::ThreadId;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use std::collections::HashMap;
const COLLAB_PROMPT_PREVIEW_GRAPHEMES: usize = 160;
const COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES: usize = 160;
const COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES: usize = 240;
pub(crate) fn spawn_end(ev: CollabAgentSpawnEndEvent) -> PlainHistoryCell {
let CollabAgentSpawnEndEvent {
call_id,
sender_thread_id,
sender_thread_id: _,
new_thread_id,
prompt,
status,
} = ev;
let new_agent = new_thread_id
.map(|id| id.to_string())
.unwrap_or_else(|| "none".to_string());
.map(|id| Span::from(id.to_string()))
.unwrap_or_else(|| Span::from("not created").dim());
let mut details = vec![
detail_line("call", call_id),
detail_line("sender", sender_thread_id),
detail_line("new_agent", new_agent),
detail_line("agent", new_agent),
status_line(&status),
];
if let Some(line) = prompt_line(&prompt) {
details.push(line);
}
collab_event("Collab spawn", details)
collab_event("Agent spawned", details)
}
pub(crate) fn interaction_end(ev: CollabAgentInteractionEndEvent) -> PlainHistoryCell {
let CollabAgentInteractionEndEvent {
call_id,
sender_thread_id,
sender_thread_id: _,
receiver_thread_id,
prompt,
status,
} = ev;
let mut details = vec![
detail_line("call", call_id),
detail_line("sender", sender_thread_id),
detail_line("receiver", receiver_thread_id),
detail_line("receiver", receiver_thread_id.to_string()),
status_line(&status),
];
if let Some(line) = prompt_line(&prompt) {
details.push(line);
}
collab_event("Collab send input", details)
collab_event("Input sent", details)
}
pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell {
let CollabWaitingBeginEvent {
call_id,
sender_thread_id,
sender_thread_id: _,
receiver_thread_ids,
} = ev;
let details = vec![
detail_line("call", call_id),
detail_line("sender", sender_thread_id),
detail_line("receiver", format!("{receiver_thread_ids:?}")),
detail_line("receivers", format_thread_ids(&receiver_thread_ids)),
];
collab_event("Collab wait begin", details)
collab_event("Waiting for agents", details)
}
pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell {
let CollabWaitingEndEvent {
call_id,
sender_thread_id,
sender_thread_id: _,
statuses,
} = ev;
let details = vec![
detail_line("call", call_id),
detail_line("sender", sender_thread_id),
detail_line("statuses", format!("{statuses:#?}")),
];
collab_event("Collab wait end", details)
let mut details = vec![detail_line("call", call_id)];
details.extend(wait_complete_lines(&statuses));
collab_event("Wait complete", details)
}
pub(crate) fn close_end(ev: CollabCloseEndEvent) -> PlainHistoryCell {
let CollabCloseEndEvent {
call_id,
sender_thread_id,
sender_thread_id: _,
receiver_thread_id,
status,
} = ev;
let details = vec![
detail_line("call", call_id),
detail_line("sender", sender_thread_id),
detail_line("receiver", receiver_thread_id),
detail_line("receiver", receiver_thread_id.to_string()),
status_line(&status),
];
collab_event("Collab close", details)
collab_event("Agent closed", details)
}
fn collab_event(title: impl Into<String>, details: Vec<Line<'static>>) -> PlainHistoryCell {
let title = title.into();
let mut lines: Vec<Line<'static>> = vec![vec!["• ".dim(), title.bold()].into()];
let mut lines: Vec<Line<'static>> =
vec![vec![Span::from("• ").dim(), Span::from(title).bold()].into()];
if !details.is_empty() {
lines.extend(prefix_lines(details, " └ ".dim(), " ".into()));
}
PlainHistoryCell::new(lines)
}
fn detail_line(label: &str, value: impl std::fmt::Display) -> Line<'static> {
Line::from(format!("{label}: {value}").dim())
fn detail_line(label: &str, value: impl Into<Span<'static>>) -> Line<'static> {
vec![Span::from(format!("{label}: ")).dim(), value.into()].into()
}
fn status_line(status: &AgentStatus) -> Line<'static> {
Line::from(format!("status: {}", status_text(status)).dim())
detail_line("status", status_span(status))
}
fn status_text(status: &AgentStatus) -> &'static str {
fn status_span(status: &AgentStatus) -> Span<'static> {
match status {
AgentStatus::PendingInit => "pending_init",
AgentStatus::Running => "running",
AgentStatus::Completed(_) => "completed",
AgentStatus::Errored(_) => "errored",
AgentStatus::Shutdown => "shutdown",
AgentStatus::NotFound => "not_found",
AgentStatus::PendingInit => Span::from("pending init").dim(),
AgentStatus::Running => Span::from("running").cyan().bold(),
AgentStatus::Completed(_) => Span::from("completed").green(),
AgentStatus::Errored(_) => Span::from("errored").red(),
AgentStatus::Shutdown => Span::from("shutdown").dim(),
AgentStatus::NotFound => Span::from("not found").red(),
}
}
@@ -134,7 +133,133 @@ fn prompt_line(prompt: &str) -> Option<Line<'static>> {
} else {
Some(detail_line(
"prompt",
truncate_text(trimmed, COLLAB_PROMPT_PREVIEW_GRAPHEMES),
Span::from(truncate_text(trimmed, COLLAB_PROMPT_PREVIEW_GRAPHEMES)).dim(),
))
}
}
fn format_thread_ids(ids: &[ThreadId]) -> Span<'static> {
if ids.is_empty() {
return Span::from("none").dim();
}
let joined = ids
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
Span::from(joined)
}
fn wait_complete_lines(statuses: &HashMap<ThreadId, AgentStatus>) -> Vec<Line<'static>> {
if statuses.is_empty() {
return vec![detail_line("agents", Span::from("none").dim())];
}
let mut pending_init = 0usize;
let mut running = 0usize;
let mut completed = 0usize;
let mut errored = 0usize;
let mut shutdown = 0usize;
let mut not_found = 0usize;
for status in statuses.values() {
match status {
AgentStatus::PendingInit => pending_init += 1,
AgentStatus::Running => running += 1,
AgentStatus::Completed(_) => completed += 1,
AgentStatus::Errored(_) => errored += 1,
AgentStatus::Shutdown => shutdown += 1,
AgentStatus::NotFound => not_found += 1,
}
}
let mut summary = vec![Span::from(format!("{} total", statuses.len())).dim()];
push_status_count(
&mut summary,
pending_init,
"pending init",
ratatui::prelude::Stylize::dim,
);
push_status_count(&mut summary, running, "running", |span| span.cyan().bold());
push_status_count(
&mut summary,
completed,
"completed",
ratatui::prelude::Stylize::green,
);
push_status_count(
&mut summary,
errored,
"errored",
ratatui::prelude::Stylize::red,
);
push_status_count(
&mut summary,
shutdown,
"shutdown",
ratatui::prelude::Stylize::dim,
);
push_status_count(
&mut summary,
not_found,
"not found",
ratatui::prelude::Stylize::red,
);
let mut entries: Vec<(String, &AgentStatus)> = statuses
.iter()
.map(|(thread_id, status)| (thread_id.to_string(), status))
.collect();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
let mut lines = Vec::with_capacity(entries.len() + 1);
lines.push(detail_line_spans("agents", summary));
lines.extend(entries.into_iter().map(|(thread_id, status)| {
let mut spans = vec![
Span::from(thread_id).dim(),
Span::from(" ").dim(),
status_span(status),
];
match status {
AgentStatus::Completed(Some(message)) => {
let message_preview = truncate_text(
&message.split_whitespace().collect::<Vec<_>>().join(" "),
COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES,
);
spans.push(Span::from(": ").dim());
spans.push(Span::from(message_preview));
}
AgentStatus::Errored(error) => {
let error_preview = truncate_text(
&error.split_whitespace().collect::<Vec<_>>().join(" "),
COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES,
);
spans.push(Span::from(": ").dim());
spans.push(Span::from(error_preview).dim());
}
_ => {}
}
spans.into()
}));
lines
}
fn push_status_count(
spans: &mut Vec<Span<'static>>,
count: usize,
label: &'static str,
style: impl FnOnce(Span<'static>) -> Span<'static>,
) {
if count == 0 {
return;
}
spans.push(Span::from(" · ").dim());
spans.push(style(Span::from(format!("{count} {label}"))));
}
fn detail_line_spans(label: &str, mut value: Vec<Span<'static>>) -> Line<'static> {
let mut spans = Vec::with_capacity(value.len() + 1);
spans.push(Span::from(format!("{label}: ")).dim());
spans.append(&mut value);
spans.into()
}
+3 -1
View File
@@ -1123,7 +1123,9 @@ impl HistoryCell for SessionHeaderHistoryCell {
label_width = label_width
);
let mut spans = vec![Span::from(format!("{collab_label} ")).dim()];
if let Some(mode_label) = self.collaboration_mode_label() {
if self.model == "loading" {
spans.push(Span::styled(self.model.clone(), self.model_style));
} else if let Some(mode_label) = self.collaboration_mode_label() {
spans.push(Span::styled(mode_label.to_string(), self.model_style));
} else {
spans.push(Span::styled("Custom", self.model_style));
+42
View File
@@ -47,6 +47,7 @@ use codex_ansi_escape::ansi_escape_line;
use codex_core::AuthManager;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::config::edit::ConfigEdit;
use codex_core::config::edit::ConfigEditsBuilder;
#[cfg(target_os = "windows")]
use codex_core::features::Feature;
@@ -1991,6 +1992,39 @@ impl App {
}
}
}
AppEvent::UpdateFeatureFlags { updates } => {
if updates.is_empty() {
return Ok(AppRunControl::Continue);
}
let mut builder = ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(self.active_profile.as_deref());
for (feature, enabled) in &updates {
let key = feature.key();
if *enabled {
self.config.features.enable(*feature);
self.chat_widget.set_feature_enabled(*feature, true);
builder = builder.set_feature_enabled(key, true);
} else {
self.config.features.disable(*feature);
self.chat_widget.set_feature_enabled(*feature, false);
if feature.default_enabled() {
builder = builder.set_feature_enabled(key, false);
} else {
builder = builder.with_edits([ConfigEdit::ClearPath {
segments: vec!["features".to_string(), key.to_string()],
}]);
}
}
}
if let Err(err) = builder.apply().await {
tracing::error!(error = %err, "failed to persist feature flags");
self.chat_widget.add_error_message(format!(
"Failed to update experimental features: {err}"
));
}
}
AppEvent::SkipNextWorldWritableScan => {
self.skip_world_writable_scan_once = true;
}
@@ -2685,6 +2719,14 @@ mod tests {
app.chat_widget.current_model(),
event,
is_first,
false,
codex_protocol::config_types::CollaborationMode::Custom(
codex_protocol::config_types::Settings {
model: "gpt-test".to_string(),
reasoning_effort: None,
developer_instructions: None,
},
),
)) as Arc<dyn HistoryCell>
};
+6
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;
@@ -168,6 +169,11 @@ pub(crate) enum AppEvent {
/// Update the current sandbox policy in the running app and widget.
UpdateSandboxPolicy(SandboxPolicy),
/// Update feature flags and persist them to config.
UpdateFeatureFlags {
updates: Vec<(Feature, bool)>,
},
/// Update whether the full access warning prompt has been acknowledged.
UpdateFullAccessWarningAcknowledged(bool),
+539 -43
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 {
@@ -541,6 +559,18 @@ impl ChatComposer {
.collect()
}
fn prune_attached_images_for_submission(&mut self, text: &str, text_elements: &[TextElement]) {
if self.attached_images.is_empty() {
return;
}
let image_placeholders: HashSet<&str> = text_elements
.iter()
.filter_map(|elem| elem.placeholder(text))
.collect();
self.attached_images
.retain(|img| image_placeholders.contains(img.placeholder.as_str()));
}
/// Insert an attachment placeholder and track it for the next submission.
pub fn attach_image(&mut self, path: PathBuf) {
let image_number = self.attached_images.len() + 1;
@@ -769,6 +799,7 @@ impl ChatComposer {
prompt,
first_line,
PromptSelectionMode::Completion,
&self.textarea.text_elements(),
) {
PromptSelectionAction::Insert { text, cursor } => {
let target = cursor.unwrap_or(text.len());
@@ -795,19 +826,31 @@ impl ChatComposer {
// If the current line starts with a custom prompt name and includes
// positional args for a numeric-style template, expand and submit
// immediately regardless of the popup selection.
let first_line = self.textarea.text().lines().next().unwrap_or("");
if let Some((name, _rest)) = parse_slash_name(first_line)
let mut text = self.textarea.text().to_string();
let mut text_elements = self.textarea.text_elements();
if !self.pending_pastes.is_empty() {
let (expanded, expanded_elements) =
Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes);
text = expanded;
text_elements = expanded_elements;
}
let first_line = text.lines().next().unwrap_or("");
if let Some((name, _rest, _rest_offset)) = parse_slash_name(first_line)
&& let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:"))
&& let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == prompt_name)
&& let Some(expanded) =
expand_if_numeric_with_positional_args(prompt, first_line)
expand_if_numeric_with_positional_args(prompt, first_line, &text_elements)
{
self.prune_attached_images_for_submission(
&expanded.text,
&expanded.text_elements,
);
self.pending_pastes.clear();
self.textarea.set_text_clearing_elements("");
return (
InputResult::Submitted {
text: expanded,
// Expanded prompt is plain text; no UI element ranges to preserve.
text_elements: Vec::new(),
text: expanded.text,
text_elements: expanded.text_elements,
},
true,
);
@@ -825,14 +868,21 @@ impl ChatComposer {
prompt,
first_line,
PromptSelectionMode::Submit,
&self.textarea.text_elements(),
) {
PromptSelectionAction::Submit { text } => {
PromptSelectionAction::Submit {
text,
text_elements,
} => {
self.prune_attached_images_for_submission(
&text,
&text_elements,
);
self.textarea.set_text_clearing_elements("");
return (
InputResult::Submitted {
text,
// Submitting a slash/custom prompt generates plain text, so there are no UI element ranges.
text_elements: Vec::new(),
text_elements,
},
true,
);
@@ -1487,11 +1537,10 @@ impl ChatComposer {
let expanded_input = text.clone();
// If there is neither text nor attachments, suppress submission entirely.
let has_attachments = !self.attached_images.is_empty();
text = text.trim().to_string();
text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements);
if let Some((name, _rest)) = parse_slash_name(&text) {
if let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) {
let treat_as_plain_text = input_starts_with_space || name.contains('/');
if !treat_as_plain_text {
let is_builtin =
@@ -1525,29 +1574,31 @@ impl ChatComposer {
}
}
let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) {
Ok(expanded) => expanded,
Err(err) => {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(err.user_message()),
)));
self.set_text_content(
original_input.clone(),
original_text_elements,
original_local_image_paths,
);
self.pending_pastes.clone_from(&original_pending_pastes);
self.textarea.set_cursor(original_input.len());
return None;
}
};
let expanded_prompt =
match expand_custom_prompt(&text, &text_elements, &self.custom_prompts) {
Ok(expanded) => expanded,
Err(err) => {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(err.user_message()),
)));
self.set_text_content(
original_input.clone(),
original_text_elements,
original_local_image_paths,
);
self.pending_pastes.clone_from(&original_pending_pastes);
self.textarea.set_cursor(original_input.len());
return None;
}
};
if let Some(expanded) = expanded_prompt {
text = expanded;
// Expanded prompt (e.g. custom prompt) is plain text; text elements not supported yet.
// TODO: Preserve UI element ranges through prompt expansion in a follow-up PR.
text_elements = Vec::new();
text = expanded.text;
text_elements = expanded.text_elements;
}
if text.is_empty() && !has_attachments {
// Custom prompt expansion can remove or rewrite image placeholders, so prune any
// attachments that no longer have a corresponding placeholder in the expanded text.
self.prune_attached_images_for_submission(&text, &text_elements);
if text.is_empty() && self.attached_images.is_empty() {
return None;
}
if !text.is_empty() {
@@ -1650,7 +1701,7 @@ impl ChatComposer {
/// Returns Some(InputResult) if a command was dispatched, None otherwise.
fn try_dispatch_bare_slash_command(&mut self) -> Option<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)
@@ -1671,7 +1722,7 @@ impl ChatComposer {
if !input_starts_with_space {
let text = self.textarea.text().to_string();
if let Some((name, rest)) = parse_slash_name(&text)
if let Some((name, rest, _rest_offset)) = parse_slash_name(&text)
&& !rest.is_empty()
&& !name.contains('/')
&& let Some((_n, cmd)) =
@@ -1746,7 +1797,7 @@ impl ChatComposer {
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
..
} => self.handle_submission(true),
} if self.is_task_running => self.handle_submission(true),
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
@@ -2463,6 +2514,7 @@ fn prompt_selection_action(
prompt: &CustomPrompt,
first_line: &str,
mode: PromptSelectionMode,
text_elements: &[TextElement],
) -> PromptSelectionAction {
let named_args = prompt_argument_names(&prompt.content);
let has_numeric = prompt_has_numeric_placeholders(&prompt.content);
@@ -2494,14 +2546,21 @@ fn prompt_selection_action(
};
}
if has_numeric {
if let Some(expanded) = expand_if_numeric_with_positional_args(prompt, first_line) {
return PromptSelectionAction::Submit { text: expanded };
if let Some(expanded) =
expand_if_numeric_with_positional_args(prompt, first_line, text_elements)
{
return PromptSelectionAction::Submit {
text: expanded.text,
text_elements: expanded.text_elements,
};
}
let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name);
return PromptSelectionAction::Insert { text, cursor: None };
}
PromptSelectionAction::Submit {
text: prompt.content.clone(),
// By now we know this custom prompt has no args, so no text elements to preserve.
text_elements: Vec::new(),
}
}
}
@@ -2522,6 +2581,7 @@ mod tests {
use crate::bottom_pane::InputResult;
use crate::bottom_pane::chat_composer::AttachedImage;
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
use crate::bottom_pane::prompt_args::PromptArg;
use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line;
use crate::bottom_pane::textarea::TextArea;
use tokio::sync::mpsc::unbounded_channel;
@@ -3792,15 +3852,37 @@ mod tests {
let args = extract_positional_args_for_prompt_line(
"/prompts:review \"docs/My File.md\"",
"review",
&[],
);
assert_eq!(
args,
vec![PromptArg {
text: "docs/My File.md".to_string(),
text_elements: Vec::new(),
}]
);
assert_eq!(args, vec!["docs/My File.md".to_string()]);
}
#[test]
fn extract_args_supports_mixed_quoted_and_unquoted() {
let args =
extract_positional_args_for_prompt_line("/prompts:cmd \"with spaces\" simple", "cmd");
assert_eq!(args, vec!["with spaces".to_string(), "simple".to_string()]);
let args = extract_positional_args_for_prompt_line(
"/prompts:cmd \"with spaces\" simple",
"cmd",
&[],
);
assert_eq!(
args,
vec![
PromptArg {
text: "with spaces".to_string(),
text_elements: Vec::new(),
},
PromptArg {
text: "simple".to_string(),
text_elements: Vec::new(),
}
]
);
}
#[test]
@@ -4831,6 +4913,166 @@ mod tests {
assert!(composer.textarea.is_empty());
}
#[test]
fn custom_prompt_submission_preserves_image_placeholder_unquoted() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<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]
@@ -4898,6 +5140,65 @@ mod tests {
assert!(composer.pending_pastes.is_empty());
}
#[test]
fn custom_prompt_with_large_paste_and_image_preserves_elements() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<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;
@@ -5116,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]
@@ -0,0 +1,293 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Block;
use ratatui::widgets::Widget;
use codex_core::features::Feature;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::style::user_message_style;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::measure_rows_height;
use super::selection_popup_common::render_rows;
pub(crate) struct BetaFeatureItem {
pub(crate) feature: Feature,
pub(crate) name: String,
pub(crate) description: String,
pub(crate) enabled: bool,
}
pub(crate) struct ExperimentalFeaturesView {
features: Vec<BetaFeatureItem>,
state: ScrollState,
complete: bool,
app_event_tx: AppEventSender,
header: Box<dyn Renderable>,
footer_hint: Line<'static>,
}
impl ExperimentalFeaturesView {
pub(crate) fn new(features: Vec<BetaFeatureItem>, app_event_tx: AppEventSender) -> Self {
let mut header = ColumnRenderable::new();
header.push(Line::from("Experimental features".bold()));
header.push(Line::from(
"Toggle beta features. Changes are saved to config.toml.".dim(),
));
let mut view = Self {
features,
state: ScrollState::new(),
complete: false,
app_event_tx,
header: Box::new(header),
footer_hint: experimental_popup_hint_line(),
};
view.initialize_selection();
view
}
fn initialize_selection(&mut self) {
if self.visible_len() == 0 {
self.state.selected_idx = None;
} else if self.state.selected_idx.is_none() {
self.state.selected_idx = Some(0);
}
}
fn visible_len(&self) -> usize {
self.features.len()
}
fn build_rows(&self) -> Vec<GenericDisplayRow> {
let mut rows = Vec::with_capacity(self.features.len());
let selected_idx = self.state.selected_idx;
for (idx, item) in self.features.iter().enumerate() {
let prefix = if selected_idx == Some(idx) {
'›'
} else {
' '
};
let marker = if item.enabled { 'x' } else { ' ' };
let name = format!("{prefix} [{marker}] {}", item.name);
rows.push(GenericDisplayRow {
name,
display_shortcut: None,
match_indices: None,
description: Some(item.description.clone()),
wrap_indent: None,
});
}
rows
}
fn move_up(&mut self) {
let len = self.visible_len();
if len == 0 {
return;
}
self.state.move_up_wrap(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
fn move_down(&mut self) {
let len = self.visible_len();
if len == 0 {
return;
}
self.state.move_down_wrap(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
fn toggle_selected(&mut self) {
let Some(selected_idx) = self.state.selected_idx else {
return;
};
if let Some(item) = self.features.get_mut(selected_idx) {
item.enabled = !item.enabled;
}
}
fn rows_width(total_width: u16) -> u16 {
total_width.saturating_sub(2)
}
}
impl BottomPaneView for ExperimentalFeaturesView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{0010}'),
modifiers: KeyModifiers::NONE,
..
} => self.move_up(),
KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::NONE,
..
} => self.move_up(),
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{000e}'),
modifiers: KeyModifiers::NONE,
..
} => self.move_down(),
KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
..
} => self.move_down(),
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => self.toggle_selected(),
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.on_ctrl_c();
}
_ => {}
}
}
fn is_complete(&self) -> bool {
self.complete
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
if !self.features.is_empty() {
let updates = self
.features
.iter()
.map(|item| (item.feature, item.enabled))
.collect();
self.app_event_tx
.send(AppEvent::UpdateFeatureFlags { updates });
}
self.complete = true;
CancellationEvent::Handled
}
}
impl Renderable for ExperimentalFeaturesView {
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let [content_area, footer_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
Block::default()
.style(user_message_style())
.render(content_area, buf);
let header_height = self
.header
.desired_height(content_area.width.saturating_sub(4));
let rows = self.build_rows();
let rows_width = Self::rows_width(content_area.width);
let rows_height = measure_rows_height(
&rows,
&self.state,
MAX_POPUP_ROWS,
rows_width.saturating_add(1),
);
let [header_area, _, list_area] = Layout::vertical([
Constraint::Max(header_height),
Constraint::Max(1),
Constraint::Length(rows_height),
])
.areas(content_area.inset(Insets::vh(1, 2)));
self.header.render(header_area, buf);
if list_area.height > 0 {
let render_area = Rect {
x: list_area.x.saturating_sub(2),
y: list_area.y,
width: rows_width.max(1),
height: list_area.height,
};
render_rows(
render_area,
buf,
&rows,
&self.state,
MAX_POPUP_ROWS,
" No experimental features available for now",
);
}
let hint_area = Rect {
x: footer_area.x + 2,
y: footer_area.y,
width: footer_area.width.saturating_sub(2),
height: footer_area.height,
};
self.footer_hint.clone().dim().render(hint_area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
let rows = self.build_rows();
let rows_width = Self::rows_width(width);
let rows_height = measure_rows_height(
&rows,
&self.state,
MAX_POPUP_ROWS,
rows_width.saturating_add(1),
);
let mut height = self.header.desired_height(width.saturating_sub(4));
height = height.saturating_add(rows_height + 3);
height.saturating_add(1)
}
}
fn experimental_popup_hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to toggle or ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to save for next conversation".into(),
])
}
+3
View File
@@ -49,6 +49,7 @@ mod chat_composer;
mod chat_composer_history;
mod command_popup;
pub mod custom_prompt_view;
mod experimental_features_view;
mod file_search_popup;
mod footer;
mod list_selection_view;
@@ -99,6 +100,8 @@ pub(crate) use chat_composer::InputResult;
use codex_protocol::custom_prompts::CustomPrompt;
use crate::status_indicator_widget::StatusIndicatorWidget;
pub(crate) use experimental_features_view::BetaFeatureItem;
pub(crate) use experimental_features_view::ExperimentalFeaturesView;
pub(crate) use list_selection_view::SelectionAction;
pub(crate) use list_selection_view::SelectionItem;
+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.
@@ -111,20 +134,39 @@ pub fn prompt_argument_names(content: &str) -> Vec<String> {
/// 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 +178,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 +196,24 @@ pub fn expand_custom_prompt(
};
// If there are named placeholders, expect key=value inputs.
let required = prompt_argument_names(&prompt.content);
let local_elements: Vec<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 +225,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 +261,42 @@ pub fn prompt_has_numeric_placeholders(content: &str) -> bool {
/// Extract positional arguments from a composer first line like "/name a b" for a given prompt name.
/// Returns empty when the command name does not match or when there are no args.
pub fn extract_positional_args_for_prompt_line(line: &str, prompt_name: &str) -> Vec<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 +304,15 @@ pub fn extract_positional_args_for_prompt_line(line: &str, prompt_name: &str) ->
pub fn expand_if_numeric_with_positional_args(
prompt: &CustomPrompt,
first_line: &str,
) -> Option<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 +320,10 @@ pub fn expand_if_numeric_with_positional_args(
}
/// Expand `$1..$9` and `$ARGUMENTS` in `content` with values from `args`.
pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String {
pub fn expand_numeric_placeholders(content: &str, args: &[PromptArg]) -> PromptExpansion {
let mut out = String::with_capacity(content.len());
let mut out_elements = Vec::new();
let mut i = 0;
let mut cached_joined_args: Option<String> = None;
while let Some(off) = content[i..].find('$') {
let j = i + off;
out.push_str(&content[i..j]);
@@ -272,8 +338,8 @@ pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String {
}
b'1'..=b'9' => {
let idx = (bytes[1] - b'1') as usize;
if let Some(val) = args.get(idx) {
out.push_str(val);
if let Some(arg) = args.get(idx) {
append_arg_with_elements(&mut out, &mut out_elements, arg);
}
i = j + 2;
continue;
@@ -283,8 +349,7 @@ pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String {
}
if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") {
if !args.is_empty() {
let joined = cached_joined_args.get_or_insert_with(|| args.join(" "));
out.push_str(joined);
append_joined_args_with_elements(&mut out, &mut out_elements, args);
}
i = j + 1 + "ARGUMENTS".len();
continue;
@@ -293,7 +358,191 @@ pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String {
i = j + 1;
}
out.push_str(&content[i..]);
out
PromptExpansion {
text: out,
text_elements: out_elements,
}
}
fn parse_tokens_with_elements(rest: &str, text_elements: &[TextElement]) -> Vec<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
}
/// 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 }))
}
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(),
})
);
}
}
+41 -11
View File
@@ -33,6 +33,7 @@ use codex_backend_client::Client as BackendClient;
use codex_core::config::Config;
use codex_core::config::ConstraintResult;
use codex_core::config::types::Notifications;
use codex_core::features::FEATURES;
use codex_core::features::Feature;
use codex_core::git_info::current_branch_name;
use codex_core::git_info::local_git_branches;
@@ -121,10 +122,12 @@ use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event::WindowsSandboxFallbackReason;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::BetaFeatureItem;
use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED;
use crate::bottom_pane::ExperimentalFeaturesView;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::LocalImageAttachment;
use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT;
@@ -159,6 +162,7 @@ use crate::slash_command::SlashCommand;
use crate::status::RateLimitSnapshotDisplay;
use crate::text_formatting::truncate_text;
use crate::tui::FrameRequester;
use crate::ui_consts::DEFAULT_MODEL_DISPLAY_NAME;
mod interrupts;
use self::interrupts::InterruptManager;
mod agent;
@@ -210,7 +214,6 @@ impl UnifiedExecWaitState {
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0];
const NUDGE_MODEL_SLUG: &str = "gpt-5.1-codex-mini";
const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0;
const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading";
#[derive(Default)]
struct RateLimitWarningState {
@@ -647,6 +650,8 @@ impl ChatWidget {
&model_for_header,
event,
self.show_welcome_banner,
self.collaboration_modes_enabled(),
self.stored_collaboration_mode.clone(),
);
self.apply_session_info_cell(session_info_cell);
@@ -1607,15 +1612,7 @@ impl ChatWidget {
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager);
let model_for_header = model
.clone()
.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string());
let active_cell = if model.is_none() {
Some(Self::placeholder_session_header_cell(&config))
} else {
None
};
let model_for_header = model.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string());
let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) {
collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| {
CollaborationMode::Custom(Settings {
@@ -1631,6 +1628,11 @@ impl ChatWidget {
developer_instructions: None,
})
};
let active_cell = Some(Self::placeholder_session_header_cell(
&config,
config.features.enabled(Feature::CollaborationModes),
stored_collaboration_mode.clone(),
));
let mut widget = Self {
app_event_tx: app_event_tx.clone(),
@@ -2059,6 +2061,9 @@ impl ChatWidget {
// Not supported; on non-Windows this command should never be reachable.
};
}
SlashCommand::Experimental => {
self.open_experimental_popup();
}
SlashCommand::Quit | SlashCommand::Exit => {
self.request_quit_without_confirmation();
}
@@ -3338,6 +3343,25 @@ impl ChatWidget {
});
}
pub(crate) fn open_experimental_popup(&mut self) {
let features: Vec<BetaFeatureItem> = FEATURES
.iter()
.filter_map(|spec| {
let name = spec.stage.beta_menu_name()?;
let description = spec.stage.beta_menu_description()?;
Some(BetaFeatureItem {
feature: spec.id,
name: name.to_string(),
description: description.to_string(),
enabled: self.config.features.enabled(spec.id),
})
})
.collect();
let view = ExperimentalFeaturesView::new(features, self.app_event_tx.clone());
self.bottom_pane.show_view(Box::new(view));
}
fn approval_preset_actions(
approval: AskForApproval,
sandbox: SandboxPolicy,
@@ -4026,7 +4050,11 @@ impl ChatWidget {
}
/// Build a placeholder header cell while the session is configuring.
fn placeholder_session_header_cell(config: &Config) -> Box<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(),
@@ -4034,6 +4062,8 @@ impl ChatWidget {
None,
config.cwd.clone(),
CODEX_CLI_VERSION,
is_collaboration,
collaboration_mode,
))
}
@@ -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.
+143 -18
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::markdown::append_markdown;
use crate::render::line_utils::line_to_static;
use crate::render::line_utils::prefix_lines;
@@ -27,6 +28,7 @@ use crate::style::user_message_style;
use crate::text_formatting::format_and_truncate_tool_result;
use crate::text_formatting::truncate_text;
use crate::tooltips;
use crate::ui_consts::DEFAULT_MODEL_DISPLAY_NAME;
use crate::ui_consts::LIVE_PREFIX_COLS;
use crate::update_action::UpdateAction;
use crate::version::CODEX_CLI_VERSION;
@@ -40,11 +42,13 @@ use codex_core::protocol::FileChange;
use codex_core::protocol::McpAuthStatus;
use codex_core::protocol::McpInvocation;
use codex_core::protocol::SessionConfiguredEvent;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::user_input::TextElement;
use crossterm::event::KeyCode;
use image::DynamicImage;
use image::ImageReader;
use mcp_types::EmbeddedResourceResource;
@@ -971,6 +975,8 @@ pub(crate) fn new_session_info(
requested_model: &str,
event: SessionConfiguredEvent,
is_first_event: bool,
is_collaboration: bool,
collaboration_mode: CollaborationMode,
) -> SessionInfoCell {
let SessionConfiguredEvent {
model,
@@ -984,6 +990,8 @@ pub(crate) fn new_session_info(
reasoning_effort,
config.cwd.clone(),
CODEX_CLI_VERSION,
is_collaboration,
collaboration_mode,
);
let mut parts: Vec<Box<dyn HistoryCell>> = vec![Box::new(header)];
@@ -1060,6 +1068,8 @@ pub(crate) struct SessionHeaderHistoryCell {
model_style: Style,
reasoning_effort: Option<ReasoningEffortConfig>,
directory: PathBuf,
is_collaboration: bool,
collaboration_mode: CollaborationMode,
}
impl SessionHeaderHistoryCell {
@@ -1069,8 +1079,18 @@ impl SessionHeaderHistoryCell {
reasoning_effort: Option<ReasoningEffortConfig>,
directory: PathBuf,
version: &'static str,
is_collaboration: bool,
collaboration_mode: CollaborationMode,
) -> Self {
Self::new_with_style(model, model_style, reasoning_effort, directory, version)
Self::new_with_style(
model,
model_style,
reasoning_effort,
directory,
version,
is_collaboration,
collaboration_mode,
)
}
pub(crate) fn new_with_style(
@@ -1079,6 +1099,8 @@ impl SessionHeaderHistoryCell {
reasoning_effort: Option<ReasoningEffortConfig>,
directory: PathBuf,
version: &'static str,
is_collaboration: bool,
collaboration_mode: CollaborationMode,
) -> Self {
Self {
version,
@@ -1086,6 +1108,20 @@ impl SessionHeaderHistoryCell {
model_style,
reasoning_effort,
directory,
is_collaboration,
collaboration_mode,
}
}
fn collaboration_mode_label(&self) -> Option<&'static str> {
if !self.is_collaboration {
return None;
}
match &self.collaboration_mode {
CollaborationMode::Plan(_) => Some("Plan"),
CollaborationMode::PairProgramming(_) => Some("Pair Programming"),
CollaborationMode::Execute(_) => Some("Execute"),
CollaborationMode::Custom(_) => None,
}
}
@@ -1146,25 +1182,48 @@ impl HistoryCell for SessionHeaderHistoryCell {
const CHANGE_MODEL_HINT_COMMAND: &str = "/model";
const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change";
const CHANGE_MODE_HINT_EXPLANATION: &str = " to change mode";
const DIR_LABEL: &str = "directory:";
let label_width = DIR_LABEL.len();
let model_label = format!(
"{model_label:<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 {
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 == DEFAULT_MODEL_DISPLAY_NAME {
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 {
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} ");
@@ -1893,11 +1952,14 @@ mod tests {
use crate::exec_cell::CommandOutput;
use crate::exec_cell::ExecCall;
use crate::exec_cell::ExecCell;
use crate::ui_consts::DEFAULT_MODEL_DISPLAY_NAME;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
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;
@@ -1919,6 +1981,22 @@ mod tests {
.expect("config")
}
fn default_collaboration_mode(model: &str) -> CollaborationMode {
CollaborationMode::Custom(Settings {
model: model.to_string(),
reasoning_effort: None,
developer_instructions: None,
})
}
fn plan_collaboration_mode(model: &str) -> CollaborationMode {
CollaborationMode::Plan(Settings {
model: model.to_string(),
reasoning_effort: None,
developer_instructions: None,
})
}
fn render_lines(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
@@ -2420,6 +2498,8 @@ mod tests {
Some(ReasoningEffortConfig::High),
std::env::temp_dir(),
"test",
false,
default_collaboration_mode("gpt-4o"),
);
let lines = render_lines(&cell.display_lines(80));
@@ -2432,6 +2512,51 @@ mod tests {
assert!(model_line.contains("/model to change"));
}
#[test]
fn session_header_collaboration_mode_includes_label_and_hint() {
let cell = SessionHeaderHistoryCell::new(
"gpt-4o".to_string(),
Style::default(),
None,
std::env::temp_dir(),
"test",
true,
plan_collaboration_mode("gpt-4o"),
);
let lines = render_lines(&cell.display_lines(80));
let mode_line = lines
.into_iter()
.find(|line| line.contains("mode:"))
.expect("mode line");
assert!(mode_line.contains("Plan"));
assert!(mode_line.contains("shift + tab"));
assert!(mode_line.contains("to change mode"));
}
#[test]
fn session_header_collaboration_mode_uses_placeholder_when_loading() {
let cell = SessionHeaderHistoryCell::new(
DEFAULT_MODEL_DISPLAY_NAME.to_string(),
Style::default(),
None,
std::env::temp_dir(),
"test",
true,
plan_collaboration_mode("gpt-4o"),
);
let lines = render_lines(&cell.display_lines(80));
let mode_line = lines
.into_iter()
.find(|line| line.contains("mode:"))
.expect("mode line");
assert!(mode_line.contains(DEFAULT_MODEL_DISPLAY_NAME));
assert!(!mode_line.contains("Plan"));
}
#[test]
fn session_header_directory_center_truncates() {
let mut dir = home_dir().expect("home directory");
+3
View File
@@ -16,6 +16,7 @@ pub enum SlashCommand {
Approvals,
#[strum(serialize = "setup-elevated-sandbox")]
ElevateSandbox,
Experimental,
Skills,
Review,
New,
@@ -58,6 +59,7 @@ impl SlashCommand {
SlashCommand::Collab => "change collaboration mode (experimental)",
SlashCommand::Approvals => "choose what Codex can do without approval",
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
SlashCommand::Experimental => "toggle beta features",
SlashCommand::Mcp => "list configured MCP tools",
SlashCommand::Logout => "log out of Codex",
SlashCommand::Rollout => "print the rollout file path",
@@ -83,6 +85,7 @@ impl SlashCommand {
| SlashCommand::Model
| SlashCommand::Approvals
| SlashCommand::ElevateSandbox
| SlashCommand::Experimental
| SlashCommand::Review
| SlashCommand::Logout => false,
SlashCommand::Diff
+1
View File
@@ -9,3 +9,4 @@
/// - User history lines account for this many columns (e.g., "▌ ") when wrapping.
pub(crate) const LIVE_PREFIX_COLS: u16 = 2;
pub(crate) const FOOTER_INDENT_COLS: usize = LIVE_PREFIX_COLS as usize;
pub(crate) const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading";
+28
View File
@@ -51,6 +51,34 @@ The solution is to detect paste-like _bursts_ and buffer them into a single expl
- After handling the key, `sync_popups()` runs so popup visibility/filters stay consistent with the
latest text + cursor.
## Submission flow (Enter/Tab)
There are multiple submission paths, but they share the same core rules:
### Normal submit/queue path
`handle_submission` calls `prepare_submission_text` for both submit and queue. That method:
1. Expands any pending paste placeholders so element ranges align with the final text.
2. Trims whitespace and rebases element ranges to the trimmed buffer.
3. Expands `/prompts:` custom prompts:
- Named args use key=value parsing.
- Numeric args use positional parsing for `$1..$9` and `$ARGUMENTS`.
The expansion preserves text elements and yields the final submission payload.
4. Prunes attachments so only placeholders that survive expansion are sent.
5. Clears pending pastes on success and suppresses submission if the final text is empty and there
are no attachments.
### Numeric auto-submit path
When the slash popup is open and the first line matches a numeric-only custom prompt with
positional args, Enter auto-submits without calling `prepare_submission_text`. That path still:
- Expands pending pastes before parsing positional args.
- Uses expanded text elements for prompt expansion.
- Prunes attachments based on expanded placeholders.
- Clears pending pastes after a successful auto-submit.
## Paste burst: concepts and assumptions
The burst detector is intentionally conservative: it only processes “plain” character input
+39
View File
@@ -0,0 +1,39 @@
# Request user input overlay (TUI)
This note documents the TUI overlay used to gather answers for
`RequestUserInputEvent`.
## Overview
The overlay renders one question at a time and collects:
- A single selected option (when options exist).
- Freeform notes (always available).
When options are present, notes are stored per selected option and the first
option is selected by default, so every option question has an answer. If a
question has no options and no notes are provided, the answer is submitted as
`skipped`.
## Focus and input routing
The overlay tracks a small focus state:
- **Options**: Up/Down move the selection and Space selects.
- **Notes**: Text input edits notes for the currently selected option.
Typing while focused on options switches into notes automatically to reduce
friction for freeform input.
## Navigation
- Enter advances to the next question.
- Enter on the last question submits all answers.
- PageUp/PageDown navigate across questions (when multiple are present).
- Esc interrupts the run.
## Layout priorities
The layout prefers to keep the question and all options visible. Notes and
footer hints collapse as space shrinks, with notes falling back to a single-line
"Notes: ..." input in tight terminals.