From 4e29c4afe4b26f444d062fe22d7e590a9c499eff Mon Sep 17 00:00:00 2001 From: easong-openai Date: Thu, 7 Aug 2025 00:41:48 -0700 Subject: [PATCH 1/8] Add a UI hint when you press @ (#1903) This will make @ more discoverable (even though it is currently not super useful, IMO it should be used to bring files into context from outside CWD) --------- Co-authored-by: Gabriel Peal --- codex-rs/tui/src/app.rs | 4 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 44 ++++++++++++++----- .../tui/src/bottom_pane/file_search_popup.rs | 11 +++++ 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ad3b4f3372..3ecb4d0fcf 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -416,7 +416,9 @@ impl App<'_> { } } AppEvent::StartFileSearch(query) => { - self.file_search.on_user_query(query); + if !query.is_empty() { + self.file_search.on_user_query(query); + } } AppEvent::FileSearchResult { query, matches } => { if let AppState::Chat { widget } = &mut self.app_state { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 5d877253a6..2feab9a2f2 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -331,8 +331,9 @@ impl ChatComposer { /// - The cursor may be anywhere *inside* the token (including on the /// leading `@`). It does **not** need to be at the end of the line. /// - A token is delimited by ASCII whitespace (space, tab, newline). - /// - If the token under the cursor starts with `@` and contains at least - /// one additional character, that token (without `@`) is returned. + /// - If the token under the cursor starts with `@`, that token is + /// returned without the leading `@`. This includes the case where the + /// token is just "@" (empty query), which is used to trigger a UI hint fn current_at_token(textarea: &TextArea) -> Option { let cursor_offset = textarea.cursor(); let text = textarea.text(); @@ -403,14 +404,20 @@ impl ChatComposer { }; let left_at = token_left - .filter(|t| t.starts_with('@') && t.len() > 1) + .filter(|t| t.starts_with('@')) .map(|t| t[1..].to_string()); let right_at = token_right - .filter(|t| t.starts_with('@') && t.len() > 1) + .filter(|t| t.starts_with('@')) .map(|t| t[1..].to_string()); if at_whitespace { - return right_at.or(left_at); + if right_at.is_some() { + return right_at; + } + if token_left.is_some_and(|t| t == "@") { + return None; + } + return left_at; } if after_cursor.starts_with('@') { return right_at.or(left_at); @@ -607,16 +614,26 @@ impl ChatComposer { return; } - self.app_event_tx - .send(AppEvent::StartFileSearch(query.clone())); + if !query.is_empty() { + self.app_event_tx + .send(AppEvent::StartFileSearch(query.clone())); + } match &mut self.active_popup { ActivePopup::File(popup) => { - popup.set_query(&query); + if query.is_empty() { + popup.set_empty_prompt(); + } else { + popup.set_query(&query); + } } _ => { let mut popup = FileSearchPopup::new(); - popup.set_query(&query); + if query.is_empty() { + popup.set_empty_prompt(); + } else { + popup.set_query(&query); + } self.active_popup = ActivePopup::File(popup); } } @@ -773,7 +790,12 @@ mod tests { ("@👍", 2, Some("👍".to_string()), "Emoji token"), // Invalid cases (should return None) ("hello", 2, None, "No @ symbol"), - ("@", 1, None, "Only @ symbol"), + ( + "@", + 1, + Some("".to_string()), + "Only @ symbol triggers empty query", + ), ("@ hello", 2, None, "@ followed by space"), ("test @ world", 6, None, "@ with spaces around"), ]; @@ -807,7 +829,7 @@ mod tests { "Second token", ), // Edge cases - ("@", 0, None, "Only @ symbol"), + ("@", 0, Some("".to_string()), "Only @ symbol"), ("@a", 2, Some("a".to_string()), "Single character after @"), ("", 0, None, "Empty input"), ]; diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index c30a24f984..a811a22a8c 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -54,6 +54,17 @@ impl FileSearchPopup { } } + /// Put the popup into an "idle" state used for an empty query (just "@"). + /// Shows a hint instead of matches until the user types more characters. + pub(crate) fn set_empty_prompt(&mut self) { + self.display_query.clear(); + self.pending_query.clear(); + self.waiting = false; + self.matches.clear(); + // Reset selection/scroll state when showing the empty prompt. + self.state.reset(); + } + /// Replace matches when a `FileSearchResult` arrives. /// Replace matches. Only applied when `query` matches `pending_query`. pub(crate) fn set_matches(&mut self, query: &str, matches: Vec) { From 04b40ac1791d2ce89181a3803a53ea3a0f0d1f2e Mon Sep 17 00:00:00 2001 From: aibrahim-oai Date: Thu, 7 Aug 2025 00:45:47 -0700 Subject: [PATCH 2/8] Move used tokens next to the hints (#1930) Before: image After: image --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 57 +++++++++---------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 2feab9a2f2..ea3a4eddbe 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -8,6 +8,7 @@ use ratatui::layout::Layout; use ratatui::layout::Margin; use ratatui::layout::Rect; use ratatui::style::Color; +use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Styled; use ratatui::style::Stylize; @@ -666,7 +667,7 @@ impl WidgetRef for &ChatComposer { ActivePopup::None => { let bottom_line_rect = popup_rect; let key_hint_style = Style::default().fg(Color::Cyan); - let hint = if self.ctrl_c_quit_hint { + let mut hint = if self.ctrl_c_quit_hint { vec![ Span::from(" "), "Ctrl+C again".set_style(key_hint_style), @@ -688,6 +689,31 @@ impl WidgetRef for &ChatComposer { Span::from(" quit"), ] }; + + // Append token/context usage info to the footer hints when available. + if let Some(token_usage_info) = &self.token_usage_info { + let token_usage = &token_usage_info.token_usage; + hint.push(Span::from(" ")); + hint.push( + Span::from(format!("{} tokens used", token_usage.total_tokens)) + .style(Style::default().add_modifier(Modifier::DIM)), + ); + if let Some(context_window) = token_usage_info.model_context_window { + let percent_remaining: u8 = if context_window > 0 { + let percent = 100.0 + - (token_usage.total_tokens as f32 / context_window as f32 * 100.0); + percent.clamp(0.0, 100.0) as u8 + } else { + 100 + }; + hint.push(Span::from(" ")); + hint.push( + Span::from(format!("{percent_remaining}% context left")) + .style(Style::default().add_modifier(Modifier::DIM)), + ); + } + } + Line::from(hint) .style(Style::default().dim()) .render_ref(bottom_line_rect, buf); @@ -712,34 +738,7 @@ impl WidgetRef for &ChatComposer { let mut state = self.textarea_state.borrow_mut(); StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); if self.textarea.text().is_empty() { - let placeholder = if let Some(token_usage_info) = &self.token_usage_info { - let token_usage = &token_usage_info.token_usage; - let model_context_window = token_usage_info.model_context_window; - match (token_usage.total_tokens, model_context_window) { - (total_tokens, Some(context_window)) => { - let percent_remaining: u8 = if context_window > 0 { - // Calculate the percentage of context left. - let percent = - 100.0 - (total_tokens as f32 / context_window as f32 * 100.0); - percent.clamp(0.0, 100.0) as u8 - } else { - // If we don't have a context window, we cannot compute the - // percentage. - 100 - }; - // When https://github.com/openai/codex/issues/1257 is resolved, - // check if `percent_remaining < 25`, and if so, recommend - // /compact. - format!("{BASE_PLACEHOLDER_TEXT} — {percent_remaining}% context left") - } - (total_tokens, None) => { - format!("{BASE_PLACEHOLDER_TEXT} — {total_tokens} tokens used") - } - } - } else { - BASE_PLACEHOLDER_TEXT.to_string() - }; - Line::from(placeholder) + Line::from(BASE_PLACEHOLDER_TEXT) .style(Style::default().dim()) .render_ref(textarea_rect.inner(Margin::new(1, 0)), buf); } From eb80614a7c6a83abb7f03e5cb45f98d0b75ff343 Mon Sep 17 00:00:00 2001 From: Ed Bayes Date: Thu, 7 Aug 2025 00:46:45 -0700 Subject: [PATCH 3/8] Tint chat composer background (#1921) ## Summary - give the chat composer a subtle custom background and apply it across the full area drawn composer-bg - update turn interrupted to be more human readable CleanShot 2025-08-06 at 22 44 47@2x ## Testing - `cargo test --all-features` *(fails: `let` expressions in `core/src/client.rs` require newer rustc)* - `just fix` *(fails: `let` expressions in `core/src/client.rs` require newer rustc)* ------ https://chatgpt.com/codex/tasks/task_i_68941f32c1008322bbcc39ee1d29a526 --- codex-rs/core/src/codex.rs | 2 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 6 ++++++ codex-rs/tui/src/history_cell.rs | 6 ++---- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 4a4faa84ee..eb1bc4f9d8 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -688,7 +688,7 @@ impl AgentTask { let event = Event { id: self.sub_id, msg: EventMsg::Error(ErrorEvent { - message: "Turn interrupted".to_string(), + message: " Turn interrupted".to_string(), }), }; let tx_event = self.sess.tx_event.clone(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ea3a4eddbe..e53fe03676 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -35,6 +35,8 @@ const BASE_PLACEHOLDER_TEXT: &str = "..."; /// If the pasted content exceeds this number of characters, replace it with a /// placeholder in the UI. const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; +/// Background color used for the chat composer area. +const COMPOSER_BG_COLOR: Color = Color::Black; /// Result returned when the user interacts with the text area. pub enum InputResult { @@ -735,6 +737,10 @@ impl WidgetRef for &ChatComposer { let mut textarea_rect = textarea_rect; textarea_rect.width = textarea_rect.width.saturating_sub(1); textarea_rect.x += 1; + + // Fill only the textarea content region with a subtle background so it + // doesn't affect the hint line or popups and remains behind the text. + buf.set_style(textarea_rect, Style::default().bg(COMPOSER_BG_COLOR)); let mut state = self.textarea_state.borrow_mut(); StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); if self.textarea.text().is_empty() { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index be96a9e9ef..8df5340f85 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -504,10 +504,8 @@ impl HistoryCell { } pub(crate) fn new_error_event(message: String) -> Self { - let lines: Vec> = vec![ - vec!["ERROR: ".red().bold(), message.into()].into(), - "".into(), - ]; + let lines: Vec> = + vec![vec!["🖐 ".red().bold(), message.into()].into(), "".into()]; HistoryCell::ErrorEvent { view: TextBlock::new(lines), } From 28395df957dbbe2acd4944ef4c42501eb013f8d3 Mon Sep 17 00:00:00 2001 From: ae Date: Thu, 7 Aug 2025 01:13:36 -0700 Subject: [PATCH 4/8] [fix] fix absolute and % token counts (#1931) - For absolute, use non-cached input + output. - For estimating what % of the model's context window is used, we need to account for reasoning output tokens from prior turns being dropped from the context window. We approximate this here by subtracting reasoning output tokens from the total. This will be off for the current turn and pending function calls. We can improve it later. --- codex-rs/core/src/protocol.rs | 41 +++++++++++++++---- .../src/event_processor_with_human_output.rs | 5 +-- codex-rs/tui/src/history_cell.rs | 18 ++------ 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index e61fc0c3dc..c789798bcd 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -448,6 +448,28 @@ impl TokenUsage { pub fn is_zero(&self) -> bool { self.total_tokens == 0 } + + pub fn cached_input(&self) -> u64 { + self.cached_input_tokens.unwrap_or(0) + } + + pub fn non_cached_input(&self) -> u64 { + self.input_tokens.saturating_sub(self.cached_input()) + } + + /// Primary count for display as a single absolute value: non-cached input + output. + pub fn blended_total(&self) -> u64 { + self.non_cached_input() + self.output_tokens + } + + /// For estimating what % of the model's context window is used, we need to account + /// for reasoning output tokens from prior turns being dropped from the context window. + /// We approximate this here by subtracting reasoning output tokens from the total. + /// This will be off for the current turn and pending function calls. + pub fn tokens_in_context_window(&self) -> u64 { + self.total_tokens + .saturating_sub(self.reasoning_output_tokens.unwrap_or(0)) + } } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -463,17 +485,20 @@ impl From for FinalOutput { impl fmt::Display for FinalOutput { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let u = &self.token_usage; + let token_usage = &self.token_usage; write!( f, "Token usage: total={} input={}{} output={}{}", - u.total_tokens, - u.input_tokens, - u.cached_input_tokens - .map(|c| format!(" (cached {c})")) - .unwrap_or_default(), - u.output_tokens, - u.reasoning_output_tokens + token_usage.blended_total(), + token_usage.non_cached_input(), + if token_usage.cached_input() > 0 { + format!(" (+ {} cached)", token_usage.cached_input()) + } else { + String::new() + }, + token_usage.output_tokens, + token_usage + .reasoning_output_tokens .map(|r| format!(" (reasoning {r})")) .unwrap_or_default() ) diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 6b03ed7882..a2ae813183 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -21,7 +21,6 @@ use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol::TaskCompleteEvent; -use codex_core::protocol::TokenUsage; use codex_core::protocol::TurnDiffEvent; use owo_colors::OwoColorize; use owo_colors::Style; @@ -183,8 +182,8 @@ impl EventProcessor for EventProcessorWithHumanOutput { } return CodexStatus::InitiateShutdown; } - EventMsg::TokenCount(TokenUsage { total_tokens, .. }) => { - ts_println!(self, "tokens used: {total_tokens}"); + EventMsg::TokenCount(token_usage) => { + ts_println!(self, "tokens used: {}", token_usage.blended_total()); } EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { if !self.answer_started { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 8df5340f85..6beb79759f 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -474,27 +474,17 @@ impl HistoryCell { lines.push(Line::from("token usage".bold())); lines.push(Line::from(vec![ " input: ".bold(), - usage.input_tokens.to_string().into(), - ])); - lines.push(Line::from(vec![ - " cached input: ".bold(), - usage.cached_input_tokens.unwrap_or(0).to_string().into(), + usage.non_cached_input().to_string().into(), + " ".into(), + format!("(+ {} cached)", usage.cached_input()).into(), ])); lines.push(Line::from(vec![ " output: ".bold(), usage.output_tokens.to_string().into(), ])); - lines.push(Line::from(vec![ - " reasoning output: ".bold(), - usage - .reasoning_output_tokens - .unwrap_or(0) - .to_string() - .into(), - ])); lines.push(Line::from(vec![ " total: ".bold(), - usage.total_tokens.to_string().into(), + usage.blended_total().to_string().into(), ])); lines.push(Line::from("")); From 6d19b73edf0f58e7ab2e5288d4048a640623eb45 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Thu, 7 Aug 2025 01:17:33 -0700 Subject: [PATCH 5/8] Add logout command to CLI and TUI (#1932) ## Summary - support `codex logout` via new subcommand and helper that removes the stored `auth.json` - expose a `logout` function in `codex-login` and test it - add `/logout` slash command in the TUI; command list is filtered when not logged in and the handler deletes `auth.json` then exits ## Testing - `just fix` *(fails: failed to get `diffy` from crates.io)* - `cargo test --all-features` *(fails: failed to get `diffy` from crates.io)* ------ https://chatgpt.com/codex/tasks/task_i_68945c3facac832ca83d48499716fb51 --- codex-rs/cli/src/login.rs | 20 ++++++++++++++++++++ codex-rs/cli/src/main.rs | 14 ++++++++++++++ codex-rs/login/src/lib.rs | 23 +++++++++++++++++++++++ codex-rs/tui/src/app.rs | 6 ++++++ codex-rs/tui/src/slash_command.rs | 2 ++ 5 files changed, 65 insertions(+) diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 4fa13f0cc6..4291e06820 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -8,6 +8,7 @@ use codex_login::OPENAI_API_KEY_ENV_VAR; use codex_login::load_auth; use codex_login::login_with_api_key; use codex_login::login_with_chatgpt; +use codex_login::logout; pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides); @@ -80,6 +81,25 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { } } +pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { + let config = load_config_or_exit(cli_config_overrides); + + match logout(&config.codex_home) { + Ok(true) => { + eprintln!("Successfully logged out"); + std::process::exit(0); + } + Ok(false) => { + eprintln!("Not logged in"); + std::process::exit(0); + } + Err(e) => { + eprintln!("Error logging out: {e}"); + std::process::exit(1); + } + } +} + fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config { let cli_overrides = match cli_config_overrides.parse_overrides() { Ok(v) => v, diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index c43365c7d5..9aef22c09f 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -10,6 +10,7 @@ use codex_cli::SeatbeltCommand; use codex_cli::login::run_login_status; use codex_cli::login::run_login_with_api_key; use codex_cli::login::run_login_with_chatgpt; +use codex_cli::login::run_logout; use codex_cli::proto; use codex_common::CliConfigOverrides; use codex_exec::Cli as ExecCli; @@ -48,6 +49,9 @@ enum Subcommand { /// Manage login. Login(LoginCommand), + /// Remove stored authentication credentials. + Logout(LogoutCommand), + /// Experimental: run Codex as an MCP server. Mcp, @@ -106,6 +110,12 @@ enum LoginSubcommand { Status, } +#[derive(Debug, Parser)] +struct LogoutCommand { + #[clap(skip)] + config_overrides: CliConfigOverrides, +} + fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { cli_main(codex_linux_sandbox_exe).await?; @@ -147,6 +157,10 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() } } } + Some(Subcommand::Logout(mut logout_cli)) => { + prepend_config_flags(&mut logout_cli.config_overrides, cli.config_overrides); + run_logout(logout_cli.config_overrides).await; + } Some(Subcommand::Proto(mut proto_cli)) => { prepend_config_flags(&mut proto_cli.config_overrides, cli.config_overrides); proto::run_main(proto_cli).await?; diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 95bc119ec5..f35191ced9 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -6,6 +6,7 @@ use serde::Serialize; use std::env; use std::fs::File; use std::fs::OpenOptions; +use std::fs::remove_file; use std::io::Read; use std::io::Write; #[cfg(unix)] @@ -185,6 +186,17 @@ fn get_auth_file(codex_home: &Path) -> PathBuf { codex_home.join("auth.json") } +/// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)` +/// if a file was removed, `Ok(false)` if no auth file was present. +pub fn logout(codex_home: &Path) -> std::io::Result { + let auth_file = get_auth_file(codex_home); + match remove_file(&auth_file) { + Ok(_) => Ok(true), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(err), + } +} + /// Represents a running login subprocess. The child can be killed by holding /// the mutex and calling `kill()`. #[derive(Debug, Clone)] @@ -494,4 +506,15 @@ mod tests { assert!(auth.get_token_data().await.is_err()); } + + #[test] + fn logout_removes_auth_file() -> Result<(), std::io::Error> { + let dir = tempdir()?; + login_with_api_key(dir.path(), "sk-test-key")?; + assert!(dir.path().join("auth.json").exists()); + let removed = logout(dir.path())?; + assert!(removed); + assert!(!dir.path().join("auth.json").exists()); + Ok(()) + } } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3ecb4d0fcf..1ba8883b0b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -328,6 +328,12 @@ impl App<'_> { SlashCommand::Quit => { break; } + SlashCommand::Logout => { + if let Err(e) = codex_login::logout(&self.config.codex_home) { + tracing::error!("failed to logout: {e}"); + } + break; + } SlashCommand::Diff => { let (is_git_repo, diff_text) = match get_git_diff() { Ok(v) => v, diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 75bca641ac..0513d64443 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -17,6 +17,7 @@ pub enum SlashCommand { Compact, Diff, Status, + Logout, Quit, #[cfg(debug_assertions)] TestApproval, @@ -32,6 +33,7 @@ impl SlashCommand { SlashCommand::Quit => "Exit the application", SlashCommand::Diff => "Show git diff (including untracked files)", SlashCommand::Status => "Show current session configuration and token usage", + SlashCommand::Logout => "Log out of Codex", #[cfg(debug_assertions)] SlashCommand::TestApproval => "Test approval request", } From 0334476894ef8fd02a5dda1c9d80b9ec82d36d25 Mon Sep 17 00:00:00 2001 From: ae Date: Thu, 7 Aug 2025 01:27:45 -0700 Subject: [PATCH 6/8] feat: parse info from auth.json and show in /status (#1923) - `/status` renders ``` signed in with chatgpt login: example@example.com plan: plus ``` - Setup for using this info in a few more places. --------- Co-authored-by: Michael Bolin --- codex-rs/Cargo.lock | 3 + codex-rs/core/tests/client.rs | 26 +++---- codex-rs/login/Cargo.toml | 3 + codex-rs/login/src/lib.rs | 118 +++++++++++++++++++++++-------- codex-rs/login/src/token_data.rs | 117 ++++++++++++++++++++++++++++++ codex-rs/tui/src/history_cell.rs | 34 ++++++++- 6 files changed, 254 insertions(+), 47 deletions(-) create mode 100644 codex-rs/login/src/token_data.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index aa5398dde1..eabd9f35db 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -792,11 +792,14 @@ dependencies = [ name = "codex-login" version = "0.0.0" dependencies = [ + "base64 0.22.1", "chrono", + "pretty_assertions", "reqwest", "serde", "serde_json", "tempfile", + "thiserror 2.0.12", "tokio", ] diff --git a/codex-rs/core/tests/client.rs b/codex-rs/core/tests/client.rs index 60eb922474..2148e874bc 100644 --- a/codex-rs/core/tests/client.rs +++ b/codex-rs/core/tests/client.rs @@ -290,13 +290,10 @@ async fn chatgpt_auth_sends_correct_request() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); - let CodexSpawnOk { codex, .. } = Codex::spawn( - config, - Some(auth_from_token("Access Token".to_string())), - ctrl_c.clone(), - ) - .await - .unwrap(); + let CodexSpawnOk { codex, .. } = + Codex::spawn(config, Some(create_dummy_codex_auth()), ctrl_c.clone()) + .await + .unwrap(); codex .submit(Op::UserInput { @@ -541,13 +538,10 @@ async fn env_var_overrides_loaded_auth() { config.model_provider = provider; let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); - let CodexSpawnOk { codex, .. } = Codex::spawn( - config, - Some(auth_from_token("Default Access Token".to_string())), - ctrl_c.clone(), - ) - .await - .unwrap(); + let CodexSpawnOk { codex, .. } = + Codex::spawn(config, Some(create_dummy_codex_auth()), ctrl_c.clone()) + .await + .unwrap(); codex .submit(Op::UserInput { @@ -561,7 +555,7 @@ async fn env_var_overrides_loaded_auth() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; } -fn auth_from_token(id_token: String) -> CodexAuth { +fn create_dummy_codex_auth() -> CodexAuth { CodexAuth::new( None, AuthMode::ChatGPT, @@ -569,7 +563,7 @@ fn auth_from_token(id_token: String) -> CodexAuth { Some(AuthDotJson { openai_api_key: None, tokens: Some(TokenData { - id_token, + id_token: Default::default(), access_token: "Access Token".to_string(), refresh_token: "test".to_string(), account_id: Some("account_id".to_string()), diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index 650291b3bc..a290c01eb6 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -7,10 +7,12 @@ version = { workspace = true } workspace = true [dependencies] +base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } reqwest = { version = "0.12", features = ["json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +thiserror = "2.0.12" tokio = { version = "1", features = [ "io-std", "macros", @@ -20,4 +22,5 @@ tokio = { version = "1", features = [ ] } [dev-dependencies] +pretty_assertions = "1.4.1" tempfile = "3" diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index f35191ced9..a52e105628 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -20,6 +20,11 @@ use std::sync::Mutex; use std::time::Duration; use tokio::process::Command; +pub use crate::token_data::TokenData; +use crate::token_data::parse_id_token; + +mod token_data; + const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py"); const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; @@ -182,7 +187,7 @@ pub fn load_auth(codex_home: &Path, include_env_var: bool) -> std::io::Result PathBuf { +pub fn get_auth_file(codex_home: &Path) -> PathBuf { codex_home.join("auth.json") } @@ -332,7 +337,7 @@ async fn update_tokens( let mut auth_dot_json = try_read_auth_json(auth_file)?; let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default); - tokens.id_token = id_token.to_string(); + tokens.id_token = parse_id_token(&id_token).map_err(std::io::Error::other)?; if let Some(access_token) = access_token { tokens.access_token = access_token.to_string(); } @@ -403,22 +408,12 @@ pub struct AuthDotJson { pub last_refresh: Option>, } -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)] -pub struct TokenData { - /// This is a JWT. - pub id_token: String, - - /// This is a JWT. - pub access_token: String, - - pub refresh_token: String, - - pub account_id: Option, -} - #[cfg(test)] mod tests { use super::*; + use crate::token_data::IdTokenInfo; + use base64::Engine; + use pretty_assertions::assert_eq; use tempfile::tempdir; #[test] @@ -446,10 +441,35 @@ mod tests { } #[tokio::test] - #[expect(clippy::unwrap_used)] + #[expect(clippy::expect_used, clippy::unwrap_used)] async fn loads_token_data_from_auth_json() { let dir = tempdir().unwrap(); let auth_file = dir.path().join("auth.json"); + // Create a minimal valid JWT for the id_token field. + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ + "email": "user@example.com", + "email_verified": true, + "https://api.openai.com/auth": { + "chatgpt_account_id": "bc3618e3-489d-4d49-9362-1561dc53ba53", + "chatgpt_plan_type": "pro", + "chatgpt_user_id": "user-12345", + "user_id": "user-12345", + } + }); + let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b); + let header_b64 = b64(&serde_json::to_vec(&header).unwrap()); + let payload_b64 = b64(&serde_json::to_vec(&payload).unwrap()); + let signature_b64 = b64(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); std::fs::write( auth_file, format!( @@ -457,30 +477,68 @@ mod tests { {{ "OPENAI_API_KEY": null, "tokens": {{ - "id_token": "test-id-token", + "id_token": "{fake_jwt}", "access_token": "test-access-token", "refresh_token": "test-refresh-token" }}, - "last_refresh": "{}" + "last_refresh": "2025-08-06T20:41:36.232376Z" }} "#, - Utc::now().to_rfc3339() ), ) .unwrap(); - let auth = load_auth(dir.path(), false).unwrap().unwrap(); - assert_eq!(auth.mode, AuthMode::ChatGPT); - assert_eq!(auth.api_key, None); + let CodexAuth { + api_key, + mode, + auth_dot_json, + auth_file, + } = load_auth(dir.path(), false).unwrap().unwrap(); + assert_eq!(None, api_key); + assert_eq!(AuthMode::ChatGPT, mode); + assert_eq!(dir.path().join("auth.json"), auth_file); + + let guard = auth_dot_json.lock().unwrap(); + let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); + assert_eq!( - auth.get_token_data().await.unwrap(), - TokenData { - id_token: "test-id-token".to_string(), - access_token: "test-access-token".to_string(), - refresh_token: "test-refresh-token".to_string(), - account_id: None, - } - ); + &AuthDotJson { + openai_api_key: None, + tokens: Some(TokenData { + id_token: IdTokenInfo { + email: Some("user@example.com".to_string()), + chatgpt_plan_type: Some("pro".to_string()), + }, + access_token: "test-access-token".to_string(), + refresh_token: "test-refresh-token".to_string(), + account_id: None, + }), + last_refresh: Some( + DateTime::parse_from_rfc3339("2025-08-06T20:41:36.232376Z") + .unwrap() + .with_timezone(&Utc) + ), + }, + auth_dot_json + ) + } + + #[test] + #[expect(clippy::expect_used, clippy::unwrap_used)] + fn id_token_info_handles_missing_fields() { + // Payload without email or plan should yield None values. + let header = serde_json::json!({"alg": "none", "typ": "JWT"}); + let payload = serde_json::json!({"sub": "123"}); + let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&payload).unwrap()); + let signature_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"sig"); + let jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let info = parse_id_token(&jwt).expect("should parse"); + assert!(info.email.is_none()); + assert!(info.chatgpt_plan_type.is_none()); } #[tokio::test] diff --git a/codex-rs/login/src/token_data.rs b/codex-rs/login/src/token_data.rs new file mode 100644 index 0000000000..55b51b9d44 --- /dev/null +++ b/codex-rs/login/src/token_data.rs @@ -0,0 +1,117 @@ +use base64::Engine; +use serde::Deserialize; +use serde::Serialize; +use thiserror::Error; + +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)] +pub struct TokenData { + /// Flat info parsed from the JWT in auth.json. + #[serde(deserialize_with = "deserialize_id_token")] + pub id_token: IdTokenInfo, + + /// This is a JWT. + pub access_token: String, + + pub refresh_token: String, + + pub account_id: Option, +} + +/// Flat subset of useful claims in id_token from auth.json. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] +pub struct IdTokenInfo { + pub email: Option, + /// The ChatGPT subscription plan type + /// (e.g., "free", "plus", "pro", "business", "enterprise", "edu"). + /// (Note: ae has not verified that those are the exact values.) + pub chatgpt_plan_type: Option, +} + +#[derive(Deserialize)] +struct IdClaims { + #[serde(default)] + email: Option, + #[serde(rename = "https://api.openai.com/auth", default)] + auth: Option, +} + +#[derive(Deserialize)] +struct AuthClaims { + #[serde(default)] + chatgpt_plan_type: Option, +} + +#[derive(Debug, Error)] +pub enum IdTokenInfoError { + #[error("invalid ID token format")] + InvalidFormat, + #[error(transparent)] + Base64(#[from] base64::DecodeError), + #[error(transparent)] + Json(#[from] serde_json::Error), +} + +pub(crate) fn parse_id_token(id_token: &str) -> Result { + // JWT format: header.payload.signature + let mut parts = id_token.split('.'); + let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) { + (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), + _ => return Err(IdTokenInfoError::InvalidFormat), + }; + + let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64)?; + let claims: IdClaims = serde_json::from_slice(&payload_bytes)?; + + Ok(IdTokenInfo { + email: claims.email, + chatgpt_plan_type: claims.auth.and_then(|a| a.chatgpt_plan_type), + }) +} + +fn deserialize_id_token<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + parse_id_token(&s).map_err(serde::de::Error::custom) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Serialize; + + #[test] + #[expect(clippy::expect_used, clippy::unwrap_used)] + fn id_token_info_parses_email_and_plan() { + // Build a fake JWT with a URL-safe base64 payload containing email and plan. + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_plan_type": "pro" + } + }); + + fn b64url_no_pad(bytes: &[u8]) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + } + + let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap()); + let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap()); + let signature_b64 = b64url_no_pad(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let info = parse_id_token(&fake_jwt).expect("should parse"); + assert_eq!(info.email.as_deref(), Some("user@example.com")); + assert_eq!(info.chatgpt_plan_type.as_deref(), Some("pro")); + } +} diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 6beb79759f..d7a06fa576 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -15,6 +15,8 @@ use codex_core::protocol::FileChange; use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol::TokenUsage; +use codex_login::get_auth_file; +use codex_login::try_read_auth_json; use image::DynamicImage; use image::ImageReader; use mcp_types::EmbeddedResourceResource; @@ -469,8 +471,38 @@ impl HistoryCell { lines.push(Line::from(vec![format!("{key}: ").bold(), value.into()])); } - // Token usage lines.push(Line::from("")); + + // Auth + let auth_file = get_auth_file(&config.codex_home); + if let Ok(auth) = try_read_auth_json(&auth_file) { + if auth.tokens.as_ref().is_some() { + lines.push(Line::from("signed in with chatgpt".bold())); + + if let Some(tokens) = auth.tokens.as_ref() { + let info = tokens.id_token.clone(); + if let Some(email) = info.email { + lines.push(Line::from(vec![" login: ".bold(), email.into()])); + } + + match auth.openai_api_key.as_deref() { + Some(key) if !key.is_empty() => { + lines.push(Line::from(" using api key")); + } + _ => { + let plan_text = info + .chatgpt_plan_type + .unwrap_or_else(|| "Unknown".to_string()); + lines.push(Line::from(vec![" plan: ".bold(), plan_text.into()])); + } + } + } + + lines.push(Line::from("")); + } + } + + // Token usage lines.push(Line::from("token usage".bold())); lines.push(Line::from(vec![ " input: ".bold(), From 13982d6b4e79415ffa435af1cb0f584807062275 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 7 Aug 2025 01:30:13 -0700 Subject: [PATCH 7/8] chore: fix outstanding review comments from the bot on #1919 (#1928) I should have read the comments before submitting! --- codex-rs/config.md | 2 +- codex-rs/core/src/config.rs | 6 +++--- codex-rs/core/src/config_types.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/codex-rs/config.md b/codex-rs/config.md index e044684426..68687c560c 100644 --- a/codex-rs/config.md +++ b/codex-rs/config.md @@ -276,7 +276,7 @@ sandbox_mode = "workspace-write" # Extra settings that only apply when `sandbox = "workspace-write"`. [sandbox_workspace_write] # By default, the cwd for the Codex session will be writable as well as $TMPDIR -# if set) and /tmp (if it exists). Setting the respective options to `true` +# (if set) and /tmp (if it exists). Setting the respective options to `true` # will override those defaults. exclude_tmpdir_env_var = false exclude_slash_tmp = false diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index a2e0618a7f..081306dab1 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -4,7 +4,7 @@ use crate::config_types::McpServerConfig; use crate::config_types::ReasoningEffort; use crate::config_types::ReasoningSummary; use crate::config_types::SandboxMode; -use crate::config_types::SandboxWorkplaceWrite; +use crate::config_types::SandboxWorkspaceWrite; use crate::config_types::ShellEnvironmentPolicy; use crate::config_types::ShellEnvironmentPolicyToml; use crate::config_types::Tui; @@ -282,7 +282,7 @@ pub struct ConfigToml { pub sandbox_mode: Option, /// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`. - pub sandbox_workspace_write: Option, + pub sandbox_workspace_write: Option, /// Disable server-side response storage (sends the full conversation /// context with every request). Currently necessary for OpenAI customers @@ -361,7 +361,7 @@ impl ConfigToml { match resolved_sandbox_mode { SandboxMode::ReadOnly => SandboxPolicy::new_read_only_policy(), SandboxMode::WorkspaceWrite => match self.sandbox_workspace_write.as_ref() { - Some(SandboxWorkplaceWrite { + Some(SandboxWorkspaceWrite { writable_roots, network_access, exclude_tmpdir_env_var, diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index a81c20502b..d584a0494d 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -93,7 +93,7 @@ pub enum SandboxMode { } #[derive(Deserialize, Debug, Clone, PartialEq, Default)] -pub struct SandboxWorkplaceWrite { +pub struct SandboxWorkspaceWrite { #[serde(default)] pub writable_roots: Vec, #[serde(default)] From 108d61f15bbb99bb7d1dae99ff479a092934afed Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 7 Aug 2025 01:33:36 -0700 Subject: [PATCH 8/8] feat: change shell_environment_policy to default to inherit="all" --- codex-rs/config.md | 9 ++++----- codex-rs/core/src/config_types.rs | 5 +++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/codex-rs/config.md b/codex-rs/config.md index 68687c560c..4baac4e0ea 100644 --- a/codex-rs/config.md +++ b/codex-rs/config.md @@ -339,13 +339,12 @@ disable_response_storage = true ## shell_environment_policy -Codex spawns subprocesses (e.g. when executing a `local_shell` tool-call suggested by the assistant). By default it passes **only a minimal core subset** of your environment to those subprocesses to avoid leaking credentials. You can tune this behavior via the **`shell_environment_policy`** block in -`config.toml`: +Codex spawns subprocesses (e.g. when executing a `local_shell` tool-call suggested by the assistant). By default it now passes **your full environment** to those subprocesses. You can tune this behavior via the **`shell_environment_policy`** block in `config.toml`: ```toml [shell_environment_policy] -# inherit can be "core" (default), "all", or "none" -inherit = "core" +# inherit can be "all" (default), "core", or "none" +inherit = "all" # set to true to *skip* the filter for `"*KEY*"` and `"*TOKEN*"` ignore_default_excludes = false # exclude patterns (case-insensitive globs) @@ -358,7 +357,7 @@ include_only = ["PATH", "HOME"] | Field | Type | Default | Description | | ------------------------- | -------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `inherit` | string | `core` | Starting template for the environment:
`core` (`HOME`, `PATH`, `USER`, …), `all` (clone full parent env), or `none` (start empty). | +| `inherit` | string | `all` | Starting template for the environment:
`all` (clone full parent env), `core` (`HOME`, `PATH`, `USER`, …), or `none` (start empty). | | `ignore_default_excludes` | boolean | `false` | When `false`, Codex removes any var whose **name** contains `KEY`, `SECRET`, or `TOKEN` (case-insensitive) before other rules run. | | `exclude` | array<string> | `[]` | Case-insensitive glob patterns to drop after the default filter.
Examples: `"AWS_*"`, `"AZURE_*"`. | | `set` | table<string,string> | `{}` | Explicit key/value overrides or additions – always win over inherited values. | diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index d584a0494d..291dcb6422 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -109,10 +109,10 @@ pub struct SandboxWorkspaceWrite { pub enum ShellEnvironmentPolicyInherit { /// "Core" environment variables for the platform. On UNIX, this would /// include HOME, LOGNAME, PATH, SHELL, and USER, among others. - #[default] Core, /// Inherits the full environment from the parent process. + #[default] All, /// Do not inherit any environment variables from the parent process. @@ -171,7 +171,8 @@ pub struct ShellEnvironmentPolicy { impl From for ShellEnvironmentPolicy { fn from(toml: ShellEnvironmentPolicyToml) -> Self { - let inherit = toml.inherit.unwrap_or(ShellEnvironmentPolicyInherit::Core); + // Default to inheriting the full environment when not specified. + let inherit = toml.inherit.unwrap_or(ShellEnvironmentPolicyInherit::All); let ignore_default_excludes = toml.ignore_default_excludes.unwrap_or(false); let exclude = toml .exclude