From acfd94f625c4f75d927c3debc533347bcd19da24 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 9 Jan 2026 13:47:37 -0800 Subject: [PATCH 1/3] Add hierarchical agent prompt (#8996) --- codex-rs/core/hierarchical_agents_message.md | 7 ++ codex-rs/core/src/features.rs | 8 +++ codex-rs/core/src/project_doc.rs | 65 +++++++++-------- .../core/tests/suite/hierarchical_agents.rs | 71 +++++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + docs/agents_md.md | 4 ++ 6 files changed, 125 insertions(+), 31 deletions(-) create mode 100644 codex-rs/core/hierarchical_agents_message.md create mode 100644 codex-rs/core/tests/suite/hierarchical_agents.rs diff --git a/codex-rs/core/hierarchical_agents_message.md b/codex-rs/core/hierarchical_agents_message.md new file mode 100644 index 0000000000..4f782078c8 --- /dev/null +++ b/codex-rs/core/hierarchical_agents_message.md @@ -0,0 +1,7 @@ +Files called AGENTS.md commonly appear in many places inside a container - at "/", in "~", deep within git repositories, or in any other directory; their location is not limited to version-controlled folders. + +Their purpose is to pass along human guidance to you, the agent. Such guidance can include coding standards, explanations of the project layout, steps for building or testing, and even wording that must accompany a GitHub pull-request description produced by the agent; all of it is to be followed. + +Each AGENTS.md governs the entire directory that contains it and every child directory beneath that point. Whenever you change a file, you have to comply with every AGENTS.md whose scope covers that file. Naming conventions, stylistic rules and similar directives are restricted to the code that falls inside that scope unless the document explicitly states otherwise. + +When two AGENTS.md files disagree, the one located deeper in the directory structure overrides the higher-level file, while instructions given directly in the prompt by the system, developer, or user outrank any AGENTS.md content. diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index b268bf6d78..8c1c597ee7 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -86,6 +86,8 @@ pub enum Feature { RemoteModels, /// Experimental shell snapshotting. ShellSnapshot, + /// Append additional AGENTS.md guidance to user instructions. + HierarchicalAgents, /// Experimental TUI v2 (viewport) implementation. Tui2, /// Enforce UTF8 output in Powershell. @@ -352,6 +354,12 @@ pub const FEATURES: &[FeatureSpec] = &[ }, default_enabled: false, }, + FeatureSpec { + id: Feature::HierarchicalAgents, + key: "hierarchical_agents", + stage: Stage::Experimental, + default_enabled: false, + }, FeatureSpec { id: Feature::ApplyPatchFreeform, key: "apply_patch_freeform", diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 79f82c4598..365475e621 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -14,6 +14,7 @@ //! 3. We do **not** walk past the Git root. use crate::config::Config; +use crate::features::Feature; use crate::skills::SkillMetadata; use crate::skills::render_skills_section; use dunce::canonicalize as normalize_path; @@ -21,6 +22,9 @@ use std::path::PathBuf; use tokio::io::AsyncReadExt; use tracing::error; +pub(crate) const HIERARCHICAL_AGENTS_MESSAGE: &str = + include_str!("../hierarchical_agents_message.md"); + /// Default filename scanned for project-level docs. pub const DEFAULT_PROJECT_DOC_FILENAME: &str = "AGENTS.md"; /// Preferred local override for project-level docs. @@ -36,35 +40,46 @@ pub(crate) async fn get_user_instructions( config: &Config, skills: Option<&[SkillMetadata]>, ) -> Option { - let skills_section = skills.and_then(render_skills_section); + let project_docs = read_project_docs(config).await; - let project_docs = match read_project_docs(config).await { - Ok(docs) => docs, + let mut output = String::new(); + + if let Some(instructions) = config.user_instructions.clone() { + output.push_str(&instructions); + } + + match project_docs { + Ok(Some(docs)) => { + if !output.is_empty() { + output.push_str(PROJECT_DOC_SEPARATOR); + } + output.push_str(&docs); + } + Ok(None) => {} Err(e) => { error!("error trying to find project doc: {e:#}"); - return config.user_instructions.clone(); } }; - let combined_project_docs = merge_project_docs_with_skills(project_docs, skills_section); - - let mut parts: Vec = Vec::new(); - - if let Some(instructions) = config.user_instructions.clone() { - parts.push(instructions); - } - - if let Some(project_doc) = combined_project_docs { - if !parts.is_empty() { - parts.push(PROJECT_DOC_SEPARATOR.to_string()); + let skills_section = skills.and_then(render_skills_section); + if let Some(skills_section) = skills_section { + if !output.is_empty() { + output.push_str("\n\n"); } - parts.push(project_doc); + output.push_str(&skills_section); } - if parts.is_empty() { - None + if config.features.enabled(Feature::HierarchicalAgents) { + if !output.is_empty() { + output.push_str("\n\n"); + } + output.push_str(HIERARCHICAL_AGENTS_MESSAGE); + } + + if !output.is_empty() { + Some(output) } else { - Some(parts.concat()) + None } } @@ -217,18 +232,6 @@ fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> { names } -fn merge_project_docs_with_skills( - project_doc: Option, - skills_section: Option, -) -> Option { - match (project_doc, skills_section) { - (Some(doc), Some(skills)) => Some(format!("{doc}\n\n{skills}")), - (Some(doc), None) => Some(doc), - (None, Some(skills)) => Some(skills), - (None, None) => None, - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/core/tests/suite/hierarchical_agents.rs b/codex-rs/core/tests/suite/hierarchical_agents.rs new file mode 100644 index 0000000000..cc7b78a94e --- /dev/null +++ b/codex-rs/core/tests/suite/hierarchical_agents.rs @@ -0,0 +1,71 @@ +use codex_core::features::Feature; +use core_test_support::load_sse_fixture_with_id; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::test_codex; + +const HIERARCHICAL_AGENTS_SNIPPET: &str = + "Files called AGENTS.md commonly appear in many places inside a container"; + +fn sse_completed(id: &str) -> String { + load_sse_fixture_with_id("../fixtures/completed_template.json", id) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn hierarchical_agents_appends_to_project_doc_in_user_instructions() { + let server = start_mock_server().await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::HierarchicalAgents); + std::fs::write(config.cwd.join("AGENTS.md"), "be nice").expect("write AGENTS.md"); + }); + let test = builder.build(&server).await.expect("build test codex"); + + test.submit_turn("hello").await.expect("submit turn"); + + let request = resp_mock.single_request(); + let user_messages = request.message_input_texts("user"); + let instructions = user_messages + .iter() + .find(|text| text.starts_with("# AGENTS.md instructions for ")) + .expect("instructions message"); + assert!( + instructions.contains("be nice"), + "expected AGENTS.md text included: {instructions}" + ); + let snippet_pos = instructions + .find(HIERARCHICAL_AGENTS_SNIPPET) + .expect("expected hierarchical agents snippet"); + let base_pos = instructions + .find("be nice") + .expect("expected AGENTS.md text"); + assert!( + snippet_pos > base_pos, + "expected hierarchical agents message appended after base instructions: {instructions}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn hierarchical_agents_emits_when_no_project_doc() { + let server = start_mock_server().await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::HierarchicalAgents); + }); + let test = builder.build(&server).await.expect("build test codex"); + + test.submit_turn("hello").await.expect("submit turn"); + + let request = resp_mock.single_request(); + let user_messages = request.message_input_texts("user"); + let instructions = user_messages + .iter() + .find(|text| text.starts_with("# AGENTS.md instructions for ")) + .expect("instructions message"); + assert!( + instructions.contains(HIERARCHICAL_AGENTS_SNIPPET), + "expected hierarchical agents message appended: {instructions}" + ); +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index effbc8a931..44093778d3 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -30,6 +30,7 @@ mod exec; mod exec_policy; mod fork_thread; mod grep_files; +mod hierarchical_agents; mod items; mod json_result; mod list_dir; diff --git a/docs/agents_md.md b/docs/agents_md.md index 4fa02abd1d..40222d1135 100644 --- a/docs/agents_md.md +++ b/docs/agents_md.md @@ -1,3 +1,7 @@ # AGENTS.md For information about AGENTS.md, see [this documentation](https://developers.openai.com/codex/guides/agents-md). + +## Hierarchical agents message + +When the `hierarchical_agents` feature flag is enabled (via `[features]` in `config.toml`), Codex appends additional guidance about AGENTS.md scope and precedence to the user instructions message and emits that message even when no AGENTS.md is present. From 1a0e2e612b7d3efddeee069a13c759a34308a0b2 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 9 Jan 2026 14:47:46 -0800 Subject: [PATCH 2/3] Delete announcement_tip.toml (#9003) --- announcement_tip.toml | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 announcement_tip.toml diff --git a/announcement_tip.toml b/announcement_tip.toml deleted file mode 100644 index 3ad4a76590..0000000000 --- a/announcement_tip.toml +++ /dev/null @@ -1,16 +0,0 @@ -# Example announcement tips for Codex TUI. -# Each [[announcements]] entry is evaluated in order; the last matching one is shown. -# Dates are UTC, formatted as YYYY-MM-DD. The from_date is inclusive and the to_date is exclusive. -# version_regex matches against the CLI version (env!("CARGO_PKG_VERSION")); omit to apply to all versions. -# target_app specify which app should display the announcement (cli, vsce, ...). - -[[announcements]] -content = "Welcome to Codex! Check out the new onboarding flow." -from_date = "2024-10-01" -to_date = "2024-10-15" -target_app = "cli" - -[[announcements]] -content = "This is a test announcement" -version_regex = "^0\\.0\\.0$" -to_date = "2026-01-10" From 6fbc466cc5805d5a2b31394cd1c7d7927521b9e2 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 9 Jan 2026 16:35:33 -0800 Subject: [PATCH 3/3] fix: report an appropriate error in the TUI for malformed rules --- codex-rs/cli/src/main.rs | 11 ++++ codex-rs/tui/src/app.rs | 85 +++++++++++++++++-------- codex-rs/tui/src/app_event.rs | 3 + codex-rs/tui/src/chatwidget/agent.rs | 8 +-- codex-rs/tui/src/lib.rs | 5 ++ codex-rs/tui2/src/app.rs | 91 +++++++++++++++++++-------- codex-rs/tui2/src/app_event.rs | 3 + codex-rs/tui2/src/chatwidget/agent.rs | 8 +-- codex-rs/tui2/src/lib.rs | 5 ++ 9 files changed, 157 insertions(+), 62 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 11ba7cfa27..14a86b8592 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -26,6 +26,7 @@ use codex_execpolicy::ExecPolicyCheckCommand; use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; +use codex_tui::ExitReason; use codex_tui::update_action::UpdateAction; use codex_tui2 as tui2; use owo_colors::OwoColorize; @@ -313,6 +314,14 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec anyhow::Result<()> { + match exit_info.exit_reason { + ExitReason::Fatal(message) => { + eprintln!("ERROR: {message}"); + std::process::exit(1); + } + ExitReason::UserRequested => { /* normal exit */ } + } + let update_action = exit_info.update_action; let color_enabled = supports_color::on(Stream::Stdout).is_some(); for line in format_exit_messages(exit_info, color_enabled) { @@ -833,6 +842,7 @@ mod tests { token_usage, thread_id: conversation.map(ThreadId::from_string).map(Result::unwrap), update_action: None, + exit_reason: ExitReason::UserRequested, } } @@ -842,6 +852,7 @@ mod tests { token_usage: TokenUsage::default(), thread_id: None, update_action: None, + exit_reason: ExitReason::UserRequested, }; let lines = format_exit_messages(exit_info, false); assert!(lines.is_empty()); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 9e5ac2d95e..134abb7d4a 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -76,6 +76,19 @@ pub struct AppExitInfo { pub token_usage: TokenUsage, pub thread_id: Option, pub update_action: Option, + pub exit_reason: ExitReason, +} + +#[derive(Debug)] +pub(crate) enum AppRunControl { + Continue, + Exit(ExitReason), +} + +#[derive(Debug, Clone)] +pub enum ExitReason { + UserRequested, + Fatal(String), } fn session_summary(token_usage: TokenUsage, thread_id: Option) -> Option { @@ -277,6 +290,7 @@ async fn handle_model_migration_prompt_if_needed( token_usage: TokenUsage::default(), thread_id: None, update_action: None, + exit_reason: ExitReason::UserRequested, }); } } @@ -468,14 +482,23 @@ impl App { #[cfg(not(debug_assertions))] if let Some(latest_version) = upgrade_version { - app.handle_event( - tui, - AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new( - latest_version, - crate::update_action::get_update_action(), - ))), - ) - .await?; + let control = app + .handle_event( + tui, + AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new( + latest_version, + crate::update_action::get_update_action(), + ))), + ) + .await?; + if let AppRunControl::Exit(exit_reason) = control { + return Ok(AppExitInfo { + token_usage: app.token_usage(), + thread_id: app.chat_widget.thread_id(), + update_action: app.pending_update_action, + exit_reason, + }); + } } let tui_events = tui.event_stream(); @@ -483,19 +506,26 @@ impl App { tui.frame_requester().schedule_frame(); - while select! { - Some(event) = app_event_rx.recv() => { - app.handle_event(tui, event).await? + let exit_reason = loop { + let control = select! { + Some(event) = app_event_rx.recv() => { + app.handle_event(tui, event).await? + } + Some(event) = tui_events.next() => { + app.handle_tui_event(tui, event).await? + } + }; + match control { + AppRunControl::Continue => {} + AppRunControl::Exit(reason) => break reason, } - Some(event) = tui_events.next() => { - app.handle_tui_event(tui, event).await? - } - } {} + }; tui.terminal.clear()?; Ok(AppExitInfo { token_usage: app.token_usage(), thread_id: app.chat_widget.thread_id(), update_action: app.pending_update_action, + exit_reason, }) } @@ -503,7 +533,7 @@ impl App { &mut self, tui: &mut tui::Tui, event: TuiEvent, - ) -> Result { + ) -> Result { if self.overlay.is_some() { let _ = self.handle_backtrack_overlay_event(tui, event).await?; } else { @@ -525,7 +555,7 @@ impl App { .chat_widget .handle_paste_burst_tick(tui.frame_requester()) { - return Ok(true); + return Ok(AppRunControl::Continue); } tui.draw( self.chat_widget.desired_height(tui.terminal.size()?.width), @@ -544,10 +574,10 @@ impl App { } } } - Ok(true) + Ok(AppRunControl::Continue) } - async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { + async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { let model_info = self .server .get_models_manager() @@ -707,7 +737,7 @@ impl App { && matches!(event.msg, EventMsg::ShutdownComplete) { self.suppress_shutdown_complete = false; - return Ok(true); + return Ok(AppRunControl::Continue); } if let EventMsg::ListSkillsResponse(response) = &event.msg { let cwd = self.chat_widget.config_ref().cwd.clone(); @@ -720,7 +750,10 @@ impl App { self.on_conversation_history_for_backtrack(tui, ev).await?; } AppEvent::ExitRequest => { - return Ok(false); + return Ok(AppRunControl::Exit(ExitReason::UserRequested)); + } + AppEvent::FatalExitRequest(message) => { + return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); } AppEvent::CodexOp(op) => self.chat_widget.submit_op(op), AppEvent::DiffResult(text) => { @@ -989,7 +1022,7 @@ impl App { tracing::warn!(%err, "failed to set sandbox policy on app config"); self.chat_widget .add_error_message(format!("Failed to set sandbox policy: {err}")); - return Ok(true); + return Ok(AppRunControl::Continue); } #[cfg(target_os = "windows")] if !matches!(&policy, codex_core::protocol::SandboxPolicy::ReadOnly) @@ -1001,7 +1034,7 @@ impl App { tracing::warn!(%err, "failed to set sandbox policy on chat config"); self.chat_widget .add_error_message(format!("Failed to set sandbox policy: {err}")); - return Ok(true); + return Ok(AppRunControl::Continue); } // If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan. @@ -1010,7 +1043,7 @@ impl App { // One-shot suppression if the user just confirmed continue. if self.skip_world_writable_scan_once { self.skip_world_writable_scan_once = false; - return Ok(true); + return Ok(AppRunControl::Continue); } let should_check = codex_core::get_platform_sandbox().is_some() @@ -1035,7 +1068,7 @@ impl App { } AppEvent::UpdateFeatureFlags { updates } => { if updates.is_empty() { - return Ok(true); + return Ok(AppRunControl::Continue); } let mut builder = ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(self.active_profile.as_deref()); @@ -1194,7 +1227,7 @@ impl App { } }, } - Ok(true) + Ok(AppRunControl::Continue) } fn reasoning_label(reasoning_effort: Option) -> &'static str { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 861ba2a54c..067d741d0a 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -42,6 +42,9 @@ pub(crate) enum AppEvent { /// Request to exit the application gracefully. ExitRequest, + /// Request to exit the application due to a fatal error. + FatalExitRequest(String), + /// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids /// bubbling channels through layers of widgets. CodexOp(codex_core::protocol::Op), diff --git a/codex-rs/tui/src/chatwidget/agent.rs b/codex-rs/tui/src/chatwidget/agent.rs index d8428b221f..21ed92d0ee 100644 --- a/codex-rs/tui/src/chatwidget/agent.rs +++ b/codex-rs/tui/src/chatwidget/agent.rs @@ -30,16 +30,14 @@ pub(crate) fn spawn_agent( .. } = match server.start_thread(config).await { Ok(v) => v, - #[allow(clippy::print_stderr)] Err(err) => { - let message = err.to_string(); - eprintln!("{message}"); + let message = format!("Failed to initialize codex: {err}"); + tracing::error!("{message}"); app_event_tx_clone.send(AppEvent::CodexEvent(Event { id: "".to_string(), msg: EventMsg::Error(err.to_error_event(None)), })); - app_event_tx_clone.send(AppEvent::ExitRequest); - tracing::error!("failed to initialize codex: {err}"); + app_event_tx_clone.send(AppEvent::FatalExitRequest(message)); return; } }; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 0f38c5ee74..d232d4b1e0 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -6,6 +6,7 @@ use additional_dirs::add_dir_warning_message; use app::App; pub use app::AppExitInfo; +pub use app::ExitReason; use codex_app_server_protocol::AuthMode; use codex_common::oss::ensure_oss_provider_ready; use codex_common::oss::get_default_model_for_oss_provider; @@ -376,6 +377,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, update_action: Some(action), + fatal_error_message: ExitReason::UserRequested, }); } } @@ -415,6 +417,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, update_action: None, + exit_reason: ExitReason::UserRequested, }); } // if the user acknowledged windows or made an explicit decision ato trust the directory, reload the config accordingly @@ -450,6 +453,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, update_action: None, + exit_reason: ExitReason::UserRequested, }); } } @@ -488,6 +492,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, update_action: None, + exit_reason: ExitReason::UserRequested, }); } other => other, diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 292ccb5ac6..ebf90dc17c 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -97,6 +97,7 @@ pub struct AppExitInfo { pub token_usage: TokenUsage, pub conversation_id: Option, pub update_action: Option, + pub exit_reason: ExitReason, /// ANSI-styled transcript lines to print after the TUI exits. /// /// These lines are rendered against the same width as the final TUI @@ -105,12 +106,29 @@ pub struct AppExitInfo { pub session_lines: Vec, } +#[derive(Debug)] +pub(crate) enum AppRunControl { + Continue, + Exit(ExitReason), +} + +#[derive(Debug, Clone)] +pub enum ExitReason { + UserRequested, + Fatal(String), +} + impl From for codex_tui::AppExitInfo { fn from(info: AppExitInfo) -> Self { + let exit_reason = match info.exit_reason { + ExitReason::UserRequested => codex_tui::ExitReason::UserRequested, + ExitReason::Fatal(message) => codex_tui::ExitReason::Fatal(message), + }; codex_tui::AppExitInfo { token_usage: info.token_usage, thread_id: info.conversation_id, update_action: info.update_action.map(Into::into), + exit_reason, } } } @@ -314,6 +332,7 @@ async fn handle_model_migration_prompt_if_needed( token_usage: TokenUsage::default(), conversation_id: None, update_action: None, + exit_reason: ExitReason::UserRequested, session_lines: Vec::new(), }); } @@ -558,14 +577,24 @@ impl App { #[cfg(not(debug_assertions))] if let Some(latest_version) = upgrade_version { - app.handle_event( - tui, - AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new( - latest_version, - crate::update_action::get_update_action(), - ))), - ) - .await?; + let control = app + .handle_event( + tui, + AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new( + latest_version, + crate::update_action::get_update_action(), + ))), + ) + .await?; + if let AppRunControl::Exit(exit_reason) = control { + return Ok(AppExitInfo { + token_usage: app.token_usage(), + conversation_id: app.chat_widget.conversation_id(), + update_action: app.pending_update_action, + exit_reason, + session_lines: Vec::new(), + }); + } } let tui_events = tui.event_stream(); @@ -573,14 +602,20 @@ impl App { tui.frame_requester().schedule_frame(); - while select! { - Some(event) = app_event_rx.recv() => { - app.handle_event(tui, event).await? + let exit_reason = loop { + let control = select! { + Some(event) = app_event_rx.recv() => { + app.handle_event(tui, event).await? + } + Some(event) = tui_events.next() => { + app.handle_tui_event(tui, event).await? + } + }; + match control { + AppRunControl::Continue => {} + AppRunControl::Exit(reason) => break reason, } - Some(event) = tui_events.next() => { - app.handle_tui_event(tui, event).await? - } - } {} + }; let width = tui.terminal.last_known_screen_size.width; let session_lines = if width == 0 { Vec::new() @@ -601,6 +636,7 @@ impl App { token_usage: app.token_usage(), conversation_id: app.chat_widget.conversation_id(), update_action: app.pending_update_action, + exit_reason, session_lines, }) } @@ -609,7 +645,7 @@ impl App { &mut self, tui: &mut tui::Tui, event: TuiEvent, - ) -> Result { + ) -> Result { if matches!(&event, TuiEvent::Draw) { self.handle_scroll_tick(tui); } @@ -638,7 +674,7 @@ impl App { .chat_widget .handle_paste_burst_tick(tui.frame_requester()) { - return Ok(true); + return Ok(AppRunControl::Continue); } let cells = self.transcript_cells.clone(); tui.draw(tui.terminal.size()?.height, |frame| { @@ -698,7 +734,7 @@ impl App { } } } - Ok(true) + Ok(AppRunControl::Continue) } pub(crate) fn render_transcript_cells( @@ -1349,7 +1385,7 @@ impl App { Some(TranscriptSelectionPoint { line_index, column }) } - async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { + async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { AppEvent::NewSession => { let summary = session_summary( @@ -1489,7 +1525,7 @@ impl App { && matches!(event.msg, EventMsg::ShutdownComplete) { self.suppress_shutdown_complete = false; - return Ok(true); + return Ok(AppRunControl::Continue); } if let EventMsg::ListSkillsResponse(response) = &event.msg { let cwd = self.chat_widget.config_ref().cwd.clone(); @@ -1502,7 +1538,10 @@ impl App { self.on_conversation_history_for_backtrack(tui, ev).await?; } AppEvent::ExitRequest => { - return Ok(false); + return Ok(AppRunControl::Exit(ExitReason::UserRequested)); + } + AppEvent::FatalExitRequest(message) => { + return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); } AppEvent::CodexOp(op) => self.chat_widget.submit_op(op), AppEvent::DiffResult(text) => { @@ -1597,7 +1636,7 @@ impl App { preset, mode: WindowsSandboxEnableMode::Elevated, }); - return Ok(true); + return Ok(AppRunControl::Continue); } self.chat_widget.show_windows_sandbox_setup_status(); @@ -1766,7 +1805,7 @@ impl App { tracing::warn!(%err, "failed to set sandbox policy on app config"); self.chat_widget .add_error_message(format!("Failed to set sandbox policy: {err}")); - return Ok(true); + return Ok(AppRunControl::Continue); } #[cfg(target_os = "windows")] if !matches!(&policy, codex_core::protocol::SandboxPolicy::ReadOnly) @@ -1778,7 +1817,7 @@ impl App { tracing::warn!(%err, "failed to set sandbox policy on chat config"); self.chat_widget .add_error_message(format!("Failed to set sandbox policy: {err}")); - return Ok(true); + return Ok(AppRunControl::Continue); } // If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan. @@ -1787,7 +1826,7 @@ impl App { // One-shot suppression if the user just confirmed continue. if self.skip_world_writable_scan_once { self.skip_world_writable_scan_once = false; - return Ok(true); + return Ok(AppRunControl::Continue); } let should_check = codex_core::get_platform_sandbox().is_some() @@ -1935,7 +1974,7 @@ impl App { } }, } - Ok(true) + Ok(AppRunControl::Continue) } fn reasoning_label(reasoning_effort: Option) -> &'static str { diff --git a/codex-rs/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs index d72eef2b96..4ebf156369 100644 --- a/codex-rs/tui2/src/app_event.rs +++ b/codex-rs/tui2/src/app_event.rs @@ -41,6 +41,9 @@ pub(crate) enum AppEvent { /// Request to exit the application gracefully. ExitRequest, + /// Request to exit the application due to a fatal error. + FatalExitRequest(String), + /// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids /// bubbling channels through layers of widgets. CodexOp(codex_core::protocol::Op), diff --git a/codex-rs/tui2/src/chatwidget/agent.rs b/codex-rs/tui2/src/chatwidget/agent.rs index 0e6fa2712b..24c4036530 100644 --- a/codex-rs/tui2/src/chatwidget/agent.rs +++ b/codex-rs/tui2/src/chatwidget/agent.rs @@ -30,16 +30,14 @@ pub(crate) fn spawn_agent( thread_id: _, } = match server.start_thread(config).await { Ok(v) => v, - #[allow(clippy::print_stderr)] Err(err) => { - let message = err.to_string(); - eprintln!("{message}"); + let message = format!("Failed to initialize codex: {err}"); + tracing::error!("{message}"); app_event_tx_clone.send(AppEvent::CodexEvent(Event { id: "".to_string(), msg: EventMsg::Error(err.to_error_event(None)), })); - app_event_tx_clone.send(AppEvent::ExitRequest); - tracing::error!("failed to initialize codex: {err}"); + app_event_tx_clone.send(AppEvent::FatalExitRequest(message)); return; } }; diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index e111af5b19..0b82ef65ac 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -6,6 +6,7 @@ use additional_dirs::add_dir_warning_message; use app::App; pub use app::AppExitInfo; +pub use app::ExitReason; use codex_app_server_protocol::AuthMode; use codex_common::oss::ensure_oss_provider_ready; use codex_common::oss::get_default_model_for_oss_provider; @@ -394,6 +395,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, update_action: Some(action), + exit_reason: ExitReason::UserRequested, session_lines: Vec::new(), }); } @@ -434,6 +436,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, update_action: None, + exit_reason: ExitReason::UserRequested, session_lines: Vec::new(), }); } @@ -470,6 +473,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, update_action: None, + exit_reason: ExitReason::UserRequested, session_lines: Vec::new(), }); } @@ -509,6 +513,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, update_action: None, + exit_reason: ExitReason::UserRequested, session_lines: Vec::new(), }); }