From ae8f772ef2ab4b432ec22e360c223c002b81e897 Mon Sep 17 00:00:00 2001
From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com>
Date: Mon, 25 Aug 2025 17:29:24 -0700
Subject: [PATCH 01/10] do not schedule frames for Tui::Draw events in
backtrack (#2692)
this was causing continuous rerendering when a transcript overlay was
present
---
codex-rs/tui/src/app_backtrack.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs
index 6cfbe164b5..fcab4a83cc 100644
--- a/codex-rs/tui/src/app_backtrack.rs
+++ b/codex-rs/tui/src/app_backtrack.rs
@@ -207,9 +207,9 @@ impl App {
overlay.handle_event(tui, event)?;
if overlay.is_done {
self.close_transcript_overlay(tui);
+ tui.frame_requester().schedule_frame();
}
}
- tui.frame_requester().schedule_frame();
Ok(())
}
From b1079187e4d904f0370a6596a974771de3215cf3 Mon Sep 17 00:00:00 2001
From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com>
Date: Mon, 25 Aug 2025 17:36:05 -0700
Subject: [PATCH 02/10] queued messages rendered italic (#2693)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
codex-rs/tui/src/status_indicator_widget.rs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs
index d7fe24db0e..a41b7c3528 100644
--- a/codex-rs/tui/src/status_indicator_widget.rs
+++ b/codex-rs/tui/src/status_indicator_widget.rs
@@ -123,10 +123,10 @@ impl WidgetRef for StatusIndicatorWidget {
for (i, piece) in wrapped.iter().take(3).enumerate() {
let prefix = if i == 0 { " โณ " } else { " " };
let content = format!("{prefix}{piece}");
- lines.push(Line::from(content.dim()));
+ lines.push(Line::from(content.dim().italic()));
}
if wrapped.len() > 3 {
- lines.push(Line::from(" โฆ".dim()));
+ lines.push(Line::from(" โฆ".dim().italic()));
}
}
if !self.queued_messages.is_empty() {
From 17e5077507869c9a116837cd48357355f34389b9 Mon Sep 17 00:00:00 2001
From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com>
Date: Mon, 25 Aug 2025 17:52:23 -0700
Subject: [PATCH 03/10] do not show timeouts as "sandbox error"s (#2587)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
๐
๐ซธ
```
โ Failed (exit -1)
โ ๐งช cargo test --all-features -q
sandbox error: command timed out
```
๐๐
```
โ Failed (exit -1)
โ ๐งช cargo test --all-features -q
error: command timed out
```
---
codex-rs/core/src/error.rs | 2 ++
codex-rs/tui/tests/fixtures/binary-size-log.jsonl | 2 +-
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs
index 356c23011c..4a9082f2a7 100644
--- a/codex-rs/core/src/error.rs
+++ b/codex-rs/core/src/error.rs
@@ -181,6 +181,8 @@ impl CodexErr {
pub fn get_error_message_ui(e: &CodexErr) -> String {
match e {
CodexErr::Sandbox(SandboxErr::Denied(_, _, stderr)) => stderr.to_string(),
+ // Timeouts are not sandbox errors from a UX perspective; present them plainly
+ CodexErr::Sandbox(SandboxErr::Timeout) => "error: command timed out".to_string(),
_ => e.to_string(),
}
}
diff --git a/codex-rs/tui/tests/fixtures/binary-size-log.jsonl b/codex-rs/tui/tests/fixtures/binary-size-log.jsonl
index 90144f300f..3c537d91c0 100644
--- a/codex-rs/tui/tests/fixtures/binary-size-log.jsonl
+++ b/codex-rs/tui/tests/fixtures/binary-size-log.jsonl
@@ -3698,7 +3698,7 @@
{"ts":"2025-08-09T15:53:04.318Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
{"ts":"2025-08-09T15:53:04.320Z","dir":"to_tui","kind":"app_event","variant":"Redraw"}
{"ts":"2025-08-09T15:53:04.353Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_command_output_delta","call_id":"call_KOxVodT3X5ci7LJmudvcovhW","stream":"stderr","chunk":[32,32,32,32,70,105,110,105,115,104,101,100,32,96,100,101,118,96,32,112,114,111,102,105,108,101,32,91,117,110,111,112,116,105,109,105,122,101,100,32,43,32,100,101,98,117,103,105,110,102,111,93,32,116,97,114,103,101,116,40,115,41,32,105,110,32,53,56,46,50,49,115,10]}}}
-{"ts":"2025-08-09T15:53:04.389Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_command_end","call_id":"call_KOxVodT3X5ci7LJmudvcovhW","stdout":"","stderr":"sandbox error: command timed out","exit_code":-1,"duration":{"secs":0,"nanos":0}}}}
+{"ts":"2025-08-09T15:53:04.389Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_command_end","call_id":"call_KOxVodT3X5ci7LJmudvcovhW","stdout":"","stderr":"error: command timed out","exit_code":-1,"duration":{"secs":0,"nanos":0}}}}
{"ts":"2025-08-09T15:53:04.389Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"token_count","input_tokens":14859,"cached_input_tokens":14625,"output_tokens":117,"reasoning_output_tokens":64,"total_tokens":14976}}}
{"ts":"2025-08-09T15:53:04.389Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"turn_diff","unified_diff":"diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs\nindex a0bb4e69e27ae82c5f70d2f4cd079c5cea3ae4f7..18bae310be9cfb81ca73e136be05148ba0510cc5\n--- a/codex-rs/core/tests/common/lib.rs\n+++ b/codex-rs/core/tests/common/lib.rs\n@@ -90,14 +90,11 @@\n where\n F: FnMut(&codex_core::protocol::EventMsg) -> bool,\n {\n+ use tokio::time::Duration;\n use tokio::time::timeout;\n loop {\n-<<<<<<< HEAD\n // Allow a bit more time to accommodate async startup work (e.g. config IO, tool discovery)\n- let ev = timeout(Duration::from_secs(5), codex.next_event())\n-=======\n- let ev = timeout(wait_time, codex.next_event())\n->>>>>>> origin/main\n+ let ev = timeout(wait_time.max(Duration::from_secs(5)), codex.next_event())\n .await\n .expect(\"timeout waiting for event\")\n .expect(\"stream ended unexpectedly\");\n"}}}
{"ts":"2025-08-09T15:53:04.389Z","dir":"to_tui","kind":"insert_history","lines":3}
From d63e44ae290ad449869add2e7492adfaf687bb48 Mon Sep 17 00:00:00 2001
From: Eric Traut
Date: Mon, 25 Aug 2025 19:18:16 -0700
Subject: [PATCH 04/10] Fixed a bug that causes token refresh to not work in a
seamless manner (#2699)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR fixes a bug in the token refresh logic. Token refresh is
performed in a retry loop so if we receive a 401 error, we refresh the
token, then we go around the loop again and reissue the fetch with a
fresh token. The bug is that we're not using the updated token on the
second and subsequent times through the loop. The result is that we'll
try to refresh the token a few more times until we hit the retry limit
(default of 4). The 401 error is then passed back up to the caller.
Subsequent calls will use the refreshed token, so the problem clears
itself up.
The fix is straightforward โย make sure we use the updated auth
information each time through the retry loop.
---
codex-rs/core/src/client.rs | 22 ++++++++++++++--------
1 file changed, 14 insertions(+), 8 deletions(-)
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
index ecf1ae7125..b5f8e4dc32 100644
--- a/codex-rs/core/src/client.rs
+++ b/codex-rs/core/src/client.rs
@@ -142,9 +142,12 @@ impl ModelClient {
}
let auth_manager = self.auth_manager.clone();
- let auth = auth_manager.as_ref().and_then(|m| m.auth());
- let auth_mode = auth.as_ref().map(|a| a.mode);
+ let auth_mode = auth_manager
+ .as_ref()
+ .and_then(|m| m.auth())
+ .as_ref()
+ .map(|a| a.mode);
let store = prompt.store && auth_mode != Some(AuthMode::ChatGPT);
@@ -211,15 +214,18 @@ impl ModelClient {
let mut attempt = 0;
let max_retries = self.provider.request_max_retries();
- trace!(
- "POST to {}: {}",
- self.provider.get_full_url(&auth),
- serde_json::to_string(&payload)?
- );
-
loop {
attempt += 1;
+ // Always fetch the latest auth in case a prior attempt refreshed the token.
+ let auth = auth_manager.as_ref().and_then(|m| m.auth());
+
+ trace!(
+ "POST to {}: {}",
+ self.provider.get_full_url(&auth),
+ serde_json::to_string(&payload)?
+ );
+
let mut req_builder = self
.provider
.create_request_builder(&self.client, &auth)
From e5283b6126b3e884e2f2af89d44f492d90974cd0 Mon Sep 17 00:00:00 2001
From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com>
Date: Mon, 25 Aug 2025 20:15:38 -0700
Subject: [PATCH 05/10] single control flow for both Esc and Ctrl+C (#2691)
Esc and Ctrl+C while a task is running should do the same thing. There
were some cases where pressing Esc would leave a "stuck" widget in the
history; this fixes that and cleans up the logic so there's just one
path for interrupting the task. Also clean up some subtly mishandled key
events (e.g. Ctrl+D would quit the app while an approval modal was
showing if the textarea was empty).
---------
Co-authored-by: Ahmed Ibrahim
---
codex-rs/tui/src/app.rs | 16 ---
codex-rs/tui/src/bottom_pane/chat_composer.rs | 10 ++
codex-rs/tui/src/chatwidget.rs | 121 +++++++++---------
...t__tests__interrupt_exec_marks_failed.snap | 6 +
codex-rs/tui/src/chatwidget/tests.rs | 42 ++++++
codex-rs/tui/src/history_cell.rs | 20 +++
6 files changed, 141 insertions(+), 74 deletions(-)
create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index 9f33c25f99..d32b741c27 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -276,22 +276,6 @@ impl App {
async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {
match key_event {
- KeyEvent {
- code: KeyCode::Char('c'),
- modifiers: crossterm::event::KeyModifiers::CONTROL,
- kind: KeyEventKind::Press,
- ..
- } => {
- self.chat_widget.on_ctrl_c();
- }
- KeyEvent {
- code: KeyCode::Char('d'),
- modifiers: crossterm::event::KeyModifiers::CONTROL,
- kind: KeyEventKind::Press,
- ..
- } if self.chat_widget.composer_is_empty() => {
- self.app_event_tx.send(AppEvent::ExitRequest);
- }
KeyEvent {
code: KeyCode::Char('t'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
index 8b10d224dd..fa320f9b4f 100644
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
@@ -1,6 +1,7 @@
use codex_core::protocol::TokenUsage;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
+use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
@@ -657,6 +658,15 @@ impl ChatComposer {
/// Handle key event when no popup is visible.
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
match key_event {
+ KeyEvent {
+ code: KeyCode::Char('d'),
+ modifiers: crossterm::event::KeyModifiers::CONTROL,
+ kind: KeyEventKind::Press,
+ ..
+ } if self.is_empty() => {
+ self.app_event_tx.send(AppEvent::ExitRequest);
+ (InputResult::None, true)
+ }
// -------------------------------------------------------------
// History navigation (Up / Down) โ only when the composer is not
// empty or when the cursor is at the correct position, to avoid
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index b09e030621..2f8a34fe13 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -244,6 +244,9 @@ impl ChatWidget {
}
fn on_error(&mut self, message: String) {
+ // Before emitting the error message, finalize the active exec as failed
+ // so spinners are replaced with a red โ marker.
+ self.finalize_active_exec_cell_as_failed();
self.add_to_history(history_cell::new_error_event(message));
self.bottom_pane.set_task_running(false);
self.running_commands.clear();
@@ -534,17 +537,7 @@ impl ChatWidget {
ev.result,
));
}
- fn interrupt_running_task(&mut self) {
- if self.bottom_pane.is_task_running() {
- self.active_exec_cell = None;
- self.running_commands.clear();
- self.bottom_pane.clear_ctrl_c_quit_hint();
- self.submit_op(Op::Interrupt);
- self.bottom_pane.set_task_running(false);
- self.stream.clear_all();
- self.request_redraw();
- }
- }
+
fn layout_areas(&self, area: Rect) -> [Rect; 2] {
Layout::vertical([
Constraint::Max(
@@ -657,48 +650,57 @@ impl ChatWidget {
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
- if key_event.kind == KeyEventKind::Press {
- self.bottom_pane.clear_ctrl_c_quit_hint();
+ match key_event {
+ KeyEvent {
+ code: KeyCode::Char('c'),
+ modifiers: crossterm::event::KeyModifiers::CONTROL,
+ kind: KeyEventKind::Press,
+ ..
+ } => {
+ self.on_ctrl_c();
+ return;
+ }
+ other if other.kind == KeyEventKind::Press => {
+ self.bottom_pane.clear_ctrl_c_quit_hint();
+ }
+ _ => {}
}
- // Alt+Up: Edit the most recent queued user message (if any).
- if matches!(
- key_event,
+ match key_event {
KeyEvent {
code: KeyCode::Up,
modifiers: KeyModifiers::ALT,
kind: KeyEventKind::Press,
..
- }
- ) && !self.queued_user_messages.is_empty()
- {
- // Prefer the most recently queued item.
- if let Some(user_message) = self.queued_user_messages.pop_back() {
- self.bottom_pane.set_composer_text(user_message.text);
- self.refresh_queued_user_messages();
- self.request_redraw();
- }
- return;
- }
-
- match self.bottom_pane.handle_key_event(key_event) {
- InputResult::Submitted(text) => {
- // If a task is running, queue the user input to be sent after the turn completes.
- let user_message = UserMessage {
- text,
- image_paths: self.bottom_pane.take_recent_submission_images(),
- };
- if self.bottom_pane.is_task_running() {
- self.queued_user_messages.push_back(user_message);
+ } if !self.queued_user_messages.is_empty() => {
+ // Prefer the most recently queued item.
+ if let Some(user_message) = self.queued_user_messages.pop_back() {
+ self.bottom_pane.set_composer_text(user_message.text);
self.refresh_queued_user_messages();
- } else {
- self.submit_user_message(user_message);
+ self.request_redraw();
}
}
- InputResult::Command(cmd) => {
- self.dispatch_command(cmd);
+ _ => {
+ match self.bottom_pane.handle_key_event(key_event) {
+ InputResult::Submitted(text) => {
+ // If a task is running, queue the user input to be sent after the turn completes.
+ let user_message = UserMessage {
+ text,
+ image_paths: self.bottom_pane.take_recent_submission_images(),
+ };
+ if self.bottom_pane.is_task_running() {
+ self.queued_user_messages.push_back(user_message);
+ self.refresh_queued_user_messages();
+ } else {
+ self.submit_user_message(user_message);
+ }
+ }
+ InputResult::Command(cmd) => {
+ self.dispatch_command(cmd);
+ }
+ InputResult::None => {}
+ }
}
- InputResult::None => {}
}
}
@@ -948,6 +950,16 @@ impl ChatWidget {
self.frame_requester.schedule_frame();
}
+ /// Mark the active exec cell as failed (โ) and flush it into history.
+ fn finalize_active_exec_cell_as_failed(&mut self) {
+ if let Some(cell) = self.active_exec_cell.take() {
+ let cell = cell.into_failed();
+ // Insert finalized exec into history and keep grouping consistent.
+ self.add_to_history(cell);
+ self.last_history_was_exec = true;
+ }
+ }
+
// If idle and there are queued inputs, submit exactly one to start the next turn.
fn maybe_send_next_queued_input(&mut self) {
if self.bottom_pane.is_task_running() {
@@ -1102,22 +1114,15 @@ impl ChatWidget {
}
/// Handle Ctrl-C key press.
- /// Returns CancellationEvent::Handled if the event was consumed by the UI, or
- /// CancellationEvent::Ignored if the caller should handle it (e.g. exit).
- pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent {
- match self.bottom_pane.on_ctrl_c() {
- CancellationEvent::Handled => return CancellationEvent::Handled,
- CancellationEvent::Ignored => {}
- }
- if self.bottom_pane.is_task_running() {
- self.interrupt_running_task();
- CancellationEvent::Ignored
- } else if self.bottom_pane.ctrl_c_quit_hint_visible() {
- self.submit_op(Op::Shutdown);
- CancellationEvent::Handled
- } else {
- self.bottom_pane.show_ctrl_c_quit_hint();
- CancellationEvent::Ignored
+ fn on_ctrl_c(&mut self) {
+ if self.bottom_pane.on_ctrl_c() == CancellationEvent::Ignored {
+ if self.bottom_pane.is_task_running() {
+ self.submit_op(Op::Interrupt);
+ } else if self.bottom_pane.ctrl_c_quit_hint_visible() {
+ self.submit_op(Op::Shutdown);
+ } else {
+ self.bottom_pane.show_ctrl_c_quit_hint();
+ }
}
}
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap
new file mode 100644
index 0000000000..a7e1f4c8a0
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap
@@ -0,0 +1,6 @@
+---
+source: tui/src/chatwidget/tests.rs
+expression: exec_blob
+---
+>_
+ โ โจ๏ธ sleep 1
diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs
index 223c65d121..659aafd556 100644
--- a/codex-rs/tui/src/chatwidget/tests.rs
+++ b/codex-rs/tui/src/chatwidget/tests.rs
@@ -370,6 +370,48 @@ fn exec_history_cell_shows_working_then_failed() {
);
}
+// Snapshot test: interrupting a running exec finalizes the active cell with a red โ
+// marker (replacing the spinner) and flushes it into history.
+#[test]
+fn interrupt_exec_marks_failed_snapshot() {
+ let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
+
+ // Begin a long-running command so we have an active exec cell with a spinner.
+ chat.handle_codex_event(Event {
+ id: "call-int".into(),
+ msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
+ call_id: "call-int".into(),
+ command: vec!["bash".into(), "-lc".into(), "sleep 1".into()],
+ cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
+ parsed_cmd: vec![
+ codex_core::parse_command::ParsedCommand::Unknown {
+ cmd: "sleep 1".into(),
+ }
+ .into(),
+ ],
+ }),
+ });
+
+ // Simulate the task being aborted (as if ESC was pressed), which should
+ // cause the active exec cell to be finalized as failed and flushed.
+ chat.handle_codex_event(Event {
+ id: "call-int".into(),
+ msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent {
+ reason: TurnAbortReason::Interrupted,
+ }),
+ });
+
+ let cells = drain_insert_history(&mut rx);
+ assert!(
+ !cells.is_empty(),
+ "expected finalized exec cell to be inserted into history"
+ );
+
+ // The first inserted cell should be the finalized exec; snapshot its text.
+ let exec_blob = lines_to_single_string(&cells[0]);
+ assert_snapshot!("interrupt_exec_marks_failed", exec_blob);
+}
+
#[test]
fn exec_history_extends_previous_when_consecutive() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index 32ecb2b71f..9376538518 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -175,6 +175,26 @@ impl WidgetRef for &ExecCell {
}
}
+impl ExecCell {
+ /// Convert an active exec cell into a failed, completed exec cell.
+ /// Replaces the spinner with a red โ and sets a zero/elapsed duration.
+ pub(crate) fn into_failed(mut self) -> ExecCell {
+ let elapsed = self
+ .start_time
+ .map(|st| st.elapsed())
+ .unwrap_or_else(|| Duration::from_millis(0));
+ self.start_time = None;
+ self.duration = Some(elapsed);
+ self.output = Some(CommandOutput {
+ exit_code: 1,
+ stdout: String::new(),
+ stderr: String::new(),
+ formatted_output: String::new(),
+ });
+ self
+ }
+}
+
#[derive(Debug)]
struct CompletedMcpToolCallWithImageOutput {
_image: DynamicImage,
From ab9250e7146473b9e9baeb4f8669dd883e477a7a Mon Sep 17 00:00:00 2001
From: Eric Traut
Date: Mon, 25 Aug 2025 21:42:10 -0700
Subject: [PATCH 06/10] Improved user message for rate-limit errors (#2695)
This PR improves the error message presented to the user when logged in
with ChatGPT and a rate-limit error occurs. In particular, it provides
the user with information about when the rate limit will be reset. It
removes older code that attempted to do the same but relied on parsing
of error messages that are not generated by the ChatGPT endpoint. The
new code uses newly-added error fields.
---
codex-rs/core/src/client.rs | 92 ++++++-----------------------
codex-rs/core/src/error.rs | 114 +++++++++++++++++++++++++++++++++---
2 files changed, 123 insertions(+), 83 deletions(-)
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
index b5f8e4dc32..2159eaf68a 100644
--- a/codex-rs/core/src/client.rs
+++ b/codex-rs/core/src/client.rs
@@ -1,6 +1,5 @@
use std::io::BufRead;
use std::path::Path;
-use std::sync::OnceLock;
use std::time::Duration;
use bytes::Bytes;
@@ -8,7 +7,6 @@ use codex_login::AuthManager;
use codex_login::AuthMode;
use eventsource_stream::Eventsource;
use futures::prelude::*;
-use regex_lite::Regex;
use reqwest::StatusCode;
use serde::Deserialize;
use serde::Serialize;
@@ -54,8 +52,11 @@ struct ErrorResponse {
#[derive(Debug, Deserialize)]
struct Error {
r#type: Option,
- code: Option,
message: Option,
+
+ // Optional fields available on "usage_limit_reached" and "usage_not_included" errors
+ plan_type: Option,
+ resets_in_seconds: Option,
}
#[derive(Debug, Clone)]
@@ -309,19 +310,20 @@ impl ModelClient {
if status == StatusCode::TOO_MANY_REQUESTS {
let body = res.json::().await.ok();
- if let Some(ErrorResponse {
- error:
- Error {
- r#type: Some(error_type),
- ..
- },
- }) = body
- {
- if error_type == "usage_limit_reached" {
+ if let Some(ErrorResponse { error }) = body {
+ if error.r#type.as_deref() == Some("usage_limit_reached") {
+ // Prefer the plan_type provided in the error message if present
+ // because it's more up to date than the one encoded in the auth
+ // token.
+ let plan_type = error
+ .plan_type
+ .or_else(|| auth.and_then(|a| a.get_plan_type()));
+ let resets_in_seconds = error.resets_in_seconds;
return Err(CodexErr::UsageLimitReached(UsageLimitReachedError {
- plan_type: auth.and_then(|a| a.get_plan_type()),
+ plan_type,
+ resets_in_seconds,
}));
- } else if error_type == "usage_not_included" {
+ } else if error.r#type.as_deref() == Some("usage_not_included") {
return Err(CodexErr::UsageNotIncluded);
}
}
@@ -569,9 +571,8 @@ async fn process_sse(
if let Some(error) = error {
match serde_json::from_value::(error.clone()) {
Ok(error) => {
- let delay = try_parse_retry_after(&error);
let message = error.message.unwrap_or_default();
- response_error = Some(CodexErr::Stream(message, delay));
+ response_error = Some(CodexErr::Stream(message, None));
}
Err(e) => {
debug!("failed to parse ErrorResponse: {e}");
@@ -659,40 +660,6 @@ async fn stream_from_fixture(
Ok(ResponseStream { rx_event })
}
-fn rate_limit_regex() -> &'static Regex {
- static RE: OnceLock = OnceLock::new();
-
- #[expect(clippy::unwrap_used)]
- RE.get_or_init(|| Regex::new(r"Please try again in (\d+(?:\.\d+)?)(s|ms)").unwrap())
-}
-
-fn try_parse_retry_after(err: &Error) -> Option {
- if err.code != Some("rate_limit_exceeded".to_string()) {
- return None;
- }
-
- // parse the Please try again in 1.898s format using regex
- let re = rate_limit_regex();
- if let Some(message) = &err.message
- && let Some(captures) = re.captures(message)
- {
- let seconds = captures.get(1);
- let unit = captures.get(2);
-
- if let (Some(value), Some(unit)) = (seconds, unit) {
- let value = value.as_str().parse::().ok()?;
- let unit = unit.as_str();
-
- if unit == "s" {
- return Some(Duration::from_secs_f64(value));
- } else if unit == "ms" {
- return Some(Duration::from_millis(value as u64));
- }
- }
- }
- None
-}
-
#[cfg(test)]
mod tests {
use super::*;
@@ -913,7 +880,7 @@ mod tests {
msg,
"Rate limit reached for gpt-5 in organization org-AAA on tokens per min (TPM): Limit 30000, Used 22999, Requested 12528. Please try again in 11.054s. Visit https://platform.openai.com/account/rate-limits to learn more."
);
- assert_eq!(*delay, Some(Duration::from_secs_f64(11.054)));
+ assert_eq!(*delay, None);
}
other => panic!("unexpected second event: {other:?}"),
}
@@ -1017,27 +984,4 @@ mod tests {
);
}
}
-
- #[test]
- fn test_try_parse_retry_after() {
- let err = Error {
- r#type: None,
- message: Some("Rate limit reached for gpt-5 in organization org- on tokens per min (TPM): Limit 1, Used 1, Requested 19304. Please try again in 28ms. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()),
- code: Some("rate_limit_exceeded".to_string()),
- };
-
- let delay = try_parse_retry_after(&err);
- assert_eq!(delay, Some(Duration::from_millis(28)));
- }
-
- #[test]
- fn test_try_parse_retry_after_no_delay() {
- let err = Error {
- r#type: None,
- message: Some("Rate limit reached for gpt-5 in organization on tokens per min (TPM): Limit 30000, Used 6899, Requested 24050. Please try again in 1.898s. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()),
- code: Some("rate_limit_exceeded".to_string()),
- };
- let delay = try_parse_retry_after(&err);
- assert_eq!(delay, Some(Duration::from_secs_f64(1.898)));
- }
}
diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs
index 4a9082f2a7..b05ff1a581 100644
--- a/codex-rs/core/src/error.rs
+++ b/codex-rs/core/src/error.rs
@@ -128,27 +128,70 @@ pub enum CodexErr {
#[derive(Debug)]
pub struct UsageLimitReachedError {
pub plan_type: Option,
+ pub resets_in_seconds: Option,
}
impl std::fmt::Display for UsageLimitReachedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ // Base message differs slightly for legacy ChatGPT Plus plan users.
if let Some(plan_type) = &self.plan_type
&& plan_type == "plus"
{
write!(
f,
- "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), or wait for limits to reset (every 5h and every week.)."
+ "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing) or try again"
)?;
+ if let Some(secs) = self.resets_in_seconds {
+ let reset_duration = format_reset_duration(secs);
+ write!(f, " in {reset_duration}.")?;
+ } else {
+ write!(f, " later.")?;
+ }
} else {
- write!(
- f,
- "You've hit your usage limit. Limits reset every 5h and every week."
- )?;
+ write!(f, "You've hit your usage limit.")?;
+
+ if let Some(secs) = self.resets_in_seconds {
+ let reset_duration = format_reset_duration(secs);
+ write!(f, " Try again in {reset_duration}.")?;
+ } else {
+ write!(f, " Try again later.")?;
+ }
}
+
Ok(())
}
}
+fn format_reset_duration(total_secs: u64) -> String {
+ let days = total_secs / 86_400;
+ let hours = (total_secs % 86_400) / 3_600;
+ let minutes = (total_secs % 3_600) / 60;
+
+ let mut parts: Vec = Vec::new();
+ if days > 0 {
+ let unit = if days == 1 { "day" } else { "days" };
+ parts.push(format!("{} {}", days, unit));
+ }
+ if hours > 0 {
+ let unit = if hours == 1 { "hour" } else { "hours" };
+ parts.push(format!("{} {}", hours, unit));
+ }
+ if minutes > 0 {
+ let unit = if minutes == 1 { "minute" } else { "minutes" };
+ parts.push(format!("{} {}", minutes, unit));
+ }
+
+ if parts.is_empty() {
+ return "less than a minute".to_string();
+ }
+
+ match parts.len() {
+ 1 => parts[0].clone(),
+ 2 => format!("{} {}", parts[0], parts[1]),
+ _ => format!("{} {} {}", parts[0], parts[1], parts[2]),
+ }
+}
+
#[derive(Debug)]
pub struct EnvVarError {
/// Name of the environment variable that is missing.
@@ -195,19 +238,23 @@ mod tests {
fn usage_limit_reached_error_formats_plus_plan() {
let err = UsageLimitReachedError {
plan_type: Some("plus".to_string()),
+ resets_in_seconds: None,
};
assert_eq!(
err.to_string(),
- "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), or wait for limits to reset (every 5h and every week.)."
+ "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing) or try again later."
);
}
#[test]
fn usage_limit_reached_error_formats_default_when_none() {
- let err = UsageLimitReachedError { plan_type: None };
+ let err = UsageLimitReachedError {
+ plan_type: None,
+ resets_in_seconds: None,
+ };
assert_eq!(
err.to_string(),
- "You've hit your usage limit. Limits reset every 5h and every week."
+ "You've hit your usage limit. Try again later."
);
}
@@ -215,10 +262,59 @@ mod tests {
fn usage_limit_reached_error_formats_default_for_other_plans() {
let err = UsageLimitReachedError {
plan_type: Some("pro".to_string()),
+ resets_in_seconds: None,
};
assert_eq!(
err.to_string(),
- "You've hit your usage limit. Limits reset every 5h and every week."
+ "You've hit your usage limit. Try again later."
+ );
+ }
+
+ #[test]
+ fn usage_limit_reached_includes_minutes_when_available() {
+ let err = UsageLimitReachedError {
+ plan_type: None,
+ resets_in_seconds: Some(5 * 60),
+ };
+ assert_eq!(
+ err.to_string(),
+ "You've hit your usage limit. Try again in 5 minutes."
+ );
+ }
+
+ #[test]
+ fn usage_limit_reached_includes_hours_and_minutes() {
+ let err = UsageLimitReachedError {
+ plan_type: Some("plus".to_string()),
+ resets_in_seconds: Some(3 * 3600 + 32 * 60),
+ };
+ assert_eq!(
+ err.to_string(),
+ "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing) or try again in 3 hours 32 minutes."
+ );
+ }
+
+ #[test]
+ fn usage_limit_reached_includes_days_hours_minutes() {
+ let err = UsageLimitReachedError {
+ plan_type: None,
+ resets_in_seconds: Some(2 * 86_400 + 3 * 3600 + 5 * 60),
+ };
+ assert_eq!(
+ err.to_string(),
+ "You've hit your usage limit. Try again in 2 days 3 hours 5 minutes."
+ );
+ }
+
+ #[test]
+ fn usage_limit_reached_less_than_minute() {
+ let err = UsageLimitReachedError {
+ plan_type: None,
+ resets_in_seconds: Some(30),
+ };
+ assert_eq!(
+ err.to_string(),
+ "You've hit your usage limit. Try again in less than a minute."
);
}
}
From d085f73a2a45cb855685553ee511517e6103794d Mon Sep 17 00:00:00 2001
From: ae
Date: Mon, 25 Aug 2025 22:47:26 -0700
Subject: [PATCH 07/10] [feat] reduce bottom padding to 1 line (#2704)
---
codex-rs/tui/src/bottom_pane/mod.rs | 17 ++---------------
..._chatwidget__tests__approval_modal_exec.snap | 2 +-
...chatwidget__tests__approval_modal_patch.snap | 3 +--
...__chatwidget__tests__chat_small_idle_h2.snap | 1 +
...__chatwidget__tests__chat_small_idle_h3.snap | 3 ++-
...hatwidget__tests__chat_small_running_h2.snap | 1 +
...hatwidget__tests__chat_small_running_h3.snap | 1 +
...chatwidget__tests__status_widget_active.snap | 3 +--
...tests__status_widget_and_approval_modal.snap | 2 +-
9 files changed, 11 insertions(+), 22 deletions(-)
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
index b9b05d559b..384d8bf41d 100644
--- a/codex-rs/tui/src/bottom_pane/mod.rs
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
@@ -72,7 +72,7 @@ pub(crate) struct BottomPaneParams {
}
impl BottomPane {
- const BOTTOM_PAD_LINES: u16 = 2;
+ const BOTTOM_PAD_LINES: u16 = 1;
pub fn new(params: BottomPaneParams) -> Self {
let enhanced_keys_supported = params.enhanced_keys_supported;
Self {
@@ -648,28 +648,15 @@ mod tests {
"expected Working header on top row: {top:?}"
);
- // Next row (spacer) is blank, and bottom two rows are blank padding
- let mut spacer = String::new();
+ // Last row should be blank padding; the row above should generally contain composer content.
let mut r_last = String::new();
- let mut r_last2 = String::new();
for x in 0..area.width {
- // Spacer row immediately below the status header lives at y=2.
- spacer.push(buf[(x, 2)].symbol().chars().next().unwrap_or(' '));
r_last.push(buf[(x, height - 1)].symbol().chars().next().unwrap_or(' '));
- r_last2.push(buf[(x, height - 2)].symbol().chars().next().unwrap_or(' '));
}
- assert!(
- spacer.trim().is_empty(),
- "expected spacer line blank: {spacer:?}"
- );
assert!(
r_last.trim().is_empty(),
"expected last row blank: {r_last:?}"
);
- assert!(
- r_last2.trim().is_empty(),
- "expected second-to-last row blank: {r_last2:?}"
- );
}
#[test]
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap
index 9a205be1d2..9173b72623 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap
@@ -1,5 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
+assertion_line: 728
expression: terminal.backend()
---
"? Codex wants to run echo hello world "
@@ -10,4 +11,3 @@ expression: terminal.backend()
"โ Yes Always No No, provide feedback "
"โ Approve and run the command "
" "
-" "
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap
index 2e79127faa..e449f0c576 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
-assertion_line: 690
+assertion_line: 763
expression: terminal.backend()
---
"The model wants to apply changes "
@@ -11,4 +11,3 @@ expression: terminal.backend()
"โ Yes No No, provide feedback "
"โ Approve and apply the changes "
" "
-" "
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap
index 48e6154eb2..bf8bd08ba1 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap
@@ -1,5 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
+assertion_line: 779
expression: terminal.backend()
---
"โ Ask Codex to do anything "
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap
index 7ab4159ade..1104773679 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap
@@ -1,7 +1,8 @@
---
source: tui/src/chatwidget/tests.rs
+assertion_line: 779
expression: terminal.backend()
---
+" "
"โ Ask Codex to do anything "
" "
-" "
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap
index 48e6154eb2..7f84982aad 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap
@@ -1,5 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
+assertion_line: 807
expression: terminal.backend()
---
"โ Ask Codex to do anything "
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap
index c77477a083..dbe9cbed21 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap
@@ -1,5 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
+assertion_line: 807
expression: terminal.backend()
---
" "
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap
index 79b55e2657..c598dbe19f 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
-assertion_line: 806
+assertion_line: 878
expression: terminal.backend()
---
" "
@@ -9,4 +9,3 @@ expression: terminal.backend()
"โ Ask Codex to do anything "
" โ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
" "
-" "
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap
index 5cd475248e..3abc8c7bcc 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap
@@ -1,5 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
+assertion_line: 851
expression: terminal.backend()
---
"? Codex wants to run echo 'hello world' "
@@ -10,4 +11,3 @@ expression: terminal.backend()
"โ Yes Always No No, provide feedback "
"โ Approve and run the command "
" "
-" "
From a4d34235bcdc0517d8905070d27676752f7ce2cb Mon Sep 17 00:00:00 2001
From: ae
Date: Mon, 25 Aug 2025 22:49:19 -0700
Subject: [PATCH 08/10] [fix] emoji padding (#2702)
- We use emojis as bullet icons of sorts, and in some common terminals
like Terminal or iTerm, these can render with insufficient padding
between the emoji and following text.
- This PR makes emoji look better in Terminal and iTerm, at the expense
of Ghostty. (All default fonts.)
# Terminal
# iTerm
# Ghostty
---
...t__tests__interrupt_exec_marks_failed.snap | 2 +-
codex-rs/tui/src/chatwidget/tests.rs | 2 +-
codex-rs/tui/src/history_cell.rs | 70 +++++++++++++------
3 files changed, 52 insertions(+), 22 deletions(-)
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap
index a7e1f4c8a0..89ce848b73 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap
@@ -3,4 +3,4 @@ source: tui/src/chatwidget/tests.rs
expression: exec_blob
---
>_
- โ โจ๏ธ sleep 1
+ โ โจโ sleep 1
diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs
index 659aafd556..fdfd7041fd 100644
--- a/codex-rs/tui/src/chatwidget/tests.rs
+++ b/codex-rs/tui/src/chatwidget/tests.rs
@@ -1207,7 +1207,7 @@ fn stream_error_is_rendered_to_history() {
let cells = drain_insert_history(&mut rx);
assert!(!cells.is_empty(), "expected a history cell for StreamError");
let blob = lines_to_single_string(cells.last().unwrap());
- assert!(blob.contains("โ "));
+ assert!(blob.contains("โ โ "));
assert!(blob.contains("stream error:"));
assert!(blob.contains("idle timeout waiting for SSE"));
}
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index 9376538518..079c853bcf 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -230,6 +230,20 @@ fn pretty_provider_name(id: &str) -> String {
title_case(id)
}
}
+/// Return the emoji followed by a hair space (U+200A) and a normal space.
+/// This creates a reasonable gap across different terminals,
+/// in particular Terminal.app and iTerm, which render too tightly with just a single normal space.
+///
+/// Improvements here could be to condition this behavior on terminal,
+/// or possibly on emoji.
+fn padded_emoji(emoji: &str) -> String {
+ format!("{emoji}\u{200A} ")
+}
+
+/// Convenience function over `padded_emoji()`.
+fn padded_emoji_with(emoji: &str, text: impl AsRef) -> String {
+ format!("{}{}", padded_emoji(emoji), text.as_ref())
+}
pub(crate) fn new_session_info(
config: &Config,
@@ -368,22 +382,22 @@ fn new_parsed_command(
for parsed in parsed_commands.iter() {
let text = match parsed {
- ParsedCommand::Read { name, .. } => format!("๐ {name}"),
+ ParsedCommand::Read { name, .. } => padded_emoji_with("๐", name),
ParsedCommand::ListFiles { cmd, path } => match path {
- Some(p) => format!("๐ {p}"),
- None => format!("๐ {cmd}"),
+ Some(p) => padded_emoji_with("๐", p),
+ None => padded_emoji_with("๐", cmd),
},
ParsedCommand::Search { query, path, cmd } => match (query, path) {
- (Some(q), Some(p)) => format!("๐ {q} in {p}"),
- (Some(q), None) => format!("๐ {q}"),
- (None, Some(p)) => format!("๐ {p}"),
- (None, None) => format!("๐ {cmd}"),
+ (Some(q), Some(p)) => padded_emoji_with("๐", format!("{q} in {p}")),
+ (Some(q), None) => padded_emoji_with("๐", q),
+ (None, Some(p)) => padded_emoji_with("๐", p),
+ (None, None) => padded_emoji_with("๐", cmd),
},
- ParsedCommand::Format { .. } => "โจ Formatting".to_string(),
- ParsedCommand::Test { cmd } => format!("๐งช {cmd}"),
- ParsedCommand::Lint { cmd, .. } => format!("๐งน {cmd}"),
- ParsedCommand::Unknown { cmd } => format!("โจ๏ธ {cmd}"),
- ParsedCommand::Noop { cmd } => format!("๐ {cmd}"),
+ ParsedCommand::Format { .. } => padded_emoji_with("โจ", "Formatting"),
+ ParsedCommand::Test { cmd } => padded_emoji_with("๐งช", cmd),
+ ParsedCommand::Lint { cmd, .. } => padded_emoji_with("๐งน", cmd),
+ ParsedCommand::Unknown { cmd } => padded_emoji_with("โจ", cmd),
+ ParsedCommand::Noop { cmd } => padded_emoji_with("๐", cmd),
};
// Prefix: two spaces, marker, space. Continuations align under the text block.
for (j, line_text) in text.lines().enumerate() {
@@ -469,8 +483,10 @@ pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> PlainHistor
}
pub(crate) fn new_web_search_call(query: String) -> PlainHistoryCell {
- let lines: Vec> =
- vec![Line::from(""), Line::from(vec!["๐ ".into(), query.into()])];
+ let lines: Vec> = vec![
+ Line::from(""),
+ Line::from(vec![padded_emoji("๐").into(), query.into()]),
+ ];
PlainHistoryCell { lines }
}
@@ -614,7 +630,10 @@ pub(crate) fn new_status_output(
};
// ๐ Workspace
- lines.push(Line::from(vec!["๐ ".into(), "Workspace".bold()]));
+ lines.push(Line::from(vec![
+ padded_emoji("๐").into(),
+ "Workspace".bold(),
+ ]));
// Path (home-relative, e.g., ~/code/project)
let cwd_str = match relativize_to_home(&config.cwd) {
Some(rel) if !rel.as_os_str().is_empty() => {
@@ -695,7 +714,10 @@ pub(crate) fn new_status_output(
if let Ok(auth) = try_read_auth_json(&auth_file)
&& let Some(tokens) = auth.tokens.clone()
{
- lines.push(Line::from(vec!["๐ค ".into(), "Account".bold()]));
+ lines.push(Line::from(vec![
+ padded_emoji("๐ค").into(),
+ "Account".bold(),
+ ]));
lines.push(Line::from(" โข Signed in with ChatGPT"));
let info = tokens.id_token;
@@ -722,7 +744,7 @@ pub(crate) fn new_status_output(
}
// ๐ง Model
- lines.push(Line::from(vec!["๐ง ".into(), "Model".bold()]));
+ lines.push(Line::from(vec![padded_emoji("๐ง ").into(), "Model".bold()]));
lines.push(Line::from(vec![
" โข Name: ".into(),
config.model.clone().into(),
@@ -873,13 +895,21 @@ pub(crate) fn new_mcp_tools_output(
}
pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
- let lines: Vec> = vec!["".into(), vec!["๐ ".red().bold(), message.into()].into()];
+ // Use a hair space (U+200A) to create a subtle, near-invisible separation
+ // before the text. VS16 is intentionally omitted to keep spacing tighter
+ // in terminals like Ghostty.
+ let lines: Vec> = vec![
+ "".into(),
+ vec![padded_emoji("๐").red().bold(), message.into()].into(),
+ ];
PlainHistoryCell { lines }
}
pub(crate) fn new_stream_error_event(message: String) -> PlainHistoryCell {
- let lines: Vec> =
- vec![vec!["โ ".magenta().bold(), message.dim()].into(), "".into()];
+ let lines: Vec> = vec![
+ vec![padded_emoji("โ ").magenta().bold(), message.dim()].into(),
+ "".into(),
+ ];
PlainHistoryCell { lines }
}
From d32e4f25cfd9313dd93296e5bd7e7ca4ff1d8a16 Mon Sep 17 00:00:00 2001
From: Eric Traut
Date: Mon, 25 Aug 2025 22:51:01 -0700
Subject: [PATCH 09/10] Added caps on retry config settings (#2701)
The CLI supports config settings `stream_max_retries` and
`request_max_retries` that allow users to override the default retry
counts (4 and 5, respectively). However, there's currently no cap placed
on these values. In theory, a user could configure an effectively
infinite retry count which could hammer the server. This PR adds a
reasonable cap (currently 100) to both of these values.
---
codex-rs/core/src/model_provider_info.rs | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs
index 0102591d2c..1a3e5a2767 100644
--- a/codex-rs/core/src/model_provider_info.rs
+++ b/codex-rs/core/src/model_provider_info.rs
@@ -17,6 +17,10 @@ use crate::error::EnvVarError;
const DEFAULT_STREAM_IDLE_TIMEOUT_MS: u64 = 300_000;
const DEFAULT_STREAM_MAX_RETRIES: u64 = 5;
const DEFAULT_REQUEST_MAX_RETRIES: u64 = 4;
+/// Hard cap for user-configured `stream_max_retries`.
+const MAX_STREAM_MAX_RETRIES: u64 = 100;
+/// Hard cap for user-configured `request_max_retries`.
+const MAX_REQUEST_MAX_RETRIES: u64 = 100;
/// Wire protocol that the provider speaks. Most third-party services only
/// implement the classic OpenAI Chat Completions JSON schema, whereas OpenAI
@@ -207,12 +211,14 @@ impl ModelProviderInfo {
pub fn request_max_retries(&self) -> u64 {
self.request_max_retries
.unwrap_or(DEFAULT_REQUEST_MAX_RETRIES)
+ .min(MAX_REQUEST_MAX_RETRIES)
}
/// Effective maximum number of stream reconnection attempts for this provider.
pub fn stream_max_retries(&self) -> u64 {
self.stream_max_retries
.unwrap_or(DEFAULT_STREAM_MAX_RETRIES)
+ .min(MAX_STREAM_MAX_RETRIES)
}
/// Effective idle timeout for streaming responses.
From 8192cf147e9db47c62289ce3e0332d063b59c89a Mon Sep 17 00:00:00 2001
From: ae
Date: Tue, 26 Aug 2025 00:27:19 -0700
Subject: [PATCH 10/10] [chore] Tweak AGENTS.md so agent doesn't always have to
test (#2706)
---
AGENTS.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/AGENTS.md b/AGENTS.md
index 672c0086d6..a6e2e565e0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,6 +11,7 @@ In the codex-rs folder where the rust code lives:
Before finalizing a change to `codex-rs`, run `just fmt` (in `codex-rs` directory) to format the code and `just fix -p ` (in `codex-rs` directory) to fix any linter issues in the code. Additionally, run the tests:
1. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `cargo test -p codex-tui`.
2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test --all-features`.
+When running interactively, ask the user before running these commands to finalize.
## TUI style conventions