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/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/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/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/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 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/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/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/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 95bc119ec5..a52e105628 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)] @@ -19,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"; @@ -181,10 +187,21 @@ 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") } +/// 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)] @@ -320,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(); } @@ -391,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] @@ -434,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!( @@ -445,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] @@ -494,4 +564,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/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/app.rs b/codex-rs/tui/src/app.rs index ad3b4f3372..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, @@ -416,7 +422,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..e53fe03676 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; @@ -34,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 { @@ -331,8 +334,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 +407,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 +617,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); } } @@ -649,7 +669,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), @@ -671,6 +691,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); @@ -692,37 +737,14 @@ 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() { - 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); } @@ -773,7 +795,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 +834,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) { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index be96a9e9ef..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,32 +471,52 @@ 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(), - 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("")); @@ -504,10 +526,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), } 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", }