diff --git a/README.md b/README.md index 60e44298a3..c7f6a1d595 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,12 @@ codex login If you complete the process successfully, you should have a `~/.codex/auth.json` file that contains the credentials that Codex will use. +To verify whether you are currently logged in, run: + +``` +codex login status +``` + If you encounter problems with the login flow, please comment on .
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 653c3e4ef2..120050c227 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -673,7 +673,9 @@ dependencies = [ "async-channel", "base64 0.22.1", "bytes", + "chrono", "codex-apply-patch", + "codex-login", "codex-mcp-client", "core_test_support", "dirs", @@ -822,6 +824,7 @@ dependencies = [ "serde", "serde_json", "shlex", + "strum_macros 0.27.2", "tempfile", "tokio", "tokio-test", diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index 4c4cb4c4c3..907783bb81 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -21,10 +21,14 @@ pub(crate) async fn chatgpt_get_request( let token = get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?; + let account_id = token.account_id.ok_or_else(|| { + anyhow::anyhow!("ChatGPT account ID not available, please re-run `codex login`") + }); + let response = client .get(&url) .bearer_auth(&token.access_token) - .header("chatgpt-account-id", &token.account_id) + .header("chatgpt-account-id", account_id?) .header("Content-Type", "application/json") .header("User-Agent", "codex-cli") .send() diff --git a/codex-rs/chatgpt/src/chatgpt_token.rs b/codex-rs/chatgpt/src/chatgpt_token.rs index adf9a6ba96..55ebc22a08 100644 --- a/codex-rs/chatgpt/src/chatgpt_token.rs +++ b/codex-rs/chatgpt/src/chatgpt_token.rs @@ -18,7 +18,10 @@ pub fn set_chatgpt_token_data(value: TokenData) { /// Initialize the ChatGPT token from auth.json file pub async fn init_chatgpt_token_from_auth(codex_home: &Path) -> std::io::Result<()> { - let auth_json = codex_login::try_read_auth_json(codex_home).await?; - set_chatgpt_token_data(auth_json.tokens.clone()); + let auth = codex_login::load_auth(codex_home)?; + if let Some(auth) = auth { + let token_data = auth.get_token_data().await?; + set_chatgpt_token_data(token_data); + } Ok(()) } diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index af3fb667f6..390c310030 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -1,25 +1,12 @@ use codex_common::CliConfigOverrides; use codex_core::config::Config; use codex_core::config::ConfigOverrides; +use codex_login::AuthMode; +use codex_login::load_auth; use codex_login::login_with_chatgpt; pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! { - let cli_overrides = match cli_config_overrides.parse_overrides() { - Ok(v) => v, - Err(e) => { - eprintln!("Error parsing -c overrides: {e}"); - std::process::exit(1); - } - }; - - let config_overrides = ConfigOverrides::default(); - let config = match Config::load_with_cli_overrides(cli_overrides, config_overrides) { - Ok(config) => config, - Err(e) => { - eprintln!("Error loading configuration: {e}"); - std::process::exit(1); - } - }; + let config = load_config_or_exit(cli_config_overrides); let capture_output = false; match login_with_chatgpt(&config.codex_home, capture_output).await { @@ -33,3 +20,77 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> } } } + +pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { + let config = load_config_or_exit(cli_config_overrides); + + match load_auth(&config.codex_home) { + Ok(Some(auth)) => match auth.mode { + AuthMode::ApiKey => { + if let Some(api_key) = auth.api_key.as_deref() { + eprintln!("Logged in using an API key - {}", safe_format_key(api_key)); + } else { + eprintln!("Logged in using an API key"); + } + std::process::exit(0); + } + AuthMode::ChatGPT => { + eprintln!("Logged in using ChatGPT"); + std::process::exit(0); + } + }, + Ok(None) => { + eprintln!("Not logged in"); + std::process::exit(1); + } + Err(e) => { + eprintln!("Error checking login status: {e}"); + std::process::exit(1); + } + } +} + +fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config { + let cli_overrides = match cli_config_overrides.parse_overrides() { + Ok(v) => v, + Err(e) => { + eprintln!("Error parsing -c overrides: {e}"); + std::process::exit(1); + } + }; + + let config_overrides = ConfigOverrides::default(); + match Config::load_with_cli_overrides(cli_overrides, config_overrides) { + Ok(config) => config, + Err(e) => { + eprintln!("Error loading configuration: {e}"); + std::process::exit(1); + } + } +} + +fn safe_format_key(key: &str) -> String { + if key.len() <= 13 { + return "***".to_string(); + } + let prefix = &key[..8]; + let suffix = &key[key.len() - 5..]; + format!("{prefix}***{suffix}") +} + +#[cfg(test)] +mod tests { + use super::safe_format_key; + + #[test] + fn formats_long_key() { + let key = "sk-proj-1234567890ABCDE"; + assert_eq!(safe_format_key(key), "sk-proj-***ABCDE"); + } + + #[test] + fn short_key_returns_stars() { + let key = "sk-proj-12345"; + assert_eq!(safe_format_key(key), "***"); + } +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 6dd596ff9f..c5fd69f9cd 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -7,6 +7,7 @@ use codex_chatgpt::apply_command::ApplyCommand; use codex_chatgpt::apply_command::run_apply_command; use codex_cli::LandlockCommand; use codex_cli::SeatbeltCommand; +use codex_cli::login::run_login_status; use codex_cli::login::run_login_with_chatgpt; use codex_cli::proto; use codex_common::CliConfigOverrides; @@ -43,7 +44,7 @@ enum Subcommand { #[clap(visible_alias = "e")] Exec(ExecCli), - /// Login with ChatGPT. + /// Manage login. Login(LoginCommand), /// Experimental: run Codex as an MCP server. @@ -90,6 +91,15 @@ enum DebugCommand { struct LoginCommand { #[clap(skip)] config_overrides: CliConfigOverrides, + + #[command(subcommand)] + action: Option, +} + +#[derive(Debug, clap::Subcommand)] +enum LoginSubcommand { + /// Show login status. + Status, } fn main() -> anyhow::Result<()> { @@ -118,7 +128,14 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() } Some(Subcommand::Login(mut login_cli)) => { prepend_config_flags(&mut login_cli.config_overrides, cli.config_overrides); - run_login_with_chatgpt(login_cli.config_overrides).await; + match login_cli.action { + Some(LoginSubcommand::Status) => { + run_login_status(login_cli.config_overrides).await; + } + None => { + run_login_with_chatgpt(login_cli.config_overrides).await; + } + } } Some(Subcommand::Proto(mut proto_cli)) => { prepend_config_flags(&mut proto_cli.config_overrides, cli.config_overrides); diff --git a/codex-rs/cli/src/proto.rs b/codex-rs/cli/src/proto.rs index 64b292d50b..291e1680f1 100644 --- a/codex-rs/cli/src/proto.rs +++ b/codex-rs/cli/src/proto.rs @@ -9,6 +9,7 @@ use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::protocol::Submission; use codex_core::util::notify_on_sigint; +use codex_login::load_auth; use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; use tracing::error; @@ -35,8 +36,9 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> { .map_err(anyhow::Error::msg)?; let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?; + let auth = load_auth(&config.codex_home)?; let ctrl_c = notify_on_sigint(); - let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c.clone()).await?; + let CodexSpawnOk { codex, .. } = Codex::spawn(config, auth, ctrl_c.clone()).await?; let codex = Arc::new(codex); // Task that reads JSON lines from stdin and forwards to Submission Queue diff --git a/codex-rs/config.md b/codex-rs/config.md index c45d81180d..1a407a239b 100644 --- a/codex-rs/config.md +++ b/codex-rs/config.md @@ -110,12 +110,15 @@ stream_idle_timeout_ms = 300000 # 5m idle timeout ``` #### request_max_retries + How many times Codex will retry a failed HTTP request to the model provider. Defaults to `4`. #### stream_max_retries + Number of times Codex will attempt to reconnect when a streaming response is interrupted. Defaults to `10`. #### stream_idle_timeout_ms + How long Codex will wait for activity on a streaming response before treating the connection as lost. Defaults to `300_000` (5 minutes). ## model_provider diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 2e0489c9b5..5ebb5ef63d 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -17,6 +17,8 @@ base64 = "0.22" bytes = "1.10.1" codex-apply-patch = { path = "../apply-patch" } codex-mcp-client = { path = "../mcp-client" } +chrono = { version = "0.4", features = ["serde"] } +codex-login = { path = "../login" } dirs = "6" env-flags = "0.1.1" eventsource-stream = "0.2.3" diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index 5adf3c4d50..5ede774b1c 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -30,6 +30,7 @@ use crate::util::backoff; pub(crate) async fn stream_chat_completions( prompt: &Prompt, model: &str, + include_plan_tool: bool, client: &reqwest::Client, provider: &ModelProviderInfo, ) -> Result { @@ -39,6 +40,10 @@ pub(crate) async fn stream_chat_completions( let full_instructions = prompt.get_full_instructions(model); messages.push(json!({"role": "system", "content": full_instructions})); + if let Some(instr) = &prompt.user_instructions { + messages.push(json!({"role": "user", "content": instr})); + } + for item in &prompt.input { match item { ResponseItem::Message { role, content, .. } => { @@ -105,7 +110,7 @@ pub(crate) async fn stream_chat_completions( } } - let tools_json = create_tools_json_for_chat_completions_api(prompt, model)?; + let tools_json = create_tools_json_for_chat_completions_api(prompt, model, include_plan_tool)?; let payload = json!({ "model": model, "messages": messages, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 1648da6d96..4e0e62c0f7 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -3,6 +3,8 @@ use std::path::Path; use std::time::Duration; use bytes::Bytes; +use codex_login::AuthMode; +use codex_login::CodexAuth; use eventsource_stream::Eventsource; use futures::prelude::*; use reqwest::StatusCode; @@ -28,10 +30,12 @@ use crate::config::Config; use crate::config_types::ReasoningEffort as ReasoningEffortConfig; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::error::CodexErr; +use crate::error::EnvVarError; use crate::error::Result; use crate::flags::CODEX_RS_SSE_FIXTURE; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; +use crate::models::ContentItem; use crate::models::ResponseItem; use crate::openai_tools::create_tools_json_for_responses_api; use crate::protocol::TokenUsage; @@ -41,6 +45,7 @@ use std::sync::Arc; #[derive(Clone)] pub struct ModelClient { config: Arc, + auth: Option, client: reqwest::Client, provider: ModelProviderInfo, session_id: Uuid, @@ -51,6 +56,7 @@ pub struct ModelClient { impl ModelClient { pub fn new( config: Arc, + auth: Option, provider: ModelProviderInfo, effort: ReasoningEffortConfig, summary: ReasoningSummaryConfig, @@ -58,6 +64,7 @@ impl ModelClient { ) -> Self { Self { config, + auth, client: reqwest::Client::new(), provider, session_id, @@ -77,6 +84,7 @@ impl ModelClient { let response_stream = stream_chat_completions( prompt, &self.config.model, + self.config.include_plan_tool, &self.client, &self.provider, ) @@ -114,28 +122,60 @@ impl ModelClient { return stream_from_fixture(path, self.provider.clone()).await; } + let auth = self.auth.as_ref().ok_or_else(|| { + CodexErr::EnvVar(EnvVarError { + var: "OPENAI_API_KEY".to_string(), + instructions: Some("Create an API key (https://platform.openai.com) and export it as an environment variable.".to_string()), + }) + })?; + + let store = prompt.store && auth.mode != AuthMode::ChatGPT; + + let base_url = match self.provider.base_url.clone() { + Some(url) => url, + None => match auth.mode { + AuthMode::ChatGPT => "https://chatgpt.com/backend-api/codex".to_string(), + AuthMode::ApiKey => "https://api.openai.com/v1".to_string(), + }, + }; + + let token = auth.get_token().await?; + let full_instructions = prompt.get_full_instructions(&self.config.model); - let tools_json = create_tools_json_for_responses_api(prompt, &self.config.model)?; + let tools_json = create_tools_json_for_responses_api( + prompt, + &self.config.model, + self.config.include_plan_tool, + )?; let reasoning = create_reasoning_param_for_request(&self.config, self.effort, self.summary); // Request encrypted COT if we are not storing responses, // otherwise reasoning items will be referenced by ID - let include = if !prompt.store && reasoning.is_some() { + let include: Vec = if !store && reasoning.is_some() { vec!["reasoning.encrypted_content".to_string()] } else { vec![] }; + let mut input_with_instructions = Vec::with_capacity(prompt.input.len() + 1); + if let Some(ui) = &prompt.user_instructions { + input_with_instructions.push(ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { text: ui.clone() }], + }); + } + input_with_instructions.extend(prompt.input.clone()); + let payload = ResponsesApiRequest { model: &self.config.model, instructions: &full_instructions, - input: &prompt.input, + input: &input_with_instructions, tools: &tools_json, tool_choice: "auto", parallel_tool_calls: false, reasoning, - store: prompt.store, - // TODO: make this configurable + store, stream: true, include, }; @@ -148,17 +188,21 @@ impl ModelClient { let mut attempt = 0; let max_retries = self.provider.request_max_retries(); + loop { attempt += 1; let req_builder = self - .provider - .create_request_builder(&self.client)? + .client + .post(format!("{base_url}/responses")) .header("OpenAI-Beta", "responses=experimental") .header("session_id", self.session_id.to_string()) + .bearer_auth(&token) .header(reqwest::header::ACCEPT, "text/event-stream") .json(&payload); + let req_builder = self.provider.apply_http_headers(req_builder); + let res = req_builder.send().await; if let Ok(resp) = &res { trace!( @@ -567,7 +611,7 @@ mod tests { let provider = ModelProviderInfo { name: "test".to_string(), - base_url: "https://test.com".to_string(), + base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), env_key_instructions: None, wire_api: WireApi::Responses, @@ -577,6 +621,7 @@ mod tests { request_max_retries: Some(0), stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), + requires_auth: false, }; let events = collect_events( @@ -626,7 +671,7 @@ mod tests { let sse1 = format!("event: response.output_item.done\ndata: {item1}\n\n"); let provider = ModelProviderInfo { name: "test".to_string(), - base_url: "https://test.com".to_string(), + base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), env_key_instructions: None, wire_api: WireApi::Responses, @@ -636,6 +681,7 @@ mod tests { request_max_retries: Some(0), stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), + requires_auth: false, }; let events = collect_events(&[sse1.as_bytes()], provider).await; @@ -728,7 +774,7 @@ mod tests { let provider = ModelProviderInfo { name: "test".to_string(), - base_url: "https://test.com".to_string(), + base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), env_key_instructions: None, wire_api: WireApi::Responses, @@ -738,6 +784,7 @@ mod tests { request_max_retries: Some(0), stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), + requires_auth: false, }; let out = run_sse(evs, provider).await; diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index afd2f04556..157f35872a 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -44,9 +44,6 @@ impl Prompt { .as_deref() .unwrap_or(BASE_INSTRUCTIONS); let mut sections: Vec<&str> = vec![base]; - if let Some(ref user) = self.user_instructions { - sections.push(user); - } if model.starts_with("gpt-4.1") { sections.push(APPLY_PATCH_TOOL_INSTRUCTIONS); } @@ -188,3 +185,19 @@ impl Stream for ResponseStream { self.rx_event.poll_recv(cx) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_full_instructions_no_user_content() { + let prompt = Prompt { + user_instructions: Some("custom instruction".to_string()), + ..Default::default() + }; + let expected = format!("{BASE_INSTRUCTIONS}\n{APPLY_PATCH_TOOL_INSTRUCTIONS}"); + let full = prompt.get_full_instructions("gpt-4.1"); + assert_eq!(full, expected); + } +} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8b38da242b..cd92739c72 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -16,6 +16,7 @@ use async_channel::Sender; use codex_apply_patch::ApplyPatchAction; use codex_apply_patch::MaybeApplyPatchVerified; use codex_apply_patch::maybe_parse_apply_patch_verified; +use codex_login::CodexAuth; use futures::prelude::*; use mcp_types::CallToolResult; use serde::Serialize; @@ -58,6 +59,7 @@ use crate::models::ReasoningItemReasoningSummary; use crate::models::ResponseInputItem; use crate::models::ResponseItem; use crate::models::ShellToolCallParams; +use crate::plan_tool::handle_update_plan; use crate::project_doc::get_user_instructions; use crate::protocol::AgentMessageDeltaEvent; use crate::protocol::AgentMessageEvent; @@ -106,7 +108,11 @@ pub struct CodexSpawnOk { impl Codex { /// Spawn a new [`Codex`] and initialize the session. - pub async fn spawn(config: Config, ctrl_c: Arc) -> CodexResult { + pub async fn spawn( + config: Config, + auth: Option, + ctrl_c: Arc, + ) -> CodexResult { // experimental resume path (undocumented) let resume_path = config.experimental_resume.clone(); info!("resume_path: {resume_path:?}"); @@ -135,7 +141,7 @@ impl Codex { // Generate a unique ID for the lifetime of this Codex session. let session_id = Uuid::new_v4(); tokio::spawn(submission_loop( - session_id, config, rx_sub, tx_event, ctrl_c, + session_id, config, auth, rx_sub, tx_event, ctrl_c, )); let codex = Codex { next_id: AtomicU64::new(0), @@ -534,6 +540,7 @@ impl AgentTask { async fn submission_loop( mut session_id: Uuid, config: Arc, + auth: Option, rx_sub: Receiver, tx_event: Sender, ctrl_c: Arc, @@ -645,6 +652,7 @@ async fn submission_loop( let client = ModelClient::new( config.clone(), + auth.clone(), provider.clone(), model_reasoning_effort, model_reasoning_summary, @@ -1346,6 +1354,7 @@ async fn handle_function_call( }; handle_container_exec_with_params(params, sess, sub_id, call_id).await } + "update_plan" => handle_update_plan(sess, arguments, sub_id, call_id).await, _ => { match sess.mcp_connection_manager.parse_tool_name(&name) { Some((server, tool_name)) => { diff --git a/codex-rs/core/src/codex_wrapper.rs b/codex-rs/core/src/codex_wrapper.rs index b80579297a..1e26a9ebed 100644 --- a/codex-rs/core/src/codex_wrapper.rs +++ b/codex-rs/core/src/codex_wrapper.rs @@ -6,6 +6,7 @@ use crate::config::Config; use crate::protocol::Event; use crate::protocol::EventMsg; use crate::util::notify_on_sigint; +use codex_login::load_auth; use tokio::sync::Notify; use uuid::Uuid; @@ -25,11 +26,12 @@ pub struct CodexConversation { /// that callers can surface the information to the UI. pub async fn init_codex(config: Config) -> anyhow::Result { let ctrl_c = notify_on_sigint(); + let auth = load_auth(&config.codex_home)?; let CodexSpawnOk { codex, init_id, session_id, - } = Codex::spawn(config, ctrl_c.clone()).await?; + } = Codex::spawn(config, auth, ctrl_c.clone()).await?; // The first event must be `SessionInitialized`. Validate and forward it to // the caller so that they can display it in the conversation history. diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 2dfd3e55fe..a65ec09674 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -143,6 +143,9 @@ pub struct Config { /// Experimental rollout resume path (absolute path to .jsonl; undocumented). pub experimental_resume: Option, + + /// Include an experimental plan tool that the model can use to update its current plan and status of each step. + pub include_plan_tool: bool, } impl Config { @@ -366,6 +369,7 @@ pub struct ConfigOverrides { pub config_profile: Option, pub codex_linux_sandbox_exe: Option, pub base_instructions: Option, + pub include_plan_tool: Option, } impl Config { @@ -388,6 +392,7 @@ impl Config { config_profile: config_profile_key, codex_linux_sandbox_exe, base_instructions, + include_plan_tool, } = overrides; let config_profile = match config_profile_key.as_ref().or(cfg.profile.as_ref()) { @@ -465,9 +470,14 @@ impl Config { let experimental_resume = cfg.experimental_resume; - let base_instructions = base_instructions.or(Self::get_base_instructions( + // 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 file_base_instructions = Self::get_base_instructions( cfg.experimental_instructions_file.as_ref(), - )); + &resolved_cwd, + )?; + let base_instructions = base_instructions.or(file_base_instructions); let config = Self { model, @@ -518,6 +528,7 @@ impl Config { .unwrap_or("https://chatgpt.com/backend-api/".to_string()), experimental_resume, + include_plan_tool: include_plan_tool.unwrap_or(false), }; Ok(config) } @@ -539,13 +550,46 @@ impl Config { }) } - fn get_base_instructions(path: Option<&PathBuf>) -> Option { - let path = path.as_ref()?; + fn get_base_instructions( + path: Option<&PathBuf>, + cwd: &Path, + ) -> std::io::Result> { + let p = match path.as_ref() { + None => return Ok(None), + Some(p) => p, + }; - std::fs::read_to_string(path) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) + // Resolve relative paths against the provided cwd to make CLI + // overrides consistent regardless of where the process was launched + // from. + let full_path = if p.is_relative() { + cwd.join(p) + } else { + p.to_path_buf() + }; + + let contents = std::fs::read_to_string(&full_path).map_err(|e| { + std::io::Error::new( + e.kind(), + format!( + "failed to read experimental instructions file {}: {e}", + full_path.display() + ), + ) + })?; + + let s = contents.trim().to_string(); + if s.is_empty() { + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "experimental instructions file is empty: {}", + full_path.display() + ), + )) + } else { + Ok(Some(s)) + } } } @@ -751,7 +795,7 @@ disable_response_storage = true let openai_chat_completions_provider = ModelProviderInfo { name: "OpenAI using Chat Completions".to_string(), - base_url: "https://api.openai.com/v1".to_string(), + base_url: Some("https://api.openai.com/v1".to_string()), env_key: Some("OPENAI_API_KEY".to_string()), wire_api: crate::WireApi::Chat, env_key_instructions: None, @@ -761,6 +805,7 @@ disable_response_storage = true request_max_retries: Some(4), stream_max_retries: Some(10), stream_idle_timeout_ms: Some(300_000), + requires_auth: false, }; let model_provider_map = { let mut model_provider_map = built_in_model_providers(); @@ -791,7 +836,7 @@ disable_response_storage = true /// /// 1. custom command-line argument, e.g. `--model o3` /// 2. as part of a profile, where the `--profile` is specified via a CLI - /// (or in the config file itelf) + /// (or in the config file itself) /// 3. as an entry in `config.toml`, e.g. `model = "o3"` /// 4. the default value for a required field defined in code, e.g., /// `crate::flags::OPENAI_DEFAULT_MODEL` @@ -841,6 +886,7 @@ disable_response_storage = true chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), experimental_resume: None, base_instructions: None, + include_plan_tool: false, }, o3_profile_config ); @@ -889,6 +935,7 @@ disable_response_storage = true chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), experimental_resume: None, base_instructions: None, + include_plan_tool: false, }; assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); @@ -952,6 +999,7 @@ disable_response_storage = true chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), experimental_resume: None, base_instructions: None, + include_plan_tool: false, }; assert_eq!(expected_zdr_profile_config, zdr_profile_config); diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 735a571edc..9bf0d483e1 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -78,7 +78,7 @@ pub enum HistoryPersistence { #[derive(Deserialize, Debug, Clone, PartialEq, Default)] pub struct Tui {} -#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default)] +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default, Serialize)] #[serde(rename_all = "kebab-case")] pub enum SandboxMode { #[serde(rename = "read-only")] diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 689f798258..054abd742a 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -30,10 +30,11 @@ mod message_history; mod model_provider_info; pub use model_provider_info::ModelProviderInfo; pub use model_provider_info::WireApi; +pub use model_provider_info::built_in_model_providers; mod models; -pub mod openai_api_key; mod openai_model_info; mod openai_tools; +pub mod plan_tool; mod project_doc; pub mod protocol; mod rollout; diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 61a51a0e7a..e92d7e8481 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -1,4 +1,5 @@ use std::time::Duration; +use std::time::Instant; use tracing::error; @@ -7,6 +8,7 @@ use crate::models::FunctionCallOutputPayload; use crate::models::ResponseInputItem; use crate::protocol::Event; use crate::protocol::EventMsg; +use crate::protocol::McpInvocation; use crate::protocol::McpToolCallBeginEvent; use crate::protocol::McpToolCallEndEvent; @@ -41,21 +43,28 @@ pub(crate) async fn handle_mcp_tool_call( } }; - let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id: call_id.clone(), + let invocation = McpInvocation { server: server.clone(), tool: tool_name.clone(), arguments: arguments_value.clone(), + }; + + let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent { + call_id: call_id.clone(), + invocation: invocation.clone(), }); notify_mcp_tool_call_event(sess, sub_id, tool_call_begin_event).await; + let start = Instant::now(); // Perform the tool call. let result = sess - .call_tool(&server, &tool_name, arguments_value, timeout) + .call_tool(&server, &tool_name, arguments_value.clone(), timeout) .await .map_err(|e| format!("tool call error: {e}")); let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent { call_id: call_id.clone(), + invocation, + duration: start.elapsed(), result: result.clone(), }); diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 72ef58c60a..4640f53ad7 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -12,7 +12,6 @@ use std::env::VarError; use std::time::Duration; use crate::error::EnvVarError; -use crate::openai_api_key::get_openai_api_key; /// Value for the `OpenAI-Originator` header that is sent with requests to /// OpenAI. @@ -30,7 +29,7 @@ const DEFAULT_REQUEST_MAX_RETRIES: u64 = 4; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum WireApi { - /// The experimental "Responses" API exposed by OpenAI at `/v1/responses`. + /// The Responses API exposed by OpenAI at `/v1/responses`. Responses, /// Regular Chat Completions compatible with `/v1/chat/completions`. @@ -44,7 +43,7 @@ pub struct ModelProviderInfo { /// Friendly display name. pub name: String, /// Base URL for the provider's OpenAI-compatible API. - pub base_url: String, + pub base_url: Option, /// Environment variable that stores the user's API key for this provider. pub env_key: Option, @@ -78,6 +77,10 @@ pub struct ModelProviderInfo { /// Idle timeout (in milliseconds) to wait for activity on a streaming response before treating /// the connection as lost. pub stream_idle_timeout_ms: Option, + + /// Whether this provider requires some form of standard authentication (API key, ChatGPT token). + #[serde(default)] + pub requires_auth: bool, } impl ModelProviderInfo { @@ -93,11 +96,11 @@ impl ModelProviderInfo { &'a self, client: &'a reqwest::Client, ) -> crate::error::Result { - let api_key = self.api_key()?; - let url = self.get_full_url(); let mut builder = client.post(url); + + let api_key = self.api_key()?; if let Some(key) = api_key { builder = builder.bearer_auth(key); } @@ -117,9 +120,15 @@ impl ModelProviderInfo { .join("&"); format!("?{full_params}") }); - let base_url = &self.base_url; + let base_url = self + .base_url + .clone() + .unwrap_or("https://api.openai.com/v1".to_string()); + match self.wire_api { - WireApi::Responses => format!("{base_url}/responses{query_string}"), + WireApi::Responses => { + format!("{base_url}/responses{query_string}") + } WireApi::Chat => format!("{base_url}/chat/completions{query_string}"), } } @@ -127,7 +136,10 @@ impl ModelProviderInfo { /// Apply provider-specific HTTP headers (both static and environment-based) /// onto an existing `reqwest::RequestBuilder` and return the updated /// builder. - fn apply_http_headers(&self, mut builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + pub fn apply_http_headers( + &self, + mut builder: reqwest::RequestBuilder, + ) -> reqwest::RequestBuilder { if let Some(extra) = &self.http_headers { for (k, v) in extra { builder = builder.header(k, v); @@ -152,11 +164,7 @@ impl ModelProviderInfo { fn api_key(&self) -> crate::error::Result> { match &self.env_key { Some(env_key) => { - let env_value = if env_key == crate::openai_api_key::OPENAI_API_KEY_ENV_VAR { - get_openai_api_key().map_or_else(|| Err(VarError::NotPresent), Ok) - } else { - std::env::var(env_key) - }; + let env_value = std::env::var(env_key); env_value .and_then(|v| { if v.trim().is_empty() { @@ -204,47 +212,51 @@ pub fn built_in_model_providers() -> HashMap { // providers are bundled with Codex CLI, so we only include the OpenAI // provider by default. Users are encouraged to add to `model_providers` // in config.toml to add their own providers. - [ - ( - "openai", - P { - name: "OpenAI".into(), - // Allow users to override the default OpenAI endpoint by - // exporting `OPENAI_BASE_URL`. This is useful when pointing - // Codex at a proxy, mock server, or Azure-style deployment - // without requiring a full TOML override for the built-in - // OpenAI provider. - base_url: std::env::var("OPENAI_BASE_URL") - .ok() - .filter(|v| !v.trim().is_empty()) - .unwrap_or_else(|| "https://api.openai.com/v1".to_string()), - env_key: Some("OPENAI_API_KEY".into()), - env_key_instructions: Some("Create an API key (https://platform.openai.com) and export it as an environment variable.".into()), - wire_api: WireApi::Responses, - query_params: None, - http_headers: Some( - [ - ("originator".to_string(), OPENAI_ORIGINATOR_HEADER.to_string()), - ("version".to_string(), env!("CARGO_PKG_VERSION").to_string()), - ] - .into_iter() - .collect(), - ), - env_http_headers: Some( - [ - ("OpenAI-Organization".to_string(), "OPENAI_ORGANIZATION".to_string()), - ("OpenAI-Project".to_string(), "OPENAI_PROJECT".to_string()), - ] - .into_iter() - .collect(), - ), - // Use global defaults for retry/timeout unless overridden in config.toml. - request_max_retries: None, - stream_max_retries: None, - stream_idle_timeout_ms: None, - }, - ), - ] + [( + "openai", + P { + name: "OpenAI".into(), + // Allow users to override the default OpenAI endpoint by + // exporting `OPENAI_BASE_URL`. This is useful when pointing + // Codex at a proxy, mock server, or Azure-style deployment + // without requiring a full TOML override for the built-in + // OpenAI provider. + base_url: std::env::var("OPENAI_BASE_URL") + .ok() + .filter(|v| !v.trim().is_empty()), + env_key: None, + env_key_instructions: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: Some( + [ + ( + "originator".to_string(), + OPENAI_ORIGINATOR_HEADER.to_string(), + ), + ("version".to_string(), env!("CARGO_PKG_VERSION").to_string()), + ] + .into_iter() + .collect(), + ), + env_http_headers: Some( + [ + ( + "OpenAI-Organization".to_string(), + "OPENAI_ORGANIZATION".to_string(), + ), + ("OpenAI-Project".to_string(), "OPENAI_PROJECT".to_string()), + ] + .into_iter() + .collect(), + ), + // Use global defaults for retry/timeout unless overridden in config.toml. + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + requires_auth: true, + }, + )] .into_iter() .map(|(k, v)| (k.to_string(), v)) .collect() @@ -264,7 +276,7 @@ base_url = "http://localhost:11434/v1" "#; let expected_provider = ModelProviderInfo { name: "Ollama".into(), - base_url: "http://localhost:11434/v1".into(), + base_url: Some("http://localhost:11434/v1".into()), env_key: None, env_key_instructions: None, wire_api: WireApi::Chat, @@ -274,6 +286,7 @@ base_url = "http://localhost:11434/v1" request_max_retries: None, stream_max_retries: None, stream_idle_timeout_ms: None, + requires_auth: false, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); @@ -290,7 +303,7 @@ query_params = { api-version = "2025-04-01-preview" } "#; let expected_provider = ModelProviderInfo { name: "Azure".into(), - base_url: "https://xxxxx.openai.azure.com/openai".into(), + base_url: Some("https://xxxxx.openai.azure.com/openai".into()), env_key: Some("AZURE_OPENAI_API_KEY".into()), env_key_instructions: None, wire_api: WireApi::Chat, @@ -302,6 +315,7 @@ query_params = { api-version = "2025-04-01-preview" } request_max_retries: None, stream_max_retries: None, stream_idle_timeout_ms: None, + requires_auth: false, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); @@ -319,7 +333,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } "#; let expected_provider = ModelProviderInfo { name: "Example".into(), - base_url: "https://example.com".into(), + base_url: Some("https://example.com".into()), env_key: Some("API_KEY".into()), env_key_instructions: None, wire_api: WireApi::Chat, @@ -333,6 +347,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } request_max_retries: None, stream_max_retries: None, stream_idle_timeout_ms: None, + requires_auth: false, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); diff --git a/codex-rs/core/src/openai_api_key.rs b/codex-rs/core/src/openai_api_key.rs deleted file mode 100644 index 728914c0f2..0000000000 --- a/codex-rs/core/src/openai_api_key.rs +++ /dev/null @@ -1,24 +0,0 @@ -use std::env; -use std::sync::LazyLock; -use std::sync::RwLock; - -pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; - -static OPENAI_API_KEY: LazyLock>> = LazyLock::new(|| { - let val = env::var(OPENAI_API_KEY_ENV_VAR) - .ok() - .and_then(|s| if s.is_empty() { None } else { Some(s) }); - RwLock::new(val) -}); - -pub fn get_openai_api_key() -> Option { - #![allow(clippy::unwrap_used)] - OPENAI_API_KEY.read().unwrap().clone() -} - -pub fn set_openai_api_key(value: String) { - #![allow(clippy::unwrap_used)] - if !value.is_empty() { - *OPENAI_API_KEY.write().unwrap() = Some(value); - } -} diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs index ef12a629b6..0f1e7d9ca7 100644 --- a/codex-rs/core/src/openai_tools.rs +++ b/codex-rs/core/src/openai_tools.rs @@ -4,13 +4,14 @@ use std::collections::BTreeMap; use std::sync::LazyLock; use crate::client_common::Prompt; +use crate::plan_tool::PLAN_TOOL; #[derive(Debug, Clone, Serialize)] pub(crate) struct ResponsesApiTool { - name: &'static str, - description: &'static str, - strict: bool, - parameters: JsonSchema, + pub(crate) name: &'static str, + pub(crate) description: &'static str, + pub(crate) strict: bool, + pub(crate) parameters: JsonSchema, } /// When serialized as JSON, this produces a valid "Tool" in the OpenAI @@ -74,6 +75,7 @@ static DEFAULT_CODEX_MODEL_TOOLS: LazyLock> = pub(crate) fn create_tools_json_for_responses_api( prompt: &Prompt, model: &str, + include_plan_tool: bool, ) -> crate::error::Result> { // Assemble tool list: built-in tools + any extra tools from the prompt. let default_tools = if model.starts_with("codex") { @@ -93,6 +95,10 @@ pub(crate) fn create_tools_json_for_responses_api( .map(|(name, tool)| mcp_tool_to_openai_tool(name, tool)), ); + if include_plan_tool { + tools_json.push(serde_json::to_value(PLAN_TOOL.clone())?); + } + Ok(tools_json) } @@ -102,10 +108,12 @@ pub(crate) fn create_tools_json_for_responses_api( pub(crate) fn create_tools_json_for_chat_completions_api( prompt: &Prompt, model: &str, + include_plan_tool: bool, ) -> crate::error::Result> { // We start with the JSON for the Responses API and than rewrite it to match // the chat completions tool call format. - let responses_api_tools_json = create_tools_json_for_responses_api(prompt, model)?; + let responses_api_tools_json = + create_tools_json_for_responses_api(prompt, model, include_plan_tool)?; let tools_json = responses_api_tools_json .into_iter() .filter_map(|mut tool| { diff --git a/codex-rs/core/src/plan_tool.rs b/codex-rs/core/src/plan_tool.rs new file mode 100644 index 0000000000..dbddb8b5eb --- /dev/null +++ b/codex-rs/core/src/plan_tool.rs @@ -0,0 +1,126 @@ +use std::collections::BTreeMap; +use std::sync::LazyLock; + +use serde::Deserialize; +use serde::Serialize; + +use crate::codex::Session; +use crate::models::FunctionCallOutputPayload; +use crate::models::ResponseInputItem; +use crate::openai_tools::JsonSchema; +use crate::openai_tools::OpenAiTool; +use crate::openai_tools::ResponsesApiTool; +use crate::protocol::Event; +use crate::protocol::EventMsg; + +// Types for the TODO tool arguments matching codex-vscode/todo-mcp/src/main.rs +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StepStatus { + Pending, + InProgress, + Completed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlanItemArg { + pub step: String, + pub status: StepStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpdatePlanArgs { + #[serde(default)] + pub explanation: Option, + pub plan: Vec, +} + +pub(crate) static PLAN_TOOL: LazyLock = LazyLock::new(|| { + let mut plan_item_props = BTreeMap::new(); + plan_item_props.insert("step".to_string(), JsonSchema::String); + plan_item_props.insert("status".to_string(), JsonSchema::String); + + let plan_items_schema = JsonSchema::Array { + items: Box::new(JsonSchema::Object { + properties: plan_item_props, + required: &["step", "status"], + additional_properties: false, + }), + }; + + let mut properties = BTreeMap::new(); + properties.insert("explanation".to_string(), JsonSchema::String); + properties.insert("plan".to_string(), plan_items_schema); + + OpenAiTool::Function(ResponsesApiTool { + name: "update_plan", + description: r#"Use the update_plan tool to keep the user updated on the current plan for the task. +After understanding the user's task, call the update_plan tool with an initial plan. An example of a plan: +1. Explore the codebase to find relevant files (status: in_progress) +2. Implement the feature in the XYZ component (status: pending) +3. Commit changes and make a pull request (status: pending) +Each step should be a short, 1-sentence description. +Until all the steps are finished, there should always be exactly one in_progress step in the plan. +Call the update_plan tool whenever you finish a step, marking the completed step as `completed` and marking the next step as `in_progress`. +Before running a command, consider whether or not you have completed the previous step, and make sure to mark it as completed before moving on to the next step. +Sometimes, you may need to change plans in the middle of a task: call `update_plan` with the updated plan and make sure to provide an `explanation` of the rationale when doing so. +When all steps are completed, call update_plan one last time with all steps marked as `completed`."#, + strict: false, + parameters: JsonSchema::Object { + properties, + required: &["plan"], + additional_properties: false, + }, + }) +}); + +/// This function doesn't do anything useful. However, it gives the model a structured way to record its plan that clients can read and render. +/// So it's the _inputs_ to this function that are useful to clients, not the outputs and neither are actually useful for the model other +/// than forcing it to come up and document a plan (TBD how that affects performance). +pub(crate) async fn handle_update_plan( + session: &Session, + arguments: String, + sub_id: String, + call_id: String, +) -> ResponseInputItem { + match parse_update_plan_arguments(arguments, &call_id) { + Ok(args) => { + let output = ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: "Plan updated".to_string(), + success: Some(true), + }, + }; + session + .send_event(Event { + id: sub_id.to_string(), + msg: EventMsg::PlanUpdate(args), + }) + .await; + output + } + Err(output) => *output, + } +} + +fn parse_update_plan_arguments( + arguments: String, + call_id: &str, +) -> Result> { + match serde_json::from_str::(&arguments) { + Ok(args) => Ok(args), + Err(e) => { + let output = ResponseInputItem::FunctionCallOutput { + call_id: call_id.to_string(), + output: FunctionCallOutputPayload { + content: format!("failed to parse function arguments: {e}"), + success: None, + }, + }; + Err(Box::new(output)) + } + } +} diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index 22bc1809f4..bc922eb0e2 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -7,7 +7,8 @@ use std::collections::HashMap; use std::fmt; use std::path::Path; use std::path::PathBuf; -use std::str::FromStr; // Added for FinalOutput Display implementation +use std::str::FromStr; +use std::time::Duration; use mcp_types::CallToolResult; use serde::Deserialize; @@ -19,6 +20,7 @@ use crate::config_types::ReasoningEffort as ReasoningEffortConfig; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::message_history::HistoryEntry; use crate::model_provider_info::ModelProviderInfo; +use crate::plan_tool::UpdatePlanArgs; /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize)] @@ -335,6 +337,8 @@ pub enum EventMsg { /// Response to GetHistoryEntryRequest. GetHistoryEntryResponse(GetHistoryEntryResponseEvent), + PlanUpdate(UpdatePlanArgs), + /// Notification that the agent is shutting down. ShutdownComplete, } @@ -411,9 +415,7 @@ pub struct AgentReasoningDeltaEvent { } #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct McpToolCallBeginEvent { - /// Identifier so this can be paired with the McpToolCallEnd event. - pub call_id: String, +pub struct McpInvocation { /// Name of the MCP server as defined in the config. pub server: String, /// Name of the tool as given by the MCP server. @@ -422,10 +424,19 @@ pub struct McpToolCallBeginEvent { pub arguments: Option, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct McpToolCallBeginEvent { + /// Identifier so this can be paired with the McpToolCallEnd event. + pub call_id: String, + pub invocation: McpInvocation, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct McpToolCallEndEvent { /// Identifier for the corresponding McpToolCallBegin that finished. pub call_id: String, + pub invocation: McpInvocation, + pub duration: Duration, /// Result of the tool call. Note this could be an error. pub result: Result, } diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 463651234c..98addffce2 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -20,8 +20,13 @@ impl Shell { return None; } - let mut result = vec![zsh.shell_path.clone(), "-c".to_string()]; - if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) { + let mut result = vec![zsh.shell_path.clone()]; + result.push("-lc".to_string()); + + let joined = strip_bash_lc(&command) + .or_else(|| shlex::try_join(command.iter().map(|s| s.as_str())).ok()); + + if let Some(joined) = joined { result.push(format!("source {} && ({joined})", zsh.zshrc_path)); } else { return None; @@ -33,6 +38,19 @@ impl Shell { } } +fn strip_bash_lc(command: &Vec) -> Option { + match command.as_slice() { + // exactly three items + [first, second, third] + // first two must be "bash", "-lc" + if first == "bash" && second == "-lc" => + { + Some(third.clone()) + } + _ => None, + } +} + #[cfg(target_os = "macos")] pub async fn default_user_shell() -> Shell { use tokio::process::Command; @@ -119,15 +137,29 @@ mod tests { let cases = vec![ ( vec!["myecho"], - vec![shell_path, "-c", "source ZSHRC_PATH && (myecho)"], + vec![shell_path, "-lc", "source ZSHRC_PATH && (myecho)"], Some("It works!\n"), ), + ( + vec!["myecho"], + vec![shell_path, "-lc", "source ZSHRC_PATH && (myecho)"], + Some("It works!\n"), + ), + ( + vec!["bash", "-c", "echo 'single' \"double\""], + vec![ + shell_path, + "-lc", + "source ZSHRC_PATH && (bash -c \"echo 'single' \\\"double\\\"\")", + ], + Some("single double\n"), + ), ( vec!["bash", "-lc", "echo 'single' \"double\""], vec![ shell_path, - "-c", - "source ZSHRC_PATH && (bash -lc \"echo 'single' \\\"double\\\"\")", + "-lc", + "source ZSHRC_PATH && (echo 'single' \"double\")", ], Some("single double\n"), ), diff --git a/codex-rs/core/tests/cli_stream.rs b/codex-rs/core/tests/cli_stream.rs index 4694ba85ed..0ab7bd0bb2 100644 --- a/codex-rs/core/tests/cli_stream.rs +++ b/codex-rs/core/tests/cli_stream.rs @@ -81,6 +81,96 @@ async fn chat_mode_stream_cli() { server.verify().await; } +/// Verify that passing `-c experimental_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() { + if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + // Start mock server which will capture the request and return a minimal + // SSE stream for a single turn. + let server = MockServer::start().await; + let sse = concat!( + "data: {\"type\":\"response.created\",\"response\":{}}\n\n", + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r1\"}}\n\n" + ); + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(sse, "text/event-stream"), + ) + .expect(1) + .mount(&server) + .await; + + // 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 custom_path = custom.path().join("instr.md"); + std::fs::write(&custom_path, marker).unwrap(); + let custom_path_str = custom_path.to_string_lossy().replace('\\', "/"); + + // Build a provider override that points at the mock server and instructs + // Codex to use the Responses API with the dummy env var. + let provider_override = format!( + "model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"responses\" }}", + server.uri() + ); + + let home = TempDir::new().unwrap(); + let mut cmd = AssertCommand::new("cargo"); + cmd.arg("run") + .arg("-p") + .arg("codex-cli") + .arg("--quiet") + .arg("--") + .arg("exec") + .arg("--skip-git-repo-check") + .arg("-c") + .arg(&provider_override) + .arg("-c") + .arg("model_provider=\"mock\"") + .arg("-c") + .arg(format!( + "experimental_instructions_file=\"{custom_path_str}\"" + )) + .arg("-C") + .arg(env!("CARGO_MANIFEST_DIR")) + .arg("hello?\n"); + cmd.env("CODEX_HOME", home.path()) + .env("OPENAI_API_KEY", "dummy") + .env("OPENAI_BASE_URL", format!("{}/v1", server.uri())); + + let output = cmd.output().unwrap(); + println!("Status: {}", output.status); + println!("Stdout:\n{}", String::from_utf8_lossy(&output.stdout)); + println!("Stderr:\n{}", String::from_utf8_lossy(&output.stderr)); + assert!(output.status.success()); + + // Inspect the captured request and verify our custom base instructions were + // included in the `instructions` field. + let request = &server.received_requests().await.unwrap()[0]; + let body = request.body_json::().unwrap(); + let instructions = body + .get("instructions") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + assert!( + instructions.contains(marker), + "instructions did not contain custom marker; got: {instructions}" + ); +} + /// Tests streaming responses through the CLI using a local SSE fixture file. /// This test: /// 1. Uses a pre-recorded SSE response fixture instead of a live server diff --git a/codex-rs/core/tests/client.rs b/codex-rs/core/tests/client.rs index 9de2d56036..67d95cb8f6 100644 --- a/codex-rs/core/tests/client.rs +++ b/codex-rs/core/tests/client.rs @@ -1,11 +1,19 @@ +use std::path::PathBuf; + +use chrono::Utc; use codex_core::Codex; use codex_core::CodexSpawnOk; use codex_core::ModelProviderInfo; +use codex_core::built_in_model_providers; use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SessionConfiguredEvent; +use codex_login::AuthDotJson; +use codex_login::AuthMode; +use codex_login::CodexAuth; +use codex_login::TokenData; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; use core_test_support::wait_for_event; @@ -48,32 +56,23 @@ async fn includes_session_id_and_model_headers_in_request() { .await; let model_provider = ModelProviderInfo { - name: "openai".into(), - base_url: format!("{}/v1", server.uri()), - // Environment variable that should exist in the test environment. - // ModelClient will return an error if the environment variable for the - // provider is not set. - env_key: Some("PATH".into()), - env_key_instructions: None, - wire_api: codex_core::WireApi::Responses, - query_params: None, - http_headers: Some( - [("originator".to_string(), "codex_cli_rs".to_string())] - .into_iter() - .collect(), - ), - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: Some(0), - stream_idle_timeout_ms: None, + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() }; // Init session let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; + let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); - let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c.clone()).await.unwrap(); + let CodexSpawnOk { codex, .. } = Codex::spawn( + config, + Some(CodexAuth::from_api_key("Test API Key".to_string())), + ctrl_c.clone(), + ) + .await + .unwrap(); codex .submit(Op::UserInput { @@ -95,15 +94,20 @@ async fn includes_session_id_and_model_headers_in_request() { // get request from the server let request = &server.received_requests().await.unwrap()[0]; - let request_body = request.headers.get("session_id").unwrap(); - let originator = request.headers.get("originator").unwrap(); + let request_session_id = request.headers.get("session_id").unwrap(); + let request_originator = request.headers.get("originator").unwrap(); + let request_authorization = request.headers.get("authorization").unwrap(); assert!(current_session_id.is_some()); assert_eq!( - request_body.to_str().unwrap(), + request_session_id.to_str().unwrap(), current_session_id.as_ref().unwrap() ); - assert_eq!(originator.to_str().unwrap(), "codex_cli_rs"); + assert_eq!(request_originator.to_str().unwrap(), "codex_cli_rs"); + assert_eq!( + request_authorization.to_str().unwrap(), + "Bearer Test API Key" + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -126,22 +130,9 @@ async fn includes_base_instructions_override_in_request() { .await; let model_provider = ModelProviderInfo { - name: "openai".into(), - base_url: format!("{}/v1", server.uri()), - // Environment variable that should exist in the test environment. - // ModelClient will return an error if the environment variable for the - // provider is not set. - env_key: Some("PATH".into()), - env_key_instructions: None, - wire_api: codex_core::WireApi::Responses, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: Some(0), - stream_idle_timeout_ms: None, + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() }; - let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home); @@ -149,7 +140,13 @@ async fn includes_base_instructions_override_in_request() { config.model_provider = model_provider; let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); - let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c.clone()).await.unwrap(); + let CodexSpawnOk { codex, .. } = Codex::spawn( + config, + Some(CodexAuth::from_api_key("Test API Key".to_string())), + ctrl_c.clone(), + ) + .await + .unwrap(); codex .submit(Op::UserInput { @@ -172,3 +169,172 @@ async fn includes_base_instructions_override_in_request() { .contains("test instructions") ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn chatgpt_auth_sends_correct_request() { + #![allow(clippy::unwrap_used)] + + if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + // Mock server + let server = MockServer::start().await; + + // First request – must NOT include `previous_response_id`. + let first = ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(sse_completed("resp1"), "text/event-stream"); + + Mock::given(method("POST")) + .and(path("/api/codex/responses")) + .respond_with(first) + .expect(1) + .mount(&server) + .await; + + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/api/codex", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + + // Init session + let codex_home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&codex_home); + config.model_provider = model_provider; + let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); + let CodexSpawnOk { codex, .. } = Codex::spawn( + config, + Some(auth_from_token("Access Token".to_string())), + ctrl_c.clone(), + ) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + let EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, .. }) = + wait_for_event(&codex, |ev| matches!(ev, EventMsg::SessionConfigured(_))).await + else { + unreachable!() + }; + + let current_session_id = Some(session_id.to_string()); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + // get request from the server + let request = &server.received_requests().await.unwrap()[0]; + let request_session_id = request.headers.get("session_id").unwrap(); + let request_originator = request.headers.get("originator").unwrap(); + let request_authorization = request.headers.get("authorization").unwrap(); + let request_body = request.body_json::().unwrap(); + + assert!(current_session_id.is_some()); + assert_eq!( + request_session_id.to_str().unwrap(), + current_session_id.as_ref().unwrap() + ); + assert_eq!(request_originator.to_str().unwrap(), "codex_cli_rs"); + assert_eq!( + request_authorization.to_str().unwrap(), + "Bearer Access Token" + ); + assert!(!request_body["store"].as_bool().unwrap()); + assert!(request_body["stream"].as_bool().unwrap()); + assert_eq!( + request_body["include"][0].as_str().unwrap(), + "reasoning.encrypted_content" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn includes_user_instructions_message_in_request() { + #![allow(clippy::unwrap_used)] + + let server = MockServer::start().await; + + let first = ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(sse_completed("resp1"), "text/event-stream"); + + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(first) + .expect(1) + .mount(&server) + .await; + + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + + let codex_home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&codex_home); + config.model_provider = model_provider; + config.user_instructions = Some("be nice".to_string()); + + let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); + let CodexSpawnOk { codex, .. } = Codex::spawn( + config, + Some(CodexAuth::from_api_key("Test API Key".to_string())), + ctrl_c.clone(), + ) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let request = &server.received_requests().await.unwrap()[0]; + let request_body = request.body_json::().unwrap(); + + assert!( + !request_body["instructions"] + .as_str() + .unwrap() + .contains("be nice") + ); + assert_eq!(request_body["input"][0]["role"], "user"); + assert!( + request_body["input"][0]["content"][0]["text"] + .as_str() + .unwrap() + .starts_with("be nice") + ); +} +fn auth_from_token(id_token: String) -> CodexAuth { + CodexAuth::new( + None, + AuthMode::ChatGPT, + PathBuf::new(), + Some(AuthDotJson { + tokens: TokenData { + id_token, + access_token: "Access Token".to_string(), + refresh_token: "test".to_string(), + account_id: None, + }, + last_refresh: Utc::now(), + openai_api_key: None, + }), + ) +} diff --git a/codex-rs/core/tests/live_agent.rs b/codex-rs/core/tests/live_agent.rs index 9895343045..95408e20e5 100644 --- a/codex-rs/core/tests/live_agent.rs +++ b/codex-rs/core/tests/live_agent.rs @@ -50,7 +50,7 @@ async fn spawn_codex() -> Result { config.model_provider.request_max_retries = Some(2); config.model_provider.stream_max_retries = Some(2); let CodexSpawnOk { codex: agent, .. } = - Codex::spawn(config, std::sync::Arc::new(Notify::new())).await?; + Codex::spawn(config, None, std::sync::Arc::new(Notify::new())).await?; Ok(agent) } diff --git a/codex-rs/core/tests/stream_no_completed.rs b/codex-rs/core/tests/stream_no_completed.rs index 8e5d83a03e..d2fc035569 100644 --- a/codex-rs/core/tests/stream_no_completed.rs +++ b/codex-rs/core/tests/stream_no_completed.rs @@ -10,6 +10,7 @@ use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; +use codex_login::CodexAuth; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture; use core_test_support::load_sse_fixture_with_id; @@ -75,7 +76,7 @@ async fn retries_on_early_close() { let model_provider = ModelProviderInfo { name: "openai".into(), - base_url: format!("{}/v1", server.uri()), + base_url: Some(format!("{}/v1", server.uri())), // Environment variable that should exist in the test environment. // ModelClient will return an error if the environment variable for the // provider is not set. @@ -89,13 +90,20 @@ async fn retries_on_early_close() { request_max_retries: Some(0), stream_max_retries: Some(1), stream_idle_timeout_ms: Some(2000), + requires_auth: false, }; let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c).await.unwrap(); + let CodexSpawnOk { codex, .. } = Codex::spawn( + config, + Some(CodexAuth::from_api_key("Test API Key".to_string())), + ctrl_c, + ) + .await + .unwrap(); codex .submit(Op::UserInput { diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index bc647c683e..54604d538b 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -1,5 +1,7 @@ +use codex_common::elapsed::format_duration; use codex_common::elapsed::format_elapsed; use codex_core::config::Config; +use codex_core::plan_tool::UpdatePlanArgs; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningDeltaEvent; @@ -10,6 +12,7 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::FileChange; +use codex_core::protocol::McpInvocation; use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::PatchApplyBeginEvent; @@ -37,11 +40,6 @@ pub(crate) struct EventProcessorWithHumanOutput { call_id_to_command: HashMap, call_id_to_patch: HashMap, - /// Tracks in-flight MCP tool calls so we can calculate duration and print - /// a concise summary when the corresponding `McpToolCallEnd` event is - /// received. - call_id_to_tool_call: HashMap, - // To ensure that --color=never is respected, ANSI escapes _must_ be added // using .style() with one of these fields. If you need a new style, add a // new field here. @@ -69,7 +67,6 @@ impl EventProcessorWithHumanOutput { ) -> Self { let call_id_to_command = HashMap::new(); let call_id_to_patch = HashMap::new(); - let call_id_to_tool_call = HashMap::new(); if with_ansi { Self { @@ -82,7 +79,6 @@ impl EventProcessorWithHumanOutput { red: Style::new().red(), green: Style::new().green(), cyan: Style::new().cyan(), - call_id_to_tool_call, show_agent_reasoning: !config.hide_agent_reasoning, answer_started: false, reasoning_started: false, @@ -99,7 +95,6 @@ impl EventProcessorWithHumanOutput { red: Style::new(), green: Style::new(), cyan: Style::new(), - call_id_to_tool_call, show_agent_reasoning: !config.hide_agent_reasoning, answer_started: false, reasoning_started: false, @@ -114,14 +109,6 @@ struct ExecCommandBegin { start_time: Instant, } -/// Metadata captured when an `McpToolCallBegin` event is received. -struct McpToolCallBegin { - /// Formatted invocation string, e.g. `server.tool({"city":"sf"})`. - invocation: String, - /// Timestamp when the call started so we can compute duration later. - start_time: Instant, -} - struct PatchApplyBegin { start_time: Instant, auto_approved: bool, @@ -291,63 +278,33 @@ impl EventProcessor for EventProcessorWithHumanOutput { println!("{}", truncated_output.style(self.dimmed)); } EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id, - server, - tool, - arguments, + call_id: _, + invocation, }) => { - // Build fully-qualified tool name: server.tool - let fq_tool_name = format!("{server}.{tool}"); - - // Format arguments as compact JSON so they fit on one line. - let args_str = arguments - .as_ref() - .map(|v: &serde_json::Value| { - serde_json::to_string(v).unwrap_or_else(|_| v.to_string()) - }) - .unwrap_or_default(); - - let invocation = if args_str.is_empty() { - format!("{fq_tool_name}()") - } else { - format!("{fq_tool_name}({args_str})") - }; - - self.call_id_to_tool_call.insert( - call_id.clone(), - McpToolCallBegin { - invocation: invocation.clone(), - start_time: Instant::now(), - }, - ); - ts_println!( self, "{} {}", "tool".style(self.magenta), - invocation.style(self.bold), + format_mcp_invocation(&invocation).style(self.bold), ); } EventMsg::McpToolCallEnd(tool_call_end_event) => { let is_success = tool_call_end_event.is_success(); - let McpToolCallEndEvent { call_id, result } = tool_call_end_event; - // Retrieve start time and invocation for duration calculation and labeling. - let info = self.call_id_to_tool_call.remove(&call_id); - - let (duration, invocation) = if let Some(McpToolCallBegin { + let McpToolCallEndEvent { + call_id: _, + result, invocation, - start_time, - .. - }) = info - { - (format!(" in {}", format_elapsed(start_time)), invocation) - } else { - (String::new(), format!("tool('{call_id}')")) - }; + duration, + } = tool_call_end_event; + + let duration = format!(" in {}", format_duration(duration)); let status_str = if is_success { "success" } else { "failed" }; let title_style = if is_success { self.green } else { self.red }; - let title = format!("{invocation} {status_str}{duration}:"); + let title = format!( + "{} {status_str}{duration}:", + format_mcp_invocation(&invocation) + ); ts_println!(self, "{}", title.style(title_style)); @@ -513,6 +470,11 @@ impl EventProcessor for EventProcessorWithHumanOutput { ts_println!(self, "model: {}", model); println!(); } + EventMsg::PlanUpdate(plan_update_event) => { + let UpdatePlanArgs { explanation, plan } = plan_update_event; + ts_println!(self, "explanation: {explanation:?}"); + ts_println!(self, "plan: {plan:?}"); + } EventMsg::GetHistoryEntryResponse(_) => { // Currently ignored in exec output. } @@ -538,3 +500,21 @@ fn format_file_change(change: &FileChange) -> &'static str { } => "M", } } + +fn format_mcp_invocation(invocation: &McpInvocation) -> String { + // Build fully-qualified tool name: server.tool + let fq_tool_name = format!("{}.{}", invocation.server, invocation.tool); + + // Format arguments as compact JSON so they fit on one line. + let args_str = invocation + .arguments + .as_ref() + .map(|v: &serde_json::Value| serde_json::to_string(v).unwrap_or_else(|_| v.to_string())) + .unwrap_or_default(); + + if args_str.is_empty() { + format!("{fq_tool_name}()") + } else { + format!("{fq_tool_name}({args_str})") + } +} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index f966d200a1..ce4d7f65cc 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -92,6 +92,20 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any ), }; + // TODO(mbolin): Take a more thoughtful approach to logging. + let default_level = "error"; + let _ = tracing_subscriber::fmt() + // Fallback to the `default_level` log filter if the environment + // variable is not set _or_ contains an invalid value + .with_env_filter( + EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new(default_level)) + .unwrap_or_else(|_| EnvFilter::new(default_level)), + ) + .with_ansi(stderr_with_ansi) + .with_writer(std::io::stderr) + .try_init(); + let sandbox_mode = if full_auto { Some(SandboxMode::WorkspaceWrite) } else if dangerously_bypass_approvals_and_sandbox { @@ -112,6 +126,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any model_provider: None, codex_linux_sandbox_exe, base_instructions: None, + include_plan_tool: None, }; // Parse `-c` overrides. let cli_kv_overrides = match config_overrides.parse_overrides() { @@ -142,20 +157,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any std::process::exit(1); } - // TODO(mbolin): Take a more thoughtful approach to logging. - let default_level = "error"; - let _ = tracing_subscriber::fmt() - // Fallback to the `default_level` log filter if the environment - // variable is not set _or_ contains an invalid value - .with_env_filter( - EnvFilter::try_from_default_env() - .or_else(|_| EnvFilter::try_new(default_level)) - .unwrap_or_else(|_| EnvFilter::new(default_level)), - ) - .with_ansi(stderr_with_ansi) - .with_writer(std::io::stderr) - .try_init(); - let CodexConversation { codex: codex_wrapper, session_configured, diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index ab92ecf616..47dbbca9fb 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -1,20 +1,152 @@ use chrono::DateTime; + use chrono::Utc; use serde::Deserialize; use serde::Serialize; +use std::env; use std::fs::OpenOptions; use std::io::Read; use std::io::Write; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; use std::path::Path; +use std::path::PathBuf; use std::process::Stdio; +use std::sync::Arc; +use std::sync::Mutex; use std::time::Duration; use tokio::process::Command; const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py"); const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; +const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; + +#[derive(Clone, Debug, PartialEq)] +pub enum AuthMode { + ApiKey, + ChatGPT, +} + +#[derive(Debug, Clone)] +pub struct CodexAuth { + pub api_key: Option, + pub mode: AuthMode, + auth_dot_json: Arc>>, + auth_file: PathBuf, +} + +impl PartialEq for CodexAuth { + fn eq(&self, other: &Self) -> bool { + self.mode == other.mode + } +} + +impl CodexAuth { + pub fn new( + api_key: Option, + mode: AuthMode, + auth_file: PathBuf, + auth_dot_json: Option, + ) -> Self { + let auth_dot_json = Arc::new(Mutex::new(auth_dot_json)); + Self { + api_key, + mode, + auth_file, + auth_dot_json, + } + } + + pub fn from_api_key(api_key: String) -> Self { + Self { + api_key: Some(api_key), + mode: AuthMode::ApiKey, + auth_file: PathBuf::new(), + auth_dot_json: Arc::new(Mutex::new(None)), + } + } + + pub async fn get_token_data(&self) -> Result { + #[expect(clippy::unwrap_used)] + let auth_dot_json = self.auth_dot_json.lock().unwrap().clone(); + + match auth_dot_json { + Some(auth_dot_json) => { + if auth_dot_json.last_refresh < Utc::now() - chrono::Duration::days(28) { + let refresh_response = tokio::time::timeout( + Duration::from_secs(60), + try_refresh_token(auth_dot_json.tokens.refresh_token.clone()), + ) + .await + .map_err(|_| { + std::io::Error::other("timed out while refreshing OpenAI API key") + })? + .map_err(std::io::Error::other)?; + + let updated_auth_dot_json = update_tokens( + &self.auth_file, + refresh_response.id_token, + refresh_response.access_token, + refresh_response.refresh_token, + ) + .await?; + + #[expect(clippy::unwrap_used)] + let mut auth_dot_json = self.auth_dot_json.lock().unwrap(); + *auth_dot_json = Some(updated_auth_dot_json); + } + Ok(auth_dot_json.tokens.clone()) + } + None => Err(std::io::Error::other("Token data is not available.")), + } + } + + pub async fn get_token(&self) -> Result { + match self.mode { + AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()), + AuthMode::ChatGPT => { + let id_token = self.get_token_data().await?.access_token; + + Ok(id_token) + } + } + } +} + +// Loads the available auth information from the auth.json or OPENAI_API_KEY environment variable. +pub fn load_auth(codex_home: &Path) -> std::io::Result> { + let auth_file = codex_home.join("auth.json"); + + let auth_dot_json = try_read_auth_json(&auth_file).ok(); + + let auth_json_api_key = auth_dot_json + .as_ref() + .and_then(|a| a.openai_api_key.clone()) + .filter(|s| !s.is_empty()); + + let openai_api_key = env::var(OPENAI_API_KEY_ENV_VAR) + .ok() + .filter(|s| !s.is_empty()) + .or(auth_json_api_key); + + if openai_api_key.is_none() && auth_dot_json.is_none() { + return Ok(None); + } + + let mode = if openai_api_key.is_some() { + AuthMode::ApiKey + } else { + AuthMode::ChatGPT + }; + + Ok(Some(CodexAuth { + api_key: openai_api_key, + mode, + auth_file, + auth_dot_json: Arc::new(Mutex::new(auth_dot_json)), + })) +} /// Run `python3 -c {{SOURCE_FOR_PYTHON_SERVER}}` with the CODEX_HOME /// environment variable set to the provided `codex_home` path. If the @@ -25,14 +157,12 @@ const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; /// If `capture_output` is true, the subprocess's output will be captured and /// recorded in memory. Otherwise, the subprocess's output will be sent to the /// current process's stdout/stderr. -pub async fn login_with_chatgpt( - codex_home: &Path, - capture_output: bool, -) -> std::io::Result { +pub async fn login_with_chatgpt(codex_home: &Path, capture_output: bool) -> std::io::Result<()> { let child = Command::new("python3") .arg("-c") .arg(SOURCE_FOR_PYTHON_SERVER) .env("CODEX_HOME", codex_home) + .env("CODEX_CLIENT_ID", CLIENT_ID) .stdin(Stdio::null()) .stdout(if capture_output { Stdio::piped() @@ -48,7 +178,7 @@ pub async fn login_with_chatgpt( let output = child.wait_with_output().await?; if output.status.success() { - try_read_openai_api_key(codex_home).await + Ok(()) } else { let stderr = String::from_utf8_lossy(&output.stderr); Err(std::io::Error::other(format!( @@ -57,65 +187,54 @@ pub async fn login_with_chatgpt( } } -/// Attempt to read the `OPENAI_API_KEY` from the `auth.json` file in the given -/// `CODEX_HOME` directory, refreshing it, if necessary. -pub async fn try_read_openai_api_key(codex_home: &Path) -> std::io::Result { - let auth_dot_json = try_read_auth_json(codex_home).await?; - Ok(auth_dot_json.openai_api_key) -} - /// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory. /// Returns the full AuthDotJson structure after refreshing if necessary. -pub async fn try_read_auth_json(codex_home: &Path) -> std::io::Result { - let auth_path = codex_home.join("auth.json"); - let mut file = std::fs::File::open(&auth_path)?; +pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result { + let mut file = std::fs::File::open(auth_file)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?; - if is_expired(&auth_dot_json) { - let refresh_response = - tokio::time::timeout(Duration::from_secs(60), try_refresh_token(&auth_dot_json)) - .await - .map_err(|_| std::io::Error::other("timed out while refreshing OpenAI API key"))? - .map_err(std::io::Error::other)?; - let mut auth_dot_json = auth_dot_json; - auth_dot_json.tokens.id_token = refresh_response.id_token; - if let Some(refresh_token) = refresh_response.refresh_token { - auth_dot_json.tokens.refresh_token = refresh_token; - } - auth_dot_json.last_refresh = Utc::now(); + Ok(auth_dot_json) +} - let mut options = OpenOptions::new(); - options.truncate(true).write(true).create(true); - #[cfg(unix)] - { - options.mode(0o600); - } - - let json_data = serde_json::to_string(&auth_dot_json)?; - { - let mut file = options.open(&auth_path)?; - file.write_all(json_data.as_bytes())?; - file.flush()?; - } - - Ok(auth_dot_json) - } else { - Ok(auth_dot_json) +async fn update_tokens( + auth_file: &Path, + id_token: String, + access_token: Option, + refresh_token: Option, +) -> std::io::Result { + let mut options = OpenOptions::new(); + options.truncate(true).write(true).create(true); + #[cfg(unix)] + { + options.mode(0o600); } + let mut auth_dot_json = try_read_auth_json(auth_file)?; + + auth_dot_json.tokens.id_token = id_token.to_string(); + if let Some(access_token) = access_token { + auth_dot_json.tokens.access_token = access_token.to_string(); + } + if let Some(refresh_token) = refresh_token { + auth_dot_json.tokens.refresh_token = refresh_token.to_string(); + } + auth_dot_json.last_refresh = Utc::now(); + + let json_data = serde_json::to_string_pretty(&auth_dot_json)?; + { + let mut file = options.open(auth_file)?; + file.write_all(json_data.as_bytes())?; + file.flush()?; + } + Ok(auth_dot_json) } -fn is_expired(auth_dot_json: &AuthDotJson) -> bool { - let last_refresh = auth_dot_json.last_refresh; - last_refresh < Utc::now() - chrono::Duration::days(28) -} - -async fn try_refresh_token(auth_dot_json: &AuthDotJson) -> std::io::Result { +async fn try_refresh_token(refresh_token: String) -> std::io::Result { let refresh_request = RefreshRequest { client_id: CLIENT_ID, grant_type: "refresh_token", - refresh_token: auth_dot_json.tokens.refresh_token.clone(), + refresh_token, scope: "openid profile email", }; @@ -150,24 +269,25 @@ struct RefreshRequest { scope: &'static str, } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] struct RefreshResponse { id_token: String, + access_token: Option, refresh_token: Option, } /// Expected structure for $CODEX_HOME/auth.json. -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] pub struct AuthDotJson { #[serde(rename = "OPENAI_API_KEY")] - pub openai_api_key: String, + pub openai_api_key: Option, pub tokens: TokenData, pub last_refresh: DateTime, } -#[derive(Deserialize, Serialize, Clone)] +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] pub struct TokenData { /// This is a JWT. pub id_token: String, @@ -177,5 +297,5 @@ pub struct TokenData { pub refresh_token: String, - pub account_id: String, + pub account_id: Option, } diff --git a/codex-rs/login/src/login_with_chatgpt.py b/codex-rs/login/src/login_with_chatgpt.py index ccb051c0af..2dbf5be58a 100644 --- a/codex-rs/login/src/login_with_chatgpt.py +++ b/codex-rs/login/src/login_with_chatgpt.py @@ -41,7 +41,6 @@ from typing import Any, Dict # for type hints REQUIRED_PORT = 1455 URL_BASE = f"http://localhost:{REQUIRED_PORT}" DEFAULT_ISSUER = "https://auth.openai.com" -DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" EXIT_CODE_WHEN_ADDRESS_ALREADY_IN_USE = 13 @@ -58,7 +57,7 @@ class TokenData: class AuthBundle: """Aggregates authentication data produced after successful OAuth flow.""" - api_key: str + api_key: str | None token_data: TokenData last_refresh: str @@ -78,12 +77,18 @@ def main() -> None: eprint("ERROR: CODEX_HOME environment variable is not set") sys.exit(1) + client_id = os.getenv("CODEX_CLIENT_ID") + if not client_id: + eprint("ERROR: CODEX_CLIENT_ID environment variable is not set") + sys.exit(1) + # Spawn server. try: httpd = _ApiKeyHTTPServer( ("127.0.0.1", REQUIRED_PORT), _ApiKeyHTTPHandler, codex_home=codex_home, + client_id=client_id, verbose=args.verbose, ) except OSError as e: @@ -157,7 +162,7 @@ class _ApiKeyHTTPHandler(http.server.BaseHTTPRequestHandler): return try: - auth_bundle, success_url = self._exchange_code_for_api_key(code) + auth_bundle, success_url = self._exchange_code(code) except Exception as exc: # noqa: BLE001 – propagate to client self.send_error(500, f"Token exchange failed: {exc}") return @@ -211,68 +216,22 @@ class _ApiKeyHTTPHandler(http.server.BaseHTTPRequestHandler): if getattr(self.server, "verbose", False): # type: ignore[attr-defined] super().log_message(fmt, *args) - def _exchange_code_for_api_key(self, code: str) -> tuple[AuthBundle, str]: - """Perform token + token-exchange to obtain an OpenAI API key. + def _obtain_api_key( + self, + token_claims: Dict[str, Any], + access_claims: Dict[str, Any], + token_data: TokenData, + ) -> tuple[str | None, str | None]: + """Obtain an API key from the auth service. - Returns (AuthBundle, success_url). + Returns (api_key, success_url) if successful, None otherwise. """ - token_endpoint = f"{self.server.issuer}/oauth/token" - - # 1. Authorization-code -> (id_token, access_token, refresh_token) - data = urllib.parse.urlencode( - { - "grant_type": "authorization_code", - "code": code, - "redirect_uri": self.server.redirect_uri, - "client_id": self.server.client_id, - "code_verifier": self.server.pkce.code_verifier, - } - ).encode() - - token_data: TokenData - - with urllib.request.urlopen( - urllib.request.Request( - token_endpoint, - data=data, - method="POST", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - ) as resp: - payload = json.loads(resp.read().decode()) - - # Extract chatgpt_account_id from id_token - id_token_parts = payload["id_token"].split(".") - if len(id_token_parts) != 3: - raise ValueError("Invalid ID token") - id_token_claims = _decode_jwt_segment(id_token_parts[1]) - auth_claims = id_token_claims.get("https://api.openai.com/auth", {}) - chatgpt_account_id = auth_claims.get("chatgpt_account_id", "") - - token_data = TokenData( - id_token=payload["id_token"], - access_token=payload["access_token"], - refresh_token=payload["refresh_token"], - account_id=chatgpt_account_id, - ) - - access_token_parts = token_data.access_token.split(".") - if len(access_token_parts) != 3: - raise ValueError("Invalid access token") - - access_token_claims = _decode_jwt_segment(access_token_parts[1]) - - token_claims = id_token_claims.get("https://api.openai.com/auth", {}) - access_claims = access_token_claims.get("https://api.openai.com/auth", {}) - org_id = token_claims.get("organization_id") - if not org_id: - raise ValueError("Missing organization in id_token claims") - project_id = token_claims.get("project_id") - if not project_id: - raise ValueError("Missing project in id_token claims") + + if not org_id or not project_id: + return (None, None) random_id = secrets.token_hex(6) @@ -292,7 +251,7 @@ class _ApiKeyHTTPHandler(http.server.BaseHTTPRequestHandler): exchanged_access_token: str with urllib.request.urlopen( urllib.request.Request( - token_endpoint, + self.server.token_endpoint, data=exchange_data, method="POST", headers={"Content-Type": "application/x-www-form-urlencoded"}, @@ -340,6 +299,65 @@ class _ApiKeyHTTPHandler(http.server.BaseHTTPRequestHandler): except Exception as exc: # pragma: no cover – best-effort only eprint(f"Unable to redeem ChatGPT subscriber API credits: {exc}") + return (exchanged_access_token, success_url) + + def _exchange_code(self, code: str) -> tuple[AuthBundle, str]: + """Perform token + token-exchange to obtain an OpenAI API key. + + Returns (AuthBundle, success_url). + """ + + # 1. Authorization-code -> (id_token, access_token, refresh_token) + data = urllib.parse.urlencode( + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": self.server.redirect_uri, + "client_id": self.server.client_id, + "code_verifier": self.server.pkce.code_verifier, + } + ).encode() + + token_data: TokenData + + with urllib.request.urlopen( + urllib.request.Request( + self.server.token_endpoint, + data=data, + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + ) as resp: + payload = json.loads(resp.read().decode()) + + # Extract chatgpt_account_id from id_token + id_token_parts = payload["id_token"].split(".") + if len(id_token_parts) != 3: + raise ValueError("Invalid ID token") + id_token_claims = _decode_jwt_segment(id_token_parts[1]) + auth_claims = id_token_claims.get("https://api.openai.com/auth", {}) + chatgpt_account_id = auth_claims.get("chatgpt_account_id", "") + + token_data = TokenData( + id_token=payload["id_token"], + access_token=payload["access_token"], + refresh_token=payload["refresh_token"], + account_id=chatgpt_account_id, + ) + + access_token_parts = token_data.access_token.split(".") + if len(access_token_parts) != 3: + raise ValueError("Invalid access token") + + access_token_claims = _decode_jwt_segment(access_token_parts[1]) + + token_claims = id_token_claims.get("https://api.openai.com/auth", {}) + access_claims = access_token_claims.get("https://api.openai.com/auth", {}) + + exchanged_access_token, success_url = self._obtain_api_key( + token_claims, access_claims, token_data + ) + # Persist refresh_token/id_token for future use (redeem credits etc.) last_refresh_str = ( datetime.datetime.now(datetime.timezone.utc) @@ -353,7 +371,7 @@ class _ApiKeyHTTPHandler(http.server.BaseHTTPRequestHandler): last_refresh=last_refresh_str, ) - return (auth_bundle, success_url) + return (auth_bundle, success_url or f"{URL_BASE}/success") def request_shutdown(self) -> None: # shutdown() must be invoked from another thread to avoid @@ -413,6 +431,7 @@ class _ApiKeyHTTPServer(http.server.HTTPServer): request_handler_class: type[http.server.BaseHTTPRequestHandler], *, codex_home: str, + client_id: str, verbose: bool = False, ) -> None: super().__init__(server_address, request_handler_class, bind_and_activate=True) @@ -422,7 +441,8 @@ class _ApiKeyHTTPServer(http.server.HTTPServer): self.verbose: bool = verbose self.issuer: str = DEFAULT_ISSUER - self.client_id: str = DEFAULT_CLIENT_ID + self.token_endpoint: str = f"{self.issuer}/oauth/token" + self.client_id: str = client_id port = server_address[1] self.redirect_uri: str = f"http://localhost:{port}/auth/callback" self.pkce: PkceCodes = _generate_pkce() @@ -581,8 +601,8 @@ def maybe_redeem_credits( granted = redeem_data.get("granted_chatgpt_subscriber_api_credits", 0) if granted and granted > 0: eprint( - f"""Thanks for being a ChatGPT {'Plus' if plan_type=='plus' else 'Pro'} subscriber! -If you haven't already redeemed, you should receive {'$5' if plan_type=='plus' else '$50'} in API credits. + f"""Thanks for being a ChatGPT {"Plus" if plan_type == "plus" else "Pro"} subscriber! +If you haven't already redeemed, you should receive {"$5" if plan_type == "plus" else "$50"} in API credits. Credits: https://platform.openai.com/settings/organization/billing/credit-grants More info: https://help.openai.com/en/articles/11381614""", diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index 488ee6a67c..19cf4db538 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -34,6 +34,7 @@ tokio = { version = "1", features = [ "signal", ] } uuid = { version = "1", features = ["serde", "v4"] } +strum_macros = "0.27.2" [dev-dependencies] assert_cmd = "2" diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 9f6f7a782d..877d0e05f7 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -50,6 +50,10 @@ pub struct CodexToolCallParam { /// The set of instructions to use instead of the default ones. #[serde(default, skip_serializing_if = "Option::is_none")] pub base_instructions: Option, + + /// Whether to include the plan tool in the conversation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub include_plan_tool: Option, } /// Custom enum mirroring [`AskForApproval`], but has an extra dependency on @@ -140,9 +144,10 @@ impl CodexToolCallParam { sandbox, config: cli_overrides, base_instructions, + include_plan_tool, } = self; - // Build the `ConfigOverrides` recognised by codex-core. + // Build the `ConfigOverrides` recognized by codex-core. let overrides = codex_core::config::ConfigOverrides { model, config_profile: profile, @@ -152,6 +157,7 @@ impl CodexToolCallParam { model_provider: None, codex_linux_sandbox_exe, base_instructions, + include_plan_tool, }; let cli_overrides = cli_overrides @@ -262,6 +268,10 @@ mod tests { "description": "Working directory for the session. If relative, it is resolved against the server process's current working directory.", "type": "string" }, + "include-plan-tool": { + "description": "Whether to include the plan tool in the conversation.", + "type": "boolean" + }, "model": { "description": "Optional override for the model name (e.g. \"o3\", \"o4-mini\").", "type": "string" diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index c3cb39c452..f25659b284 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -263,6 +263,7 @@ async fn run_codex_tool_session_inner( | EventMsg::PatchApplyBegin(_) | EventMsg::PatchApplyEnd(_) | EventMsg::GetHistoryEntryResponse(_) + | EventMsg::PlanUpdate(_) | EventMsg::ShutdownComplete => { // For now, we do not do anything extra for these // events. Note that diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index aaf67571b4..0912fed118 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -19,6 +19,7 @@ mod codex_tool_config; mod codex_tool_runner; mod exec_approval; mod json_to_toml; +mod mcp_protocol; mod message_processor; mod outgoing_message; mod patch_approval; diff --git a/codex-rs/mcp-server/src/mcp_protocol.rs b/codex-rs/mcp-server/src/mcp_protocol.rs new file mode 100644 index 0000000000..e507376c16 --- /dev/null +++ b/codex-rs/mcp-server/src/mcp_protocol.rs @@ -0,0 +1,962 @@ +use codex_core::config_types::SandboxMode; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::InputItem; +use serde::Deserialize; +use serde::Serialize; +use strum_macros::Display; +use uuid::Uuid; + +use mcp_types::RequestId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ConversationId(pub Uuid); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct MessageId(pub Uuid); + +// Requests +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCallRequest { + #[serde(rename = "jsonrpc")] + pub jsonrpc: &'static str, + pub id: RequestId, + pub method: &'static str, + pub params: ToolCallRequestParams, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "name", content = "arguments", rename_all = "camelCase")] +pub enum ToolCallRequestParams { + ConversationCreate(ConversationCreateArgs), + ConversationStream(ConversationStreamArgs), + ConversationSendMessage(ConversationSendMessageArgs), + ConversationsList(ConversationsListArgs), +} + +impl ToolCallRequestParams { + /// Wrap this request in a JSON-RPC request. + #[allow(dead_code)] + pub fn into_request(self, id: RequestId) -> ToolCallRequest { + ToolCallRequest { + jsonrpc: "2.0", + id, + method: "tools/call", + params: self, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationCreateArgs { + pub prompt: String, + pub model: String, + pub cwd: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_instructions: Option, +} + +/// Optional overrides for an existing conversation's execution context when sending a message. +/// Fields left as `None` inherit the current conversation/session settings. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationOverrides { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_instructions: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationStreamArgs { + pub conversation_id: ConversationId, +} + +/// If omitted, the message continues from the latest turn. +/// Set to resume/edit from an earlier parent message in the thread. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationSendMessageArgs { + pub conversation_id: ConversationId, + pub content: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_message_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(flatten)] + pub conversation_overrides: Option, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationsListArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +// Responses +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCallResponse { + pub request_id: RequestId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ToolCallResponseResult { + ConversationCreate(ConversationCreateResult), + ConversationStream(ConversationStreamResult), + ConversationSendMessage(ConversationSendMessageResult), + ConversationsList(ConversationsListResult), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationCreateResult { + pub conversation_id: ConversationId, + pub model: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationStreamResult {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationSendMessageResult { + pub success: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationsListResult { + pub conversations: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationSummary { + pub conversation_id: ConversationId, + pub title: String, +} + +// Notifications +#[derive(Debug, Clone, Deserialize, Display)] +pub enum ServerNotification { + InitialState(InitialStateNotificationParams), + StreamDisconnected(StreamDisconnectedNotificationParams), + CodexEvent(Box), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotificationMeta { + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitialStateNotificationParams { + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub initial_state: InitialStatePayload, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitialStatePayload { + #[serde(default)] + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StreamDisconnectedNotificationParams { + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodexEventNotificationParams { + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub msg: EventMsg, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CancelNotificationParams { + pub request_id: RequestId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +impl Serialize for ServerNotification { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeMap; + + let mut map = serializer.serialize_map(Some(2))?; + match self { + ServerNotification::CodexEvent(p) => { + map.serialize_entry("method", &format!("notifications/{}", p.msg))?; + map.serialize_entry("params", p)?; + } + ServerNotification::InitialState(p) => { + map.serialize_entry("method", "notifications/initial_state")?; + map.serialize_entry("params", p)?; + } + ServerNotification::StreamDisconnected(p) => { + map.serialize_entry("method", "notifications/stream_disconnected")?; + map.serialize_entry("params", p)?; + } + } + map.end() + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "method", content = "params", rename_all = "camelCase")] +pub enum ClientNotification { + #[serde(rename = "notifications/cancelled")] + Cancelled(CancelNotificationParams), +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +#[allow(clippy::unwrap_used)] +mod tests { + use std::path::PathBuf; + + use super::*; + use codex_core::protocol::McpInvocation; + use codex_core::protocol::McpToolCallBeginEvent; + use pretty_assertions::assert_eq; + use serde::Serialize; + use serde_json::Value; + use serde_json::json; + use uuid::uuid; + + fn to_val(v: &T) -> Value { + serde_json::to_value(v).expect("serialize to Value") + } + + // ----- Requests ----- + + #[test] + fn serialize_tool_call_request_params_conversation_create_minimal() { + let req = ToolCallRequestParams::ConversationCreate(ConversationCreateArgs { + prompt: "".into(), + model: "o3".into(), + cwd: "/repo".into(), + approval_policy: None, + sandbox: None, + config: None, + profile: None, + base_instructions: None, + }); + + let observed = to_val(&req.into_request(mcp_types::RequestId::Integer(2))); + let expected = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "conversationCreate", + "arguments": { + "prompt": "", + "model": "o3", + "cwd": "/repo" + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_tool_call_request_params_conversation_send_message_with_overrides_and_parent_message_id() + { + let req = ToolCallRequestParams::ConversationSendMessage(ConversationSendMessageArgs { + conversation_id: ConversationId(uuid!("d0f6ecbe-84a2-41c1-b23d-b20473b25eab")), + content: vec![ + InputItem::Text { text: "Hi".into() }, + InputItem::Image { + image_url: "https://example.com/cat.jpg".into(), + }, + InputItem::LocalImage { + path: "notes.txt".into(), + }, + ], + parent_message_id: Some(MessageId(uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8"))), + conversation_overrides: Some(ConversationOverrides { + model: Some("o4-mini".into()), + cwd: Some("/workdir".into()), + approval_policy: None, + sandbox: Some(SandboxMode::DangerFullAccess), + config: Some(json!({"temp": 0.2})), + profile: Some("eng".into()), + base_instructions: Some("Be terse".into()), + }), + }); + + let observed = to_val(&req.into_request(mcp_types::RequestId::Integer(2))); + let expected = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "conversationSendMessage", + "arguments": { + "conversation_id": "d0f6ecbe-84a2-41c1-b23d-b20473b25eab", + "content": [ + { "type": "text", "text": "Hi" }, + { "type": "image", "image_url": "https://example.com/cat.jpg" }, + { "type": "local_image", "path": "notes.txt" } + ], + "parent_message_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "model": "o4-mini", + "cwd": "/workdir", + "sandbox": "danger-full-access", + "config": { "temp": 0.2 }, + "profile": "eng", + "base_instructions": "Be terse" + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_tool_call_request_params_conversations_list_with_opts() { + let req = ToolCallRequestParams::ConversationsList(ConversationsListArgs { + limit: Some(50), + cursor: Some("abc".into()), + }); + + let observed = to_val(&req.into_request(RequestId::Integer(2))); + let expected = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "conversationsList", + "arguments": { + "limit": 50, + "cursor": "abc" + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_tool_call_request_params_conversation_stream() { + let req = ToolCallRequestParams::ConversationStream(ConversationStreamArgs { + conversation_id: ConversationId(uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8")), + }); + + let observed = to_val(&req.into_request(mcp_types::RequestId::Integer(2))); + let expected = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "conversationStream", + "arguments": { + "conversation_id": "67e55044-10b1-426f-9247-bb680e5fe0c8" + } + } + }); + assert_eq!(observed, expected); + } + + // ----- Message inputs / sources ----- + + #[test] + fn serialize_message_input_image_url() { + let item = InputItem::Image { + image_url: "https://example.com/x.png".into(), + }; + let observed = to_val(&item); + let expected = json!({ + "type": "image", + "image_url": "https://example.com/x.png" + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_message_input_local_image_path() { + let url = InputItem::LocalImage { + path: PathBuf::from("https://example.com/a.pdf"), + }; + let id = InputItem::LocalImage { + path: PathBuf::from("file_456"), + }; + let observed_url = to_val(&url); + let expected_url = json!({"type":"local_image","path":"https://example.com/a.pdf"}); + assert_eq!( + observed_url, expected_url, + "LocalImage with URL path should serialize as image_url" + ); + let observed_id = to_val(&id); + let expected_id = json!({"type":"local_image","path":"file_456"}); + assert_eq!( + observed_id, expected_id, + "LocalImage with file id should serialize as image_url" + ); + } + + #[test] + fn serialize_message_input_image_url_without_detail() { + let item = InputItem::Image { + image_url: "https://example.com/x.png".into(), + }; + let observed = to_val(&item); + let expected = json!({ + "type": "image", + "image_url": "https://example.com/x.png" + }); + assert_eq!(observed, expected); + } + + // ----- Responses ----- + + #[test] + fn response_success_conversation_create_full_schema() { + let env = ToolCallResponse { + request_id: RequestId::Integer(1), + is_error: None, + result: Some(ToolCallResponseResult::ConversationCreate( + ConversationCreateResult { + conversation_id: ConversationId(uuid!("d0f6ecbe-84a2-41c1-b23d-b20473b25eab")), + model: "o3".into(), + }, + )), + }; + let observed = to_val(&env); + let expected = json!({ + "requestId": 1, + "result": { + "conversation_id": "d0f6ecbe-84a2-41c1-b23d-b20473b25eab", + "model": "o3" + } + }); + assert_eq!( + observed, expected, + "response (ConversationCreate) must match" + ); + } + + #[test] + fn response_success_conversation_stream_empty_result_object() { + let env = ToolCallResponse { + request_id: RequestId::Integer(2), + is_error: None, + result: Some(ToolCallResponseResult::ConversationStream( + ConversationStreamResult {}, + )), + }; + let observed = to_val(&env); + let expected = json!({ + "requestId": 2, + "result": {} + }); + assert_eq!( + observed, expected, + "response (ConversationStream) must have empty object result" + ); + } + + #[test] + fn response_success_send_message_accepted_full_schema() { + let env = ToolCallResponse { + request_id: RequestId::Integer(3), + is_error: None, + result: Some(ToolCallResponseResult::ConversationSendMessage( + ConversationSendMessageResult { success: true }, + )), + }; + let observed = to_val(&env); + let expected = json!({ + "requestId": 3, + "result": { "success": true } + }); + assert_eq!( + observed, expected, + "response (ConversationSendMessageAccepted) must match" + ); + } + + #[test] + fn response_success_conversations_list_with_next_cursor_full_schema() { + let env = ToolCallResponse { + request_id: RequestId::Integer(4), + is_error: None, + result: Some(ToolCallResponseResult::ConversationsList( + ConversationsListResult { + conversations: vec![ConversationSummary { + conversation_id: ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + )), + title: "Refactor config loader".into(), + }], + next_cursor: Some("next123".into()), + }, + )), + }; + let observed = to_val(&env); + let expected = json!({ + "requestId": 4, + "result": { + "conversations": [ + { + "conversation_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "title": "Refactor config loader" + } + ], + "next_cursor": "next123" + } + }); + assert_eq!( + observed, expected, + "response (ConversationsList with cursor) must match" + ); + } + + #[test] + fn response_error_only_is_error_and_request_id_string() { + let env = ToolCallResponse { + request_id: RequestId::Integer(4), + is_error: Some(true), + result: None, + }; + let observed = to_val(&env); + let expected = json!({ + "requestId": 4, + "isError": true + }); + assert_eq!( + observed, expected, + "error response must omit `result` and include `isError`" + ); + } + + // ----- Notifications ----- + + #[test] + fn serialize_notification_initial_state_minimal() { + let params = InitialStateNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ))), + request_id: Some(RequestId::Integer(44)), + }), + initial_state: InitialStatePayload { + events: vec![ + CodexEventNotificationParams { + meta: None, + msg: EventMsg::TaskStarted, + }, + CodexEventNotificationParams { + meta: None, + msg: EventMsg::AgentMessageDelta( + codex_core::protocol::AgentMessageDeltaEvent { + delta: "Loading...".into(), + }, + ), + }, + ], + }, + }; + + let observed = to_val(&ServerNotification::InitialState(params.clone())); + let expected = json!({ + "method": "notifications/initial_state", + "params": { + "_meta": { + "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "requestId": 44 + }, + "initial_state": { + "events": [ + { "msg": { "type": "task_started" } }, + { "msg": { "type": "agent_message_delta", "delta": "Loading..." } } + ] + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_initial_state_omits_empty_events_full_json() { + let params = InitialStateNotificationParams { + meta: None, + initial_state: InitialStatePayload { events: vec![] }, + }; + + let observed = to_val(&ServerNotification::InitialState(params)); + let expected = json!({ + "method": "notifications/initial_state", + "params": { + "initial_state": { "events": [] } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_stream_disconnected() { + let params = StreamDisconnectedNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ))), + request_id: None, + }), + reason: "New stream() took over".into(), + }; + + let observed = to_val(&ServerNotification::StreamDisconnected(params)); + let expected = json!({ + "method": "notifications/stream_disconnected", + "params": { + "_meta": { "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8" }, + "reason": "New stream() took over" + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_uses_eventmsg_type_in_method() { + let params = CodexEventNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ))), + request_id: Some(RequestId::Integer(44)), + }), + msg: EventMsg::AgentMessage(codex_core::protocol::AgentMessageEvent { + message: "hi".into(), + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); + let expected = json!({ + "method": "notifications/agent_message", + "params": { + "_meta": { + "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "requestId": 44 + }, + "msg": { "type": "agent_message", "message": "hi" } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_task_started_full_json() { + let params = CodexEventNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ))), + request_id: Some(RequestId::Integer(7)), + }), + msg: EventMsg::TaskStarted, + }; + + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); + let expected = json!({ + "method": "notifications/task_started", + "params": { + "_meta": { + "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "requestId": 7 + }, + "msg": { "type": "task_started" } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_agent_message_delta_full_json() { + let params = CodexEventNotificationParams { + meta: None, + msg: EventMsg::AgentMessageDelta(codex_core::protocol::AgentMessageDeltaEvent { + delta: "stream...".into(), + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); + let expected = json!({ + "method": "notifications/agent_message_delta", + "params": { + "msg": { "type": "agent_message_delta", "delta": "stream..." } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_agent_message_full_json() { + let params = CodexEventNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ))), + request_id: Some(RequestId::Integer(44)), + }), + msg: EventMsg::AgentMessage(codex_core::protocol::AgentMessageEvent { + message: "hi".into(), + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); + let expected = json!({ + "method": "notifications/agent_message", + "params": { + "_meta": { + "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "requestId": 44 + }, + "msg": { "type": "agent_message", "message": "hi" } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_agent_reasoning_full_json() { + let params = CodexEventNotificationParams { + meta: None, + msg: EventMsg::AgentReasoning(codex_core::protocol::AgentReasoningEvent { + text: "thinking…".into(), + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); + let expected = json!({ + "method": "notifications/agent_reasoning", + "params": { + "msg": { "type": "agent_reasoning", "text": "thinking…" } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_token_count_full_json() { + let usage = codex_core::protocol::TokenUsage { + input_tokens: 10, + cached_input_tokens: Some(2), + output_tokens: 5, + reasoning_output_tokens: Some(1), + total_tokens: 16, + }; + let params = CodexEventNotificationParams { + meta: None, + msg: EventMsg::TokenCount(usage), + }; + + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); + let expected = json!({ + "method": "notifications/token_count", + "params": { + "msg": { + "type": "token_count", + "input_tokens": 10, + "cached_input_tokens": 2, + "output_tokens": 5, + "reasoning_output_tokens": 1, + "total_tokens": 16 + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_session_configured_full_json() { + let params = CodexEventNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ))), + request_id: None, + }), + msg: EventMsg::SessionConfigured(codex_core::protocol::SessionConfiguredEvent { + session_id: uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8"), + model: "codex-mini-latest".into(), + history_log_id: 42, + history_entry_count: 3, + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); + let expected = json!({ + "method": "notifications/session_configured", + "params": { + "_meta": { "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8" }, + "msg": { + "type": "session_configured", + "session_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "model": "codex-mini-latest", + "history_log_id": 42, + "history_entry_count": 3 + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_exec_command_begin_full_json() { + let params = CodexEventNotificationParams { + meta: None, + msg: EventMsg::ExecCommandBegin(codex_core::protocol::ExecCommandBeginEvent { + call_id: "c1".into(), + command: vec!["bash".into(), "-lc".into(), "echo hi".into()], + cwd: std::path::PathBuf::from("/work"), + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); + let expected = json!({ + "method": "notifications/exec_command_begin", + "params": { + "msg": { + "type": "exec_command_begin", + "call_id": "c1", + "command": ["bash", "-lc", "echo hi"], + "cwd": "/work" + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_mcp_tool_call_begin_full_json() { + let params = CodexEventNotificationParams { + meta: None, + msg: EventMsg::McpToolCallBegin(McpToolCallBeginEvent { + call_id: "m1".into(), + invocation: McpInvocation { + server: "calc".into(), + tool: "add".into(), + arguments: Some(json!({"a":1,"b":2})), + }, + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); + let expected = json!({ + "method": "notifications/mcp_tool_call_begin", + "params": { + "msg": { + "type": "mcp_tool_call_begin", + "call_id": "m1", + "invocation": { + "server": "calc", + "tool": "add", + "arguments": { "a": 1, "b": 2 } + } + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_patch_apply_end_full_json() { + let params = CodexEventNotificationParams { + meta: None, + msg: EventMsg::PatchApplyEnd(codex_core::protocol::PatchApplyEndEvent { + call_id: "p1".into(), + stdout: "ok".into(), + stderr: "".into(), + success: true, + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); + let expected = json!({ + "method": "notifications/patch_apply_end", + "params": { + "msg": { + "type": "patch_apply_end", + "call_id": "p1", + "stdout": "ok", + "stderr": "", + "success": true + } + } + }); + assert_eq!(observed, expected); + } + + // ----- Cancelled notifications ----- + + #[test] + fn serialize_notification_cancelled_with_reason_full_json() { + let params = CancelNotificationParams { + request_id: RequestId::String("r-123".into()), + reason: Some("user_cancelled".into()), + }; + + let observed = to_val(&ClientNotification::Cancelled(params)); + let expected = json!({ + "method": "notifications/cancelled", + "params": { + "requestId": "r-123", + "reason": "user_cancelled" + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_cancelled_without_reason_full_json() { + let params = CancelNotificationParams { + request_id: RequestId::Integer(77), + reason: None, + }; + + let observed = to_val(&ClientNotification::Cancelled(params)); + + // Check exact structure: reason must be omitted. + assert_eq!(observed["method"], "notifications/cancelled"); + assert_eq!(observed["params"]["requestId"], 77); + assert!( + observed["params"].get("reason").is_none(), + "reason must be omitted when None" + ); + } +} diff --git a/codex-rs/mcp-server/tests/interrupt.rs b/codex-rs/mcp-server/tests/interrupt.rs index cd163ea06b..313bc7afab 100644 --- a/codex-rs/mcp-server/tests/interrupt.rs +++ b/codex-rs/mcp-server/tests/interrupt.rs @@ -81,6 +81,7 @@ async fn shell_command_interruption() -> anyhow::Result<()> { sandbox: None, config: None, base_instructions: None, + include_plan_tool: None, }) .await?; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b671075ba8..6823a83a50 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5,7 +5,6 @@ use crate::file_search::FileSearchManager; use crate::get_git_diff::get_git_diff; use crate::git_warning_screen::GitWarningOutcome; use crate::git_warning_screen::GitWarningScreen; -use crate::scroll_event_helper::ScrollEventHelper; use crate::slash_command::SlashCommand; use crate::tui; use codex_core::config::Config; @@ -13,8 +12,6 @@ use codex_core::protocol::Event; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; -use crossterm::event::MouseEvent; -use crossterm::event::MouseEventKind; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -77,7 +74,6 @@ impl App<'_> { let (app_event_tx, app_event_rx) = channel(); let app_event_tx = AppEventSender::new(app_event_tx); let pending_redraw = Arc::new(AtomicBool::new(false)); - let scroll_event_helper = ScrollEventHelper::new(app_event_tx.clone()); // Spawn a dedicated thread for reading the crossterm event loop and // re-publishing the events as AppEvents, as appropriate. @@ -100,18 +96,6 @@ impl App<'_> { crossterm::event::Event::Resize(_, _) => { app_event_tx.send(AppEvent::RequestRedraw); } - crossterm::event::Event::Mouse(MouseEvent { - kind: MouseEventKind::ScrollUp, - .. - }) => { - scroll_event_helper.scroll_up(); - } - crossterm::event::Event::Mouse(MouseEvent { - kind: MouseEventKind::ScrollDown, - .. - }) => { - scroll_event_helper.scroll_down(); - } crossterm::event::Event::Paste(pasted) => { // Many terminals convert newlines to \r when // pasting, e.g. [iTerm2][]. But [tui-textarea @@ -259,9 +243,6 @@ impl App<'_> { } }; } - AppEvent::Scroll(scroll_delta) => { - self.dispatch_scroll_event(scroll_delta); - } AppEvent::Paste(text) => { self.dispatch_paste_event(text); } @@ -392,13 +373,6 @@ impl App<'_> { } } - fn dispatch_scroll_event(&mut self, scroll_delta: i32) { - match &mut self.app_state { - AppState::Chat { widget } => widget.handle_scroll_delta(scroll_delta), - AppState::GitWarning { .. } => {} - } - } - fn dispatch_codex_event(&mut self, event: Event) { match &mut self.app_state { AppState::Chat { widget } => widget.handle_codex_event(event), diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index a1f304fe42..77a600d304 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -20,10 +20,6 @@ pub(crate) enum AppEvent { /// Text pasted from the terminal clipboard. Paste(String), - /// Scroll event with a value representing the "scroll delta" as the net - /// scroll up/down events within a short time window. - Scroll(i32), - /// Request to exit the application gracefully. ExitRequest, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6a1bb526ce..b15d81f8f5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -477,6 +477,17 @@ impl ChatComposer<'_> { } } + if let Input { + key: Key::Char('u'), + ctrl: true, + alt: false, + .. + } = input + { + self.textarea.delete_line_by_head(); + return (InputResult::None, true); + } + // Normal input handling self.textarea.input(input); let text_after = self.textarea.lines().join("\n"); diff --git a/codex-rs/tui/src/cell_widget.rs b/codex-rs/tui/src/cell_widget.rs deleted file mode 100644 index 8acdc0553a..0000000000 --- a/codex-rs/tui/src/cell_widget.rs +++ /dev/null @@ -1,20 +0,0 @@ -use ratatui::prelude::*; - -/// Trait implemented by every type that can live inside the conversation -/// history list. It provides two primitives that the parent scroll-view -/// needs: how *tall* the widget is at a given width and how to render an -/// arbitrary contiguous *window* of that widget. -/// -/// The `first_visible_line` argument to [`render_window`] allows partial -/// rendering when the top of the widget is scrolled off-screen. The caller -/// guarantees that `first_visible_line + area.height as usize` never exceeds -/// the total height previously returned by [`height`]. -pub(crate) trait CellWidget { - /// Total height measured in wrapped terminal lines when drawn with the - /// given *content* width (no scrollbar column included). - fn height(&self, width: u16) -> usize; - - /// Render a *window* that starts `first_visible_line` lines below the top - /// of the widget. The window’s size is given by `area`. - fn render_window(&self, first_visible_line: usize, area: Rect, buf: &mut Buffer); -} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a896ae37bc..fde6978634 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use codex_core::codex_wrapper::CodexConversation; use codex_core::codex_wrapper::init_codex; @@ -36,8 +37,9 @@ use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::InputResult; -use crate::conversation_history_widget::ConversationHistoryWidget; use crate::exec_command::strip_bash_lc_and_escape; +use crate::history_cell::CommandOutput; +use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; use crate::user_approval_widget::ApprovalRequest; use codex_file_search::FileMatch; @@ -45,7 +47,6 @@ use codex_file_search::FileMatch; pub(crate) struct ChatWidget<'a> { app_event_tx: AppEventSender, codex_op_tx: UnboundedSender, - conversation_history: ConversationHistoryWidget, bottom_pane: BottomPane<'a>, config: Config, initial_user_message: Option, @@ -127,7 +128,6 @@ impl ChatWidget<'_> { Self { app_event_tx: app_event_tx.clone(), codex_op_tx, - conversation_history: ConversationHistoryWidget::new(), bottom_pane: BottomPane::new(BottomPaneParams { app_event_tx, has_input_focus: true, @@ -158,11 +158,9 @@ impl ChatWidget<'_> { self.bottom_pane.handle_paste(text); } - /// Emits the last entry's plain lines from conversation_history, if any. - fn emit_last_history_entry(&mut self) { - if let Some(lines) = self.conversation_history.last_entry_plain_lines() { - self.app_event_tx.send(AppEvent::InsertHistory(lines)); - } + fn add_to_history(&mut self, cell: HistoryCell) { + self.app_event_tx + .send(AppEvent::InsertHistory(cell.plain_lines())); } fn submit_user_message(&mut self, user_message: UserMessage) { @@ -198,28 +196,18 @@ impl ChatWidget<'_> { // Only show text portion in conversation history for now. if !text.is_empty() { - self.conversation_history.add_user_message(text.clone()); - self.emit_last_history_entry(); + self.add_to_history(HistoryCell::new_user_prompt(text.clone())); } - self.conversation_history.scroll_to_bottom(); } pub(crate) fn handle_codex_event(&mut self, event: Event) { let Event { id, msg } = event; match msg { EventMsg::SessionConfigured(event) => { - // Record session information at the top of the conversation. - self.conversation_history - .add_session_info(&self.config, event.clone()); - // Immediately surface the session banner / settings summary in - // scrollback so the user can review configuration (model, - // sandbox, approvals, etc.) before interacting. - self.emit_last_history_entry(); - - // Forward history metadata to the bottom pane so the chat - // composer can navigate through past messages. self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); + // Record session information at the top of the conversation. + self.add_to_history(HistoryCell::new_session_info(&self.config, event, true)); if let Some(user_message) = self.initial_user_message.take() { // If the user provided an initial message, add it to the @@ -241,9 +229,7 @@ impl ChatWidget<'_> { message }; if !full.is_empty() { - self.conversation_history - .add_agent_message(&self.config, full); - self.emit_last_history_entry(); + self.add_to_history(HistoryCell::new_agent_message(&self.config, full)); } self.request_redraw(); } @@ -270,9 +256,7 @@ impl ChatWidget<'_> { text }; if !full.is_empty() { - self.conversation_history - .add_agent_reasoning(&self.config, full); - self.emit_last_history_entry(); + self.add_to_history(HistoryCell::new_agent_reasoning(&self.config, full)); } self.request_redraw(); } @@ -293,8 +277,7 @@ impl ChatWidget<'_> { .set_token_usage(self.token_usage.clone(), self.config.model_context_window); } EventMsg::Error(ErrorEvent { message }) => { - self.conversation_history.add_error(message.clone()); - self.emit_last_history_entry(); + self.add_to_history(HistoryCell::new_error_event(message.clone())); self.bottom_pane.set_task_running(false); } EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { @@ -313,9 +296,7 @@ impl ChatWidget<'_> { .map(|r| format!("\n{r}")) .unwrap_or_default() ); - self.conversation_history.add_background_event(text); - self.emit_last_history_entry(); - self.conversation_history.scroll_to_bottom(); + self.add_to_history(HistoryCell::new_background_event(text)); let request = ApprovalRequest::Exec { id, @@ -343,11 +324,10 @@ impl ChatWidget<'_> { // prompt before they have seen *what* is being requested. // ------------------------------------------------------------------ - self.conversation_history - .add_patch_event(PatchEventType::ApprovalRequest, changes); - self.emit_last_history_entry(); - - self.conversation_history.scroll_to_bottom(); + self.add_to_history(HistoryCell::new_patch_event( + PatchEventType::ApprovalRequest, + changes, + )); // Now surface the approval request in the BottomPane as before. let request = ApprovalRequest::ApplyPatch { @@ -359,13 +339,11 @@ impl ChatWidget<'_> { self.request_redraw(); } EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id, + call_id: _, command, cwd: _, }) => { - self.conversation_history - .add_active_exec_command(call_id, command); - self.emit_last_history_entry(); + self.add_to_history(HistoryCell::new_active_exec_command(command)); self.request_redraw(); } EventMsg::PatchApplyBegin(PatchApplyBeginEvent { @@ -375,12 +353,10 @@ impl ChatWidget<'_> { }) => { // Even when a patch is auto‑approved we still display the // summary so the user can follow along. - self.conversation_history - .add_patch_event(PatchEventType::ApplyBegin { auto_approved }, changes); - self.emit_last_history_entry(); - if !auto_approved { - self.conversation_history.scroll_to_bottom(); - } + self.add_to_history(HistoryCell::new_patch_event( + PatchEventType::ApplyBegin { auto_approved }, + changes, + )); self.request_redraw(); } EventMsg::ExecCommandEnd(ExecCommandEndEvent { @@ -389,27 +365,39 @@ impl ChatWidget<'_> { stdout, stderr, }) => { - self.conversation_history - .record_completed_exec_command(call_id, stdout, stderr, exit_code); - self.request_redraw(); + self.add_to_history(HistoryCell::new_completed_exec_command( + call_id, + CommandOutput { + exit_code, + stdout, + stderr, + duration: Duration::from_secs(0), + }, + )); } EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id, - server, - tool, - arguments, + call_id: _, + invocation, }) => { - self.conversation_history - .add_active_mcp_tool_call(call_id, server, tool, arguments); - self.emit_last_history_entry(); + self.add_to_history(HistoryCell::new_active_mcp_tool_call(invocation)); self.request_redraw(); } - EventMsg::McpToolCallEnd(mcp_tool_call_end_event) => { - let success = mcp_tool_call_end_event.is_success(); - let McpToolCallEndEvent { call_id, result } = mcp_tool_call_end_event; - self.conversation_history - .record_completed_mcp_tool_call(call_id, success, result); - self.request_redraw(); + EventMsg::McpToolCallEnd(McpToolCallEndEvent { + call_id: _, + duration, + invocation, + result, + }) => { + self.add_to_history(HistoryCell::new_completed_mcp_tool_call( + 80, + invocation, + duration, + result + .as_ref() + .map(|r| r.is_error.unwrap_or(false)) + .unwrap_or(false), + result, + )); } EventMsg::GetHistoryEntryResponse(event) => { let codex_core::protocol::GetHistoryEntryResponseEvent { @@ -426,9 +414,7 @@ impl ChatWidget<'_> { self.app_event_tx.send(AppEvent::ExitRequest); } event => { - self.conversation_history - .add_background_event(format!("{event:?}")); - self.emit_last_history_entry(); + self.add_to_history(HistoryCell::new_background_event(format!("{event:?}"))); self.request_redraw(); } } @@ -445,22 +431,7 @@ impl ChatWidget<'_> { } pub(crate) fn add_diff_output(&mut self, diff_output: String) { - self.conversation_history - .add_diff_output(diff_output.clone()); - self.emit_last_history_entry(); - self.request_redraw(); - } - - pub(crate) fn handle_scroll_delta(&mut self, scroll_delta: i32) { - // If the user is trying to scroll exactly one line, we let them, but - // otherwise we assume they are trying to scroll in larger increments. - let magnified_scroll_delta = if scroll_delta == 1 { - 1 - } else { - // Play with this: perhaps it should be non-linear? - scroll_delta * 2 - }; - self.conversation_history.scroll(magnified_scroll_delta); + self.add_to_history(HistoryCell::new_diff_output(diff_output.clone())); self.request_redraw(); } diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs deleted file mode 100644 index dede0caf5f..0000000000 --- a/codex-rs/tui/src/conversation_history_widget.rs +++ /dev/null @@ -1,429 +0,0 @@ -use crate::cell_widget::CellWidget; -use crate::history_cell::CommandOutput; -use crate::history_cell::HistoryCell; -use crate::history_cell::PatchEventType; -use codex_core::config::Config; -use codex_core::protocol::FileChange; -use codex_core::protocol::SessionConfiguredEvent; -use ratatui::prelude::*; -use ratatui::style::Style; -use ratatui::widgets::*; -use serde_json::Value as JsonValue; -use std::cell::Cell as StdCell; -use std::cell::Cell; -use std::collections::HashMap; -use std::path::PathBuf; - -/// A single history entry plus its cached wrapped-line count. -struct Entry { - cell: HistoryCell, - line_count: Cell, -} - -pub struct ConversationHistoryWidget { - entries: Vec, - /// The width (in terminal cells/columns) that [`Entry::line_count`] was - /// computed for. When the available width changes we recompute counts. - cached_width: StdCell, - scroll_position: usize, - /// Number of lines the last time render_ref() was called - num_rendered_lines: StdCell, - /// The height of the viewport last time render_ref() was called - last_viewport_height: StdCell, - has_input_focus: bool, -} - -impl ConversationHistoryWidget { - pub fn new() -> Self { - Self { - entries: Vec::new(), - cached_width: StdCell::new(0), - scroll_position: usize::MAX, - num_rendered_lines: StdCell::new(0), - last_viewport_height: StdCell::new(0), - has_input_focus: false, - } - } - - /// Negative delta scrolls up; positive delta scrolls down. - pub(crate) fn scroll(&mut self, delta: i32) { - match delta.cmp(&0) { - std::cmp::Ordering::Less => self.scroll_up(-delta as u32), - std::cmp::Ordering::Greater => self.scroll_down(delta as u32), - std::cmp::Ordering::Equal => {} - } - } - - fn scroll_up(&mut self, num_lines: u32) { - // If a user is scrolling up from the "stick to bottom" mode, we need to - // map this to a specific scroll position so we can calculate the delta. - // This requires us to care about how tall the screen is. - if self.scroll_position == usize::MAX { - self.scroll_position = self - .num_rendered_lines - .get() - .saturating_sub(self.last_viewport_height.get()); - } - - self.scroll_position = self.scroll_position.saturating_sub(num_lines as usize); - } - - fn scroll_down(&mut self, num_lines: u32) { - // If we're already pinned to the bottom there's nothing to do. - if self.scroll_position == usize::MAX { - return; - } - - let viewport_height = self.last_viewport_height.get().max(1); - let num_rendered_lines = self.num_rendered_lines.get(); - - // Compute the maximum explicit scroll offset that still shows a full - // viewport. This mirrors the calculation in `scroll_page_down()` and - // in the render path. - let max_scroll = num_rendered_lines.saturating_sub(viewport_height); - - let new_pos = self.scroll_position.saturating_add(num_lines as usize); - - if new_pos >= max_scroll { - // Reached (or passed) the bottom – switch to stick‑to‑bottom mode - // so that additional output keeps the view pinned automatically. - self.scroll_position = usize::MAX; - } else { - self.scroll_position = new_pos; - } - } - - pub fn scroll_to_bottom(&mut self) { - self.scroll_position = usize::MAX; - } - - /// Note `model` could differ from `config.model` if the agent decided to - /// use a different model than the one requested by the user. - pub fn add_session_info(&mut self, config: &Config, event: SessionConfiguredEvent) { - // In practice, SessionConfiguredEvent should always be the first entry - // in the history, but it is possible that an error could be sent - // before the session info. - let has_welcome_message = self - .entries - .iter() - .any(|entry| matches!(entry.cell, HistoryCell::WelcomeMessage { .. })); - self.add_to_history(HistoryCell::new_session_info( - config, - event, - !has_welcome_message, - )); - } - - pub fn add_user_message(&mut self, message: String) { - self.add_to_history(HistoryCell::new_user_prompt(message)); - } - - pub fn add_agent_message(&mut self, config: &Config, message: String) { - self.add_to_history(HistoryCell::new_agent_message(config, message)); - } - - pub fn add_agent_reasoning(&mut self, config: &Config, text: String) { - self.add_to_history(HistoryCell::new_agent_reasoning(config, text)); - } - - pub fn add_background_event(&mut self, message: String) { - self.add_to_history(HistoryCell::new_background_event(message)); - } - - pub fn add_diff_output(&mut self, diff_output: String) { - self.add_to_history(HistoryCell::new_diff_output(diff_output)); - } - - pub fn add_error(&mut self, message: String) { - self.add_to_history(HistoryCell::new_error_event(message)); - } - - /// Add a pending patch entry (before user approval). - pub fn add_patch_event( - &mut self, - event_type: PatchEventType, - changes: HashMap, - ) { - self.add_to_history(HistoryCell::new_patch_event(event_type, changes)); - } - - pub fn add_active_exec_command(&mut self, call_id: String, command: Vec) { - self.add_to_history(HistoryCell::new_active_exec_command(call_id, command)); - } - - pub fn add_active_mcp_tool_call( - &mut self, - call_id: String, - server: String, - tool: String, - arguments: Option, - ) { - self.add_to_history(HistoryCell::new_active_mcp_tool_call( - call_id, server, tool, arguments, - )); - } - - fn add_to_history(&mut self, cell: HistoryCell) { - let width = self.cached_width.get(); - let count = if width > 0 { cell.height(width) } else { 0 }; - - self.entries.push(Entry { - cell, - line_count: Cell::new(count), - }); - } - - /// Return the lines for the most recently appended entry (if any) so the - /// parent widget can surface them via the new scrollback insertion path. - pub(crate) fn last_entry_plain_lines(&self) -> Option>> { - self.entries.last().map(|e| e.cell.plain_lines()) - } - - pub fn record_completed_exec_command( - &mut self, - call_id: String, - stdout: String, - stderr: String, - exit_code: i32, - ) { - let width = self.cached_width.get(); - for entry in self.entries.iter_mut() { - let cell = &mut entry.cell; - if let HistoryCell::ActiveExecCommand { - call_id: history_id, - command, - start, - .. - } = cell - { - if &call_id == history_id { - *cell = HistoryCell::new_completed_exec_command( - command.clone(), - CommandOutput { - exit_code, - stdout, - stderr, - duration: start.elapsed(), - }, - ); - - // Update cached line count. - if width > 0 { - entry.line_count.set(cell.height(width)); - } - break; - } - } - } - } - - pub fn record_completed_mcp_tool_call( - &mut self, - call_id: String, - success: bool, - result: Result, - ) { - let width = self.cached_width.get(); - for entry in self.entries.iter_mut() { - if let HistoryCell::ActiveMcpToolCall { - call_id: history_id, - invocation, - start, - .. - } = &entry.cell - { - if &call_id == history_id { - let completed = HistoryCell::new_completed_mcp_tool_call( - width, - invocation.clone(), - *start, - success, - result, - ); - entry.cell = completed; - - if width > 0 { - entry.line_count.set(entry.cell.height(width)); - } - - break; - } - } - } - } -} - -impl WidgetRef for ConversationHistoryWidget { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let (title, border_style) = if self.has_input_focus { - ( - "Messages (↑/↓ or j/k = line, b/space = page)", - Style::default().fg(Color::LightYellow), - ) - } else { - ("Messages (tab to focus)", Style::default().dim()) - }; - - let block = Block::default() - .title(title) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(border_style); - - // Compute the inner area that will be available for the list after - // the surrounding `Block` is drawn. - let inner = block.inner(area); - let viewport_height = inner.height as usize; - - // Cache (and if necessary recalculate) the wrapped line counts for every - // [`HistoryCell`] so that our scrolling math accounts for text - // wrapping. We always reserve one column on the right-hand side for the - // scrollbar so that the content never renders "under" the scrollbar. - let effective_width = inner.width.saturating_sub(1); - - if effective_width == 0 { - return; // Nothing to draw – avoid division by zero. - } - - // Recompute cache if the effective width changed. - let num_lines: usize = if self.cached_width.get() != effective_width { - self.cached_width.set(effective_width); - - let mut num_lines: usize = 0; - for entry in &self.entries { - let count = entry.cell.height(effective_width); - num_lines += count; - entry.line_count.set(count); - } - num_lines - } else { - self.entries.iter().map(|e| e.line_count.get()).sum() - }; - - // Determine the scroll position. Note the existing value of - // `self.scroll_position` could exceed the maximum scroll offset if the - // user made the window wider since the last render. - let max_scroll = num_lines.saturating_sub(viewport_height); - let scroll_pos = if self.scroll_position == usize::MAX { - max_scroll - } else { - self.scroll_position.min(max_scroll) - }; - - // ------------------------------------------------------------------ - // Render order: - // 1. Clear full widget area (avoid artifacts from prior frame). - // 2. Draw the surrounding Block (border and title). - // 3. Render *each* visible HistoryCell into its own sub-Rect while - // respecting partial visibility at the top and bottom. - // 4. Draw the scrollbar track / thumb in the reserved column. - // ------------------------------------------------------------------ - - // Clear entire widget area first. - Clear.render(area, buf); - - // Draw border + title. - block.render(area, buf); - - // ------------------------------------------------------------------ - // Calculate which cells are visible for the current scroll position - // and paint them one by one. - // ------------------------------------------------------------------ - - let mut y_cursor = inner.y; // first line inside viewport - let mut remaining_height = inner.height as usize; - let mut lines_to_skip = scroll_pos; // number of wrapped lines to skip (above viewport) - - for entry in &self.entries { - let cell_height = entry.line_count.get(); - - // Completely above viewport? Skip whole cell. - if lines_to_skip >= cell_height { - lines_to_skip -= cell_height; - continue; - } - - // Determine how much of this cell is visible. - let visible_height = (cell_height - lines_to_skip).min(remaining_height); - - if visible_height == 0 { - break; // no space left - } - - let cell_rect = Rect { - x: inner.x, - y: y_cursor, - width: effective_width, - height: visible_height as u16, - }; - - entry.cell.render_window(lines_to_skip, cell_rect, buf); - - // Advance cursor inside viewport. - y_cursor += visible_height as u16; - remaining_height -= visible_height; - - // After the first (possibly partially skipped) cell, we no longer - // need to skip lines at the top. - lines_to_skip = 0; - - if remaining_height == 0 { - break; // viewport filled - } - } - - // Always render a scrollbar *track* so the reserved column is filled. - let overflow = num_lines.saturating_sub(viewport_height); - - let mut scroll_state = ScrollbarState::default() - // The Scrollbar widget expects the *content* height minus the - // viewport height. When there is no overflow we still provide 0 - // so that the widget renders only the track without a thumb. - .content_length(overflow) - .position(scroll_pos); - - { - // Choose a thumb color that stands out only when this pane has focus so that the - // user's attention is naturally drawn to the active viewport. When unfocused we show - // a low-contrast thumb so the scrollbar fades into the background without becoming - // invisible. - let thumb_style = if self.has_input_focus { - Style::reset().fg(Color::LightYellow) - } else { - Style::reset().fg(Color::Gray) - }; - - // By default the Scrollbar widget inherits any style that was - // present in the underlying buffer cells. That means if a colored - // line happens to be underneath the scrollbar, the track (and - // potentially the thumb) adopt that color. Explicitly setting the - // track/thumb styles ensures we always draw the scrollbar with a - // consistent palette regardless of what content is behind it. - StatefulWidget::render( - Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(Some("↑")) - .end_symbol(Some("↓")) - .begin_style(Style::reset().fg(Color::DarkGray)) - .end_style(Style::reset().fg(Color::DarkGray)) - .thumb_symbol("█") - .thumb_style(thumb_style) - .track_symbol(Some("│")) - .track_style(Style::reset().fg(Color::DarkGray)), - inner, - buf, - &mut scroll_state, - ); - } - - // Update auxiliary stats that the scroll handlers rely on. - self.num_rendered_lines.set(num_lines); - self.last_viewport_height.set(viewport_height); - } -} - -/// Common [`Wrap`] configuration used for both measurement and rendering so -/// they stay in sync. -#[inline] -pub(crate) const fn wrap_cfg() -> ratatui::widgets::Wrap { - ratatui::widgets::Wrap { trim: false } -} diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index ab657163ad..04279a01f8 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1,4 +1,3 @@ -use crate::cell_widget::CellWidget; use crate::exec_command::escape_command; use crate::markdown::append_markdown; use crate::text_block::TextBlock; @@ -11,11 +10,10 @@ use codex_core::WireApi; use codex_core::config::Config; use codex_core::model_supports_reasoning_summaries; use codex_core::protocol::FileChange; +use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; use image::DynamicImage; -use image::GenericImageView; use image::ImageReader; -use lazy_static::lazy_static; use mcp_types::EmbeddedResourceResource; use mcp_types::ResourceLink; use ratatui::prelude::*; @@ -24,14 +22,10 @@ use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::text::Line as RtLine; use ratatui::text::Span as RtSpan; -use ratatui_image::Image as TuiImage; -use ratatui_image::Resize as ImgResize; -use ratatui_image::picker::ProtocolType; use std::collections::HashMap; use std::io::Cursor; use std::path::PathBuf; use std::time::Duration; -use std::time::Instant; use tracing::error; pub(crate) struct CommandOutput { @@ -46,6 +40,21 @@ pub(crate) enum PatchEventType { ApplyBegin { auto_approved: bool }, } +fn span_to_static(span: &Span) -> Span<'static> { + Span { + style: span.style, + content: std::borrow::Cow::Owned(span.content.clone().into_owned()), + } +} + +fn line_to_static(line: &Line) -> Line<'static> { + Line { + style: line.style, + alignment: line.alignment, + spans: line.spans.iter().map(span_to_static).collect(), + } +} + /// Represents an event to display in the conversation history. Returns its /// `Vec>` representation to make it easier to display in a /// scrollable list. @@ -63,25 +72,13 @@ pub(crate) enum HistoryCell { AgentReasoning { view: TextBlock }, /// An exec tool call that has not finished yet. - ActiveExecCommand { - call_id: String, - /// The shell command, escaped and formatted. - command: String, - start: Instant, - view: TextBlock, - }, + ActiveExecCommand { view: TextBlock }, /// Completed exec tool call. CompletedExecCommand { view: TextBlock }, /// An MCP tool call that has not finished yet. - ActiveMcpToolCall { - call_id: String, - /// Formatted line that shows the command name and arguments - invocation: Line<'static>, - start: Instant, - view: TextBlock, - }, + ActiveMcpToolCall { view: TextBlock }, /// Completed MCP tool call where we show the result serialized as JSON. CompletedMcpToolCall { view: TextBlock }, @@ -94,13 +91,7 @@ pub(crate) enum HistoryCell { // resized version avoids doing the potentially expensive rescale twice // because the scroll-view first calls `height()` for layouting and then // `render_window()` for painting. - CompletedMcpToolCallWithImageOutput { - image: DynamicImage, - /// Cached data derived from the current terminal width. The cache is - /// invalidated whenever the width changes (e.g. when the user - /// resizes the window). - render_cache: std::cell::RefCell>, - }, + CompletedMcpToolCallWithImageOutput { _image: DynamicImage }, /// Background event. BackgroundEvent { view: TextBlock }, @@ -140,7 +131,9 @@ impl HistoryCell { | HistoryCell::CompletedMcpToolCall { view } | HistoryCell::PendingPatch { view } | HistoryCell::ActiveExecCommand { view, .. } - | HistoryCell::ActiveMcpToolCall { view, .. } => view.lines.clone(), + | HistoryCell::ActiveMcpToolCall { view, .. } => { + view.lines.iter().map(line_to_static).collect() + } HistoryCell::CompletedMcpToolCallWithImageOutput { .. } => vec![ Line::from("tool result (image output omitted)"), Line::from(""), @@ -252,9 +245,8 @@ impl HistoryCell { } } - pub(crate) fn new_active_exec_command(call_id: String, command: Vec) -> Self { + pub(crate) fn new_active_exec_command(command: Vec) -> Self { let command_escaped = escape_command(&command); - let start = Instant::now(); let lines: Vec> = vec![ Line::from(vec!["command".magenta(), " running...".dim()]), @@ -263,9 +255,6 @@ impl HistoryCell { ]; HistoryCell::ActiveExecCommand { - call_id, - command: command_escaped, - start, view: TextBlock::new(lines), } } @@ -310,41 +299,15 @@ impl HistoryCell { } } - pub(crate) fn new_active_mcp_tool_call( - call_id: String, - server: String, - tool: String, - arguments: Option, - ) -> Self { - // Format the arguments as compact JSON so they roughly fit on one - // line. If there are no arguments we keep it empty so the invocation - // mirrors a function-style call. - let args_str = arguments - .as_ref() - .map(|v| { - // Use compact form to keep things short but readable. - serde_json::to_string(v).unwrap_or_else(|_| v.to_string()) - }) - .unwrap_or_default(); - - let invocation_spans = vec![ - Span::styled(server, Style::default().fg(Color::Blue)), - Span::raw("."), - Span::styled(tool, Style::default().fg(Color::Blue)), - Span::raw("("), - Span::styled(args_str, Style::default().fg(Color::Gray)), - Span::raw(")"), - ]; - let invocation = Line::from(invocation_spans); - - let start = Instant::now(); + pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> Self { let title_line = Line::from(vec!["tool".magenta(), " running...".dim()]); - let lines: Vec> = vec![title_line, invocation.clone(), Line::from("")]; + let lines: Vec = vec![ + title_line, + format_mcp_invocation(invocation.clone()), + Line::from(""), + ]; HistoryCell::ActiveMcpToolCall { - call_id, - invocation, - start, view: TextBlock::new(lines), } } @@ -382,10 +345,7 @@ impl HistoryCell { } }; - Some(HistoryCell::CompletedMcpToolCallWithImageOutput { - image, - render_cache: std::cell::RefCell::new(None), - }) + Some(HistoryCell::CompletedMcpToolCallWithImageOutput { _image: image }) } else { None } @@ -396,8 +356,8 @@ impl HistoryCell { pub(crate) fn new_completed_mcp_tool_call( num_cols: u16, - invocation: Line<'static>, - start: Instant, + invocation: McpInvocation, + duration: Duration, success: bool, result: Result, ) -> Self { @@ -405,7 +365,7 @@ impl HistoryCell { return cell; } - let duration = format_duration(start.elapsed()); + let duration = format_duration(duration); let status_str = if success { "success" } else { "failed" }; let title_line = Line::from(vec![ "tool".magenta(), @@ -420,7 +380,7 @@ impl HistoryCell { let mut lines: Vec> = Vec::new(); lines.push(title_line); - lines.push(invocation); + lines.push(format_mcp_invocation(invocation)); match result { Ok(mcp_types::CallToolResult { content, .. }) => { @@ -581,85 +541,6 @@ impl HistoryCell { } } -// --------------------------------------------------------------------------- -// `CellWidget` implementation – most variants delegate to their internal -// `TextBlock`. Variants that need custom painting can add their own logic in -// the match arms. -// --------------------------------------------------------------------------- - -impl CellWidget for HistoryCell { - fn height(&self, width: u16) -> usize { - match self { - HistoryCell::WelcomeMessage { view } - | HistoryCell::UserPrompt { view } - | HistoryCell::AgentMessage { view } - | HistoryCell::AgentReasoning { view } - | HistoryCell::BackgroundEvent { view } - | HistoryCell::GitDiffOutput { view } - | HistoryCell::ErrorEvent { view } - | HistoryCell::SessionInfo { view } - | HistoryCell::CompletedExecCommand { view } - | HistoryCell::CompletedMcpToolCall { view } - | HistoryCell::PendingPatch { view } - | HistoryCell::ActiveExecCommand { view, .. } - | HistoryCell::ActiveMcpToolCall { view, .. } => view.height(width), - HistoryCell::CompletedMcpToolCallWithImageOutput { - image, - render_cache, - } => ensure_image_cache(image, width, render_cache), - } - } - - fn render_window(&self, first_visible_line: usize, area: Rect, buf: &mut Buffer) { - match self { - HistoryCell::WelcomeMessage { view } - | HistoryCell::UserPrompt { view } - | HistoryCell::AgentMessage { view } - | HistoryCell::AgentReasoning { view } - | HistoryCell::BackgroundEvent { view } - | HistoryCell::GitDiffOutput { view } - | HistoryCell::ErrorEvent { view } - | HistoryCell::SessionInfo { view } - | HistoryCell::CompletedExecCommand { view } - | HistoryCell::CompletedMcpToolCall { view } - | HistoryCell::PendingPatch { view } - | HistoryCell::ActiveExecCommand { view, .. } - | HistoryCell::ActiveMcpToolCall { view, .. } => { - view.render_window(first_visible_line, area, buf) - } - HistoryCell::CompletedMcpToolCallWithImageOutput { - image, - render_cache, - } => { - // Ensure we have a cached, resized copy that matches the current width. - // `height()` should have prepared the cache, but if something invalidated it - // (e.g. the first `render_window()` call happens *before* `height()` after a - // resize) we rebuild it here. - - let width_cells = area.width; - - // Ensure the cache is up-to-date and extract the scaled image. - let _ = ensure_image_cache(image, width_cells, render_cache); - - let Some(resized) = render_cache - .borrow() - .as_ref() - .map(|c| c.scaled_image.clone()) - else { - return; - }; - - let picker = &*TERMINAL_PICKER; - - if let Ok(protocol) = picker.new_protocol(resized, area, ImgResize::Fit(None)) { - let img_widget = TuiImage::new(&protocol); - img_widget.render(area, buf); - } - } - } - } -} - fn create_diff_summary(changes: HashMap) -> Vec { // Build a concise, human‑readable summary list similar to the // `git status` short format so the user can reason about the @@ -692,119 +573,23 @@ fn create_diff_summary(changes: HashMap) -> Vec { summaries } -// ------------------------------------- -// Helper types for image rendering -// ------------------------------------- +fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { + let args_str = invocation + .arguments + .as_ref() + .map(|v| { + // Use compact form to keep things short but readable. + serde_json::to_string(v).unwrap_or_else(|_| v.to_string()) + }) + .unwrap_or_default(); -/// Cached information for rendering an image inside a conversation cell. -/// -/// The cache ties the resized image to a *specific* content width (in -/// terminal cells). Whenever the terminal is resized and the width changes -/// we need to re-compute the scaled variant so that it still fits the -/// available space. Keeping the resized copy around saves a costly rescale -/// between the back-to-back `height()` and `render_window()` calls that the -/// scroll-view performs while laying out the UI. -pub(crate) struct ImageRenderCache { - /// Width in *terminal cells* the cached image was generated for. - width_cells: u16, - /// Height in *terminal rows* that the conversation cell must occupy so - /// the whole image becomes visible. - height_rows: usize, - /// The resized image that fits the given width / height constraints. - scaled_image: DynamicImage, -} - -lazy_static! { - static ref TERMINAL_PICKER: ratatui_image::picker::Picker = { - use ratatui_image::picker::Picker; - use ratatui_image::picker::cap_parser::QueryStdioOptions; - - // Ask the terminal for capabilities and explicit font size. Request the - // Kitty *text-sizing protocol* as a fallback mechanism for terminals - // (like iTerm2) that do not reply to the standard CSI 16/18 queries. - match Picker::from_query_stdio_with_options(QueryStdioOptions { - text_sizing_protocol: true, - }) { - Ok(picker) => picker, - Err(err) => { - // Fall back to the conservative default that assumes ~8×16 px cells. - // Still better than breaking the build in a headless test run. - tracing::warn!("terminal capability query failed: {err:?}; using default font size"); - Picker::from_fontsize((8, 16)) - } - } - }; -} - -/// Resize `image` to fit into `width_cells`×10-rows keeping the original aspect -/// ratio. The function updates `render_cache` and returns the number of rows -/// (<= 10) the picture will occupy. -fn ensure_image_cache( - image: &DynamicImage, - width_cells: u16, - render_cache: &std::cell::RefCell>, -) -> usize { - if let Some(cache) = render_cache.borrow().as_ref() { - if cache.width_cells == width_cells { - return cache.height_rows; - } - } - - let picker = &*TERMINAL_PICKER; - let (char_w_px, char_h_px) = picker.font_size(); - - // Heuristic to compensate for Hi-DPI terminals (iTerm2 on Retina Mac) that - // report logical pixels (≈ 8×16) while the iTerm2 graphics protocol - // expects *device* pixels. Empirically the device-pixel-ratio is almost - // always 2 on macOS Retina panels. - let hidpi_scale = if picker.protocol_type() == ProtocolType::Iterm2 { - 2.0f64 - } else { - 1.0 - }; - - // The fallback Halfblocks protocol encodes two pixel rows per cell, so each - // terminal *row* represents only half the (possibly scaled) font height. - let effective_char_h_px: f64 = if picker.protocol_type() == ProtocolType::Halfblocks { - (char_h_px as f64) * hidpi_scale / 2.0 - } else { - (char_h_px as f64) * hidpi_scale - }; - - let char_w_px_f64 = (char_w_px as f64) * hidpi_scale; - - const MAX_ROWS: f64 = 10.0; - let max_height_px: f64 = effective_char_h_px * MAX_ROWS; - - let (orig_w_px, orig_h_px) = { - let (w, h) = image.dimensions(); - (w as f64, h as f64) - }; - - if orig_w_px == 0.0 || orig_h_px == 0.0 || width_cells == 0 { - *render_cache.borrow_mut() = None; - return 0; - } - - let max_w_px = char_w_px_f64 * width_cells as f64; - let scale_w = max_w_px / orig_w_px; - let scale_h = max_height_px / orig_h_px; - let scale = scale_w.min(scale_h).min(1.0); - - use image::imageops::FilterType; - let scaled_w_px = (orig_w_px * scale).round().max(1.0) as u32; - let scaled_h_px = (orig_h_px * scale).round().max(1.0) as u32; - - let scaled_image = image.resize(scaled_w_px, scaled_h_px, FilterType::Lanczos3); - - let height_rows = ((scaled_h_px as f64 / effective_char_h_px).ceil()) as usize; - - let new_cache = ImageRenderCache { - width_cells, - height_rows, - scaled_image, - }; - *render_cache.borrow_mut() = Some(new_cache); - - height_rows + let invocation_spans = vec![ + Span::styled(invocation.server.clone(), Style::default().fg(Color::Blue)), + Span::raw("."), + Span::styled(invocation.tool.clone(), Style::default().fg(Color::Blue)), + Span::raw("("), + Span::styled(args_str, Style::default().fg(Color::Gray)), + Span::raw(")"), + ]; + Line::from(invocation_spans) } diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 1e8b1f5392..32d0b4b297 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -21,7 +21,7 @@ use ratatui::text::Line; use ratatui::text::Span; /// Insert `lines` above the viewport. -pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec>) { +pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec) { let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0)); let mut area = terminal.get_frame().area(); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 1f660b1aaf..424b5ac2fc 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -6,16 +6,14 @@ use app::App; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config_types::SandboxMode; -use codex_core::openai_api_key::OPENAI_API_KEY_ENV_VAR; -use codex_core::openai_api_key::get_openai_api_key; -use codex_core::openai_api_key::set_openai_api_key; use codex_core::protocol::AskForApproval; use codex_core::util::is_inside_git_repo; -use codex_login::try_read_openai_api_key; +use codex_login::load_auth; use log_layer::TuiLogLayer; use std::fs::OpenOptions; use std::io::Write; use std::path::PathBuf; +use tracing::error; use tracing_appender::non_blocking; use tracing_subscriber::EnvFilter; use tracing_subscriber::prelude::*; @@ -24,11 +22,9 @@ mod app; mod app_event; mod app_event_sender; mod bottom_pane; -mod cell_widget; mod chatwidget; mod citation_regex; mod cli; -mod conversation_history_widget; mod exec_command; mod file_search; mod get_git_diff; @@ -37,7 +33,6 @@ mod history_cell; mod insert_history; mod log_layer; mod markdown; -mod scroll_event_helper; mod slash_command; mod status_indicator_widget; mod text_block; @@ -79,6 +74,7 @@ pub async fn run_main( config_profile: cli.config_profile.clone(), codex_linux_sandbox_exe, base_instructions: None, + include_plan_tool: None, }; // Parse `-c` overrides from the CLI. let cli_kv_overrides = match cli.config_overrides.parse_overrides() { @@ -142,24 +138,22 @@ pub async fn run_main( .with(tui_layer) .try_init(); - let show_login_screen = should_show_login_screen(&config).await; + let show_login_screen = should_show_login_screen(&config); if show_login_screen { - std::io::stdout().write_all( - b"Oh dear, we don't seem to have an API key.\nTerribly sorry, but may I open a browser window for you to log in? [Yn] ", - )?; + std::io::stdout() + .write_all(b"No API key detected.\nLogin with your ChatGPT account? [Yn] ")?; std::io::stdout().flush()?; let mut input = String::new(); std::io::stdin().read_line(&mut input)?; let trimmed = input.trim(); if !(trimmed.is_empty() || trimmed.eq_ignore_ascii_case("y")) { - std::io::stdout().write_all(b"Right-o, fair enough. See you next time!\n")?; std::process::exit(1); } // Spawn a task to run the login command. // Block until the login command is finished. - let new_key = codex_login::login_with_chatgpt(&config.codex_home, false).await?; - set_openai_api_key(new_key); - std::io::stdout().write_all(b"Excellent, looks like that worked. Let's get started!\n")?; + codex_login::login_with_chatgpt(&config.codex_home, false).await?; + + std::io::stdout().write_all(b"Login successful.\n")?; } // Determine whether we need to display the "not a git repo" warning @@ -221,28 +215,21 @@ fn restore() { } } -async fn should_show_login_screen(config: &Config) -> bool { - if is_in_need_of_openai_api_key(config) { +#[allow(clippy::unwrap_used)] +fn should_show_login_screen(config: &Config) -> bool { + if config.model_provider.requires_auth { // Reading the OpenAI API key is an async operation because it may need // to refresh the token. Block on it. let codex_home = config.codex_home.clone(); - if let Ok(openai_api_key) = try_read_openai_api_key(&codex_home).await { - set_openai_api_key(openai_api_key); - false - } else { - true + match load_auth(&codex_home) { + Ok(Some(_)) => false, + Ok(None) => true, + Err(err) => { + error!("Failed to read auth.json: {err}"); + true + } } } else { false } } - -fn is_in_need_of_openai_api_key(config: &Config) -> bool { - let is_using_openai_key = config - .model_provider - .env_key - .as_ref() - .map(|s| s == OPENAI_API_KEY_ENV_VAR) - .unwrap_or(false); - is_using_openai_key && get_openai_api_key().is_none() -} diff --git a/codex-rs/tui/src/scroll_event_helper.rs b/codex-rs/tui/src/scroll_event_helper.rs deleted file mode 100644 index ad3ae37e0d..0000000000 --- a/codex-rs/tui/src/scroll_event_helper.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::sync::Arc; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::AtomicI32; -use std::sync::atomic::Ordering; - -use tokio::runtime::Handle; -use tokio::time::Duration; -use tokio::time::sleep; - -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; - -pub(crate) struct ScrollEventHelper { - app_event_tx: AppEventSender, - scroll_delta: Arc, - timer_scheduled: Arc, - runtime: Handle, -} - -/// How long to wait after the first scroll event before sending the -/// accumulated scroll delta to the main thread. -const DEBOUNCE_WINDOW: Duration = Duration::from_millis(100); - -/// Utility to debounce scroll events so we can determine the **magnitude** of -/// each scroll burst by accumulating individual wheel events over a short -/// window. The debounce timer now runs on Tokio so we avoid spinning up a new -/// operating-system thread for every burst. -impl ScrollEventHelper { - pub(crate) fn new(app_event_tx: AppEventSender) -> Self { - Self { - app_event_tx, - scroll_delta: Arc::new(AtomicI32::new(0)), - timer_scheduled: Arc::new(AtomicBool::new(false)), - runtime: Handle::current(), - } - } - - pub(crate) fn scroll_up(&self) { - self.scroll_delta.fetch_sub(1, Ordering::Relaxed); - self.schedule_notification(); - } - - pub(crate) fn scroll_down(&self) { - self.scroll_delta.fetch_add(1, Ordering::Relaxed); - self.schedule_notification(); - } - - /// Starts a one-shot timer **only once** per burst of wheel events. - fn schedule_notification(&self) { - // If the timer is already scheduled, do nothing. - if self - .timer_scheduled - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .is_err() - { - return; - } - - // Otherwise, schedule a new timer. - let tx = self.app_event_tx.clone(); - let delta = Arc::clone(&self.scroll_delta); - let timer_flag = Arc::clone(&self.timer_scheduled); - - // Use self.runtime instead of tokio::spawn() because the calling thread - // in app.rs is not part of the Tokio runtime: it is a plain OS thread. - self.runtime.spawn(async move { - sleep(DEBOUNCE_WINDOW).await; - - let accumulated = delta.swap(0, Ordering::SeqCst); - if accumulated != 0 { - tx.send(AppEvent::Scroll(accumulated)); - } - - timer_flag.store(false, Ordering::SeqCst); - }); - } -} diff --git a/codex-rs/tui/src/text_block.rs b/codex-rs/tui/src/text_block.rs index 2c68d90f11..33f326b83d 100644 --- a/codex-rs/tui/src/text_block.rs +++ b/codex-rs/tui/src/text_block.rs @@ -1,4 +1,3 @@ -use crate::cell_widget::CellWidget; use ratatui::prelude::*; /// A simple widget that just displays a list of `Line`s via a `Paragraph`. @@ -13,20 +12,3 @@ impl TextBlock { Self { lines } } } - -impl CellWidget for TextBlock { - fn height(&self, width: u16) -> usize { - // Use the same wrapping configuration as ConversationHistoryWidget so - // measurement stays in sync with rendering. - ratatui::widgets::Paragraph::new(self.lines.clone()) - .wrap(crate::conversation_history_widget::wrap_cfg()) - .line_count(width) - } - - fn render_window(&self, first_visible_line: usize, area: Rect, buf: &mut Buffer) { - ratatui::widgets::Paragraph::new(self.lines.clone()) - .wrap(crate::conversation_history_widget::wrap_cfg()) - .scroll((first_visible_line as u16, 0)) - .render(area, buf); - } -}