fix: report an appropriate error in the TUI for malformed rules

This commit is contained in:
Michael Bolin
2026-01-13 13:29:24 -08:00
parent e25d2ab3bf
commit b21b75693a
9 changed files with 164 additions and 76 deletions

View File

@@ -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;
@@ -353,6 +354,14 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
/// Handle the app exit and print the results. Optionally run the update action.
fn handle_app_exit(exit_info: AppExitInfo) -> 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) {
@@ -947,6 +956,7 @@ mod tests {
token_usage,
thread_id: conversation.map(ThreadId::from_string).map(Result::unwrap),
update_action: None,
exit_reason: ExitReason::UserRequested,
}
}
@@ -956,6 +966,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());

View File

@@ -77,6 +77,19 @@ pub struct AppExitInfo {
pub token_usage: TokenUsage,
pub thread_id: Option<ThreadId>,
pub update_action: Option<UpdateAction>,
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<ThreadId>) -> Option<SessionSummary> {
@@ -289,6 +302,7 @@ async fn handle_model_migration_prompt_if_needed(
token_usage: TokenUsage::default(),
thread_id: None,
update_action: None,
exit_reason: ExitReason::UserRequested,
});
}
}
@@ -506,14 +520,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();
@@ -521,19 +544,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,
})
}
@@ -541,7 +571,7 @@ impl App {
&mut self,
tui: &mut tui::Tui,
event: TuiEvent,
) -> Result<bool> {
) -> Result<AppRunControl> {
if self.overlay.is_some() {
let _ = self.handle_backtrack_overlay_event(tui, event).await?;
} else {
@@ -563,7 +593,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),
@@ -582,10 +612,10 @@ impl App {
}
}
}
Ok(true)
Ok(AppRunControl::Continue)
}
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<bool> {
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<AppRunControl> {
let model_info = self
.server
.get_models_manager()
@@ -816,7 +846,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();
@@ -826,7 +856,10 @@ impl App {
self.chat_widget.handle_codex_event(event);
}
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) => {
@@ -926,7 +959,7 @@ impl App {
preset,
mode: WindowsSandboxEnableMode::Elevated,
});
return Ok(true);
return Ok(AppRunControl::Continue);
}
self.chat_widget.show_windows_sandbox_setup_status();
@@ -1095,7 +1128,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)
@@ -1107,7 +1140,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.
@@ -1116,7 +1149,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()
@@ -1141,7 +1174,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());
@@ -1300,7 +1333,7 @@ impl App {
}
},
}
Ok(true)
Ok(AppRunControl::Continue)
}
fn reasoning_label(reasoning_effort: Option<ReasoningEffortConfig>) -> &'static str {

View File

@@ -44,6 +44,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),

View File

@@ -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;
}
};

View File

@@ -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;
@@ -99,7 +100,6 @@ pub use cli::Cli;
pub use markdown_render::render_markdown_text;
pub use public_widgets::composer_input::ComposerAction;
pub use public_widgets::composer_input::ComposerInput;
use std::io::Write as _;
// (tests access modules directly within the crate)
pub async fn run_main(
@@ -377,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,
});
}
}
@@ -416,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
@@ -444,16 +446,13 @@ async fn run_ratatui_app(
restore();
session_log::log_session_end();
let _ = tui.terminal.clear();
if let Err(err) = writeln!(
std::io::stdout(),
"No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions."
) {
error!("Failed to write session error message: {err}");
}
Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
thread_id: None,
update_action: None,
exit_reason: ExitReason::Fatal(format!(
"No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions."
)),
})
};
@@ -499,6 +498,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,
@@ -546,6 +546,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,

View File

@@ -98,6 +98,7 @@ pub struct AppExitInfo {
pub token_usage: TokenUsage,
pub conversation_id: Option<ThreadId>,
pub update_action: Option<UpdateAction>,
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
@@ -106,12 +107,29 @@ pub struct AppExitInfo {
pub session_lines: Vec<String>,
}
#[derive(Debug)]
pub(crate) enum AppRunControl {
Continue,
Exit(ExitReason),
}
#[derive(Debug, Clone)]
pub enum ExitReason {
UserRequested,
Fatal(String),
}
impl From<AppExitInfo> 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,
}
}
}
@@ -326,6 +344,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(),
});
}
@@ -596,14 +615,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();
@@ -611,14 +640,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()
@@ -639,6 +674,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,
})
}
@@ -647,7 +683,7 @@ impl App {
&mut self,
tui: &mut tui::Tui,
event: TuiEvent,
) -> Result<bool> {
) -> Result<AppRunControl> {
if matches!(&event, TuiEvent::Draw) {
self.handle_scroll_tick(tui);
}
@@ -676,7 +712,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| {
@@ -736,7 +772,7 @@ impl App {
}
}
}
Ok(true)
Ok(AppRunControl::Continue)
}
pub(crate) fn render_transcript_cells(
@@ -1387,7 +1423,7 @@ impl App {
Some(TranscriptSelectionPoint { line_index, column })
}
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<bool> {
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<AppRunControl> {
match event {
AppEvent::NewSession => {
let summary = session_summary(
@@ -1597,7 +1633,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();
@@ -1607,7 +1643,10 @@ impl App {
self.chat_widget.handle_codex_event(event);
}
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) => {
@@ -1702,7 +1741,7 @@ impl App {
preset,
mode: WindowsSandboxEnableMode::Elevated,
});
return Ok(true);
return Ok(AppRunControl::Continue);
}
self.chat_widget.show_windows_sandbox_setup_status();
@@ -1871,7 +1910,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)
@@ -1883,7 +1922,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.
@@ -1892,7 +1931,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()
@@ -2040,7 +2079,7 @@ impl App {
}
},
}
Ok(true)
Ok(AppRunControl::Continue)
}
fn reasoning_label(reasoning_effort: Option<ReasoningEffortConfig>) -> &'static str {

View File

@@ -43,6 +43,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),

View File

@@ -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;
}
};

View File

@@ -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;
@@ -395,6 +396,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(),
});
}
@@ -435,6 +437,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(),
});
}
@@ -464,16 +467,13 @@ async fn run_ratatui_app(
restore();
session_log::log_session_end();
let _ = tui.terminal.clear();
if let Err(err) = writeln!(
std::io::stdout(),
"No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions."
) {
error!("Failed to write session error message: {err}");
}
Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
update_action: None,
exit_reason: ExitReason::Fatal(format!(
"No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions."
)),
session_lines: Vec::new(),
})
};
@@ -520,6 +520,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(),
});
}
@@ -568,6 +569,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(),
});
}