Merge 4670b0c561 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2025-09-25 12:13:58 -07:00
committed by GitHub
39 changed files with 1729 additions and 472 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
- name: Annotate locations with typos
uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1
- name: Codespell
uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2
uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2.1
with:
ignore_words_file: .codespellignore
skip: frame*.txt
+35 -1
View File
@@ -56,7 +56,7 @@ checksum = "8fac2ce611db8b8cee9b2aa886ca03c924e9da5e5295d0dbd0526e5d0b0710f7"
dependencies = [
"allocative_derive",
"bumpalo",
"ctor",
"ctor 0.1.26",
"hashbrown 0.14.5",
"num-bigint",
]
@@ -605,6 +605,7 @@ dependencies = [
"codex-core",
"codex-linux-sandbox",
"dotenvy",
"libc",
"tempfile",
"tokio",
]
@@ -641,6 +642,8 @@ dependencies = [
"codex-protocol",
"codex-protocol-ts",
"codex-tui",
"ctor 0.5.0",
"libc",
"owo-colors",
"predicates",
"pretty_assertions",
@@ -1213,6 +1216,22 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "ctor"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb"
dependencies = [
"ctor-proc-macro",
"dtor",
]
[[package]]
name = "ctor-proc-macro"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
[[package]]
name = "darling"
version = "0.20.11"
@@ -1463,6 +1482,21 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dtor"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e58a0764cddb55ab28955347b45be00ade43d4d6f3ba4bf3dc354e4ec9432934"
dependencies = [
"dtor-proc-macro",
]
[[package]]
name = "dtor-proc-macro"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
[[package]]
name = "dupe"
version = "0.9.1"
+1
View File
@@ -72,6 +72,7 @@ clap = "4"
clap_complete = "4"
color-eyre = "0.6.3"
crossterm = "0.28.1"
ctor = "0.5.0"
derive_more = "2"
diffy = "0.4.2"
dirs = "6"
+1
View File
@@ -16,5 +16,6 @@ codex-apply-patch = { workspace = true }
codex-core = { workspace = true }
codex-linux-sandbox = { workspace = true }
dotenvy = { workspace = true }
libc = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread"] }
+31 -5
View File
@@ -7,10 +7,28 @@ use codex_core::CODEX_APPLY_PATCH_ARG1;
use std::os::unix::fs::symlink;
use tempfile::TempDir;
mod openai_api_key_env_var;
use openai_api_key_env_var::extract_locked_openai_api_key;
const LINUX_SANDBOX_ARG0: &str = "codex-linux-sandbox";
const APPLY_PATCH_ARG0: &str = "apply_patch";
const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch";
/// Arguments supplied to the async entrypoint invoked by [`arg0_dispatch_or_else`].
///
#[derive(Debug)]
pub struct PreMainArgs {
pub codex_linux_sandbox_exe: Option<PathBuf>,
/// Value of the `OPENAI_API_KEY` environment variable that was set at
/// startup, if any.
///
/// If `Some`, the key has already been removed from the environment and an
/// attempt was made to `mlock(2)` the string's allocation to keep it
/// resident in memory.
pub openai_api_key: Option<String>,
}
/// While we want to deploy the Codex CLI as a single executable for simplicity,
/// we also want to expose some of its functionality as distinct CLIs, so we use
/// the "arg0 trick" to determine which CLI to dispatch. This effectively allows
@@ -26,15 +44,15 @@ const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch";
/// 3. Derive the path to the current executable (so children can re-invoke the
/// sandbox) when running on Linux.
/// 4. Execute the provided async `main_fn` inside that runtime, forwarding any
/// error. Note that `main_fn` receives `codex_linux_sandbox_exe:
/// Option<PathBuf>`, as an argument, which is generally needed as part of
/// constructing [`codex_core::config::Config`].
/// error. The closure receives [`PreMainArgs`], which includes the optional
/// path to the linux sandbox helper as well as the OpenAI API key if the
/// caller requested it.
///
/// This function should be used to wrap any `main()` function in binary crates
/// in this workspace that depends on these helper CLIs.
pub fn arg0_dispatch_or_else<F, Fut>(main_fn: F) -> anyhow::Result<()>
where
F: FnOnce(Option<PathBuf>) -> Fut,
F: FnOnce(PreMainArgs) -> Fut,
Fut: Future<Output = anyhow::Result<()>>,
{
// Determine if we were invoked via the special alias.
@@ -76,6 +94,9 @@ where
// before creating any threads/the Tokio runtime.
load_dotenv();
// Perform the OPENAI_API_KEY check after loading the .env file.
let openai_api_key = extract_locked_openai_api_key();
// Retain the TempDir so it exists for the lifetime of the invocation of
// this executable. Admittedly, we could invoke `keep()` on it, but it
// would be nice to avoid leaving temporary directories behind, if possible.
@@ -99,7 +120,12 @@ where
None
};
main_fn(codex_linux_sandbox_exe).await
let args = PreMainArgs {
codex_linux_sandbox_exe,
openai_api_key,
};
main_fn(args).await
})
}
@@ -0,0 +1,69 @@
const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
pub(crate) fn extract_locked_openai_api_key() -> Option<String> {
match std::env::var(OPENAI_API_KEY_ENV_VAR) {
Ok(key) => {
if key.is_empty() {
return None;
}
mlock_str(&key);
// Safety: modifying environment variables is only done before new
// threads are spawned.
unsafe {
std::env::remove_var(OPENAI_API_KEY_ENV_VAR);
}
Some(key)
}
Err(std::env::VarError::NotPresent) => None,
Err(std::env::VarError::NotUnicode(_)) => {
// Cannot possibly be a valid API key, so ignore it.
None
}
}
}
#[cfg(unix)]
fn mlock_str(value: &str) {
use libc::_SC_PAGESIZE;
use libc::c_void;
use libc::mlock;
use libc::sysconf;
if value.is_empty() {
return;
}
// Safety: we only read the pointer and length for mlock bookkeeping.
let page_size = unsafe { sysconf(_SC_PAGESIZE) };
if page_size <= 0 {
return;
}
let page_size = page_size as usize;
if page_size == 0 {
return;
}
let addr = value.as_ptr() as usize;
let len = value.len();
let start = addr & !(page_size - 1);
let addr_end = match addr.checked_add(len) {
Some(v) => match v.checked_add(page_size - 1) {
Some(total) => total,
None => return,
},
None => return,
};
let end = addr_end & !(page_size - 1);
let size = end.saturating_sub(start);
if size == 0 {
return;
}
// Best-effort; ignore failures because mlock may require privileges.
let _ = unsafe { mlock(start as *const c_void, size) };
}
#[cfg(not(unix))]
fn mlock_str(_value: &str) {}
+10
View File
@@ -28,6 +28,7 @@ codex-mcp-server = { workspace = true }
codex-protocol = { workspace = true }
codex-protocol-ts = { workspace = true }
codex-tui = { workspace = true }
ctor = { workspace = true }
owo-colors = { workspace = true }
serde_json = { workspace = true }
supports-color = { workspace = true }
@@ -41,6 +42,15 @@ tokio = { workspace = true, features = [
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
libc = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
libc = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
libc = { workspace = true }
[dev-dependencies]
assert_cmd = { workspace = true }
predicates = { workspace = true }
+38 -3
View File
@@ -2,6 +2,7 @@ use clap::CommandFactory;
use clap::Parser;
use clap_complete::Shell;
use clap_complete::generate;
use codex_arg0::PreMainArgs;
use codex_arg0::arg0_dispatch_or_else;
use codex_chatgpt::apply_command::ApplyCommand;
use codex_chatgpt::apply_command::run_apply_command;
@@ -21,6 +22,7 @@ use std::path::PathBuf;
use supports_color::Stream;
mod mcp_cmd;
mod pre_main_hardening;
use crate::mcp_cmd::McpCli;
use crate::proto::ProtoCli;
@@ -194,14 +196,47 @@ fn print_exit_messages(exit_info: AppExitInfo) {
}
}
pub(crate) const CODEX_SECURE_MODE_ENV_VAR: &str = "CODEX_SECURE_MODE";
/// As early as possible in the process lifecycle, apply hardening measures
/// if the CODEX_SECURE_MODE environment variable is set to "1".
#[ctor::ctor]
fn pre_main_hardening() {
let secure_mode = match std::env::var(CODEX_SECURE_MODE_ENV_VAR) {
Ok(value) => value,
Err(_) => return,
};
if secure_mode == "1" {
#[cfg(any(target_os = "linux", target_os = "android"))]
crate::pre_main_hardening::pre_main_hardening_linux();
#[cfg(target_os = "macos")]
crate::pre_main_hardening::pre_main_hardening_macos();
#[cfg(windows)]
crate::pre_main_hardening::pre_main_hardening_windows();
}
// Always clear this env var so child processes don't inherit it.
unsafe {
std::env::remove_var(CODEX_SECURE_MODE_ENV_VAR);
}
}
fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
cli_main(codex_linux_sandbox_exe).await?;
arg0_dispatch_or_else(|pre_main_args| async move {
cli_main(pre_main_args).await?;
Ok(())
})
}
async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> {
async fn cli_main(pre_main_args: PreMainArgs) -> anyhow::Result<()> {
let PreMainArgs {
codex_linux_sandbox_exe,
openai_api_key: _,
} = pre_main_args;
let MultitoolCli {
config_overrides: root_config_overrides,
mut interactive,
+98
View File
@@ -0,0 +1,98 @@
#[cfg(any(target_os = "linux", target_os = "android"))]
const PRCTL_FAILED_EXIT_CODE: i32 = 5;
#[cfg(target_os = "macos")]
const PTRACE_DENY_ATTACH_FAILED_EXIT_CODE: i32 = 6;
#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))]
const SET_RLIMIT_CORE_FAILED_EXIT_CODE: i32 = 7;
#[cfg(any(target_os = "linux", target_os = "android"))]
pub(crate) fn pre_main_hardening_linux() {
// Disable ptrace attach / mark process non-dumpable.
let ret_code = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) };
if ret_code != 0 {
eprintln!(
"ERROR: prctl(PR_SET_DUMPABLE, 0) failed: {}",
std::io::Error::last_os_error()
);
std::process::exit(PRCTL_FAILED_EXIT_CODE);
}
// For "defense in depth," set the core file size limit to 0.
set_core_file_size_limit_to_zero();
// Official Codex releases are MUSL-linked, which means that variables such
// as LD_PRELOAD are ignored anyway, but just to be sure, clear them here.
let ld_keys: Vec<String> = std::env::vars()
.filter_map(|(key, _)| {
if key.starts_with("LD_") {
Some(key)
} else {
None
}
})
.collect();
for key in ld_keys {
unsafe {
std::env::remove_var(key);
}
}
}
#[cfg(target_os = "macos")]
pub(crate) fn pre_main_hardening_macos() {
// Prevent debuggers from attaching to this process.
let ret_code = unsafe { libc::ptrace(libc::PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0) };
if ret_code == -1 {
eprintln!(
"ERROR: ptrace(PT_DENY_ATTACH) failed: {}",
std::io::Error::last_os_error()
);
std::process::exit(PTRACE_DENY_ATTACH_FAILED_EXIT_CODE);
}
// Set the core file size limit to 0 to prevent core dumps.
set_core_file_size_limit_to_zero();
// Remove all DYLD_ environment variables, which can be used to subvert
// library loading.
let dyld_keys: Vec<String> = std::env::vars()
.filter_map(|(key, _)| {
if key.starts_with("DYLD_") {
Some(key)
} else {
None
}
})
.collect();
for key in dyld_keys {
unsafe {
std::env::remove_var(key);
}
}
}
#[cfg(unix)]
fn set_core_file_size_limit_to_zero() {
let rlim = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
let ret_code = unsafe { libc::setrlimit(libc::RLIMIT_CORE, &rlim) };
if ret_code != 0 {
eprintln!(
"ERROR: setrlimit(RLIMIT_CORE) failed: {}",
std::io::Error::last_os_error()
);
std::process::exit(SET_RLIMIT_CORE_FAILED_EXIT_CODE);
}
}
#[cfg(windows)]
pub(crate) fn pre_main_hardening_windows() {
// TODO(mbolin): Perform the appropriate configuration for Windows.
}
View File
+14 -10
View File
@@ -184,19 +184,23 @@ impl ModelClient {
let input_with_instructions = prompt.get_formatted_input();
// Only include `text.verbosity` for GPT-5 family models
let text = if self.config.model_family.family == "gpt-5" {
create_text_param_for_request(self.config.model_verbosity, &prompt.output_schema)
} else {
if self.config.model_verbosity.is_some() {
warn!(
"model_verbosity is set but ignored for non-gpt-5 model family: {}",
self.config.model_family.family
);
let verbosity = match &self.config.model_family.family {
family if family == "gpt-5" => self.config.model_verbosity,
_ => {
if self.config.model_verbosity.is_some() {
warn!(
"model_verbosity is set but ignored for non-gpt-5 model family: {}",
self.config.model_family.family
);
}
None
}
None
};
// Only include `text.verbosity` for GPT-5 family models
let text = create_text_param_for_request(verbosity, &prompt.output_schema);
// In general, we want to explicitly send `store: false` when using the Responses API,
// but in practice, the Azure Responses API rejects `store: false`:
//
+143 -93
View File
@@ -1,6 +1,5 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
@@ -111,7 +110,6 @@ use crate::protocol::Submission;
use crate::protocol::TaskCompleteEvent;
use crate::protocol::TokenCountEvent;
use crate::protocol::TokenUsage;
use crate::protocol::TokenUsageInfo;
use crate::protocol::TurnDiffEvent;
use crate::protocol::WebSearchBeginEvent;
use crate::rollout::RolloutRecorder;
@@ -120,6 +118,8 @@ use crate::safety::SafetyCheck;
use crate::safety::assess_command_safety;
use crate::safety::assess_safety_for_untrusted_command;
use crate::shell;
use crate::state::ActiveTurn;
use crate::state::SessionServices;
use crate::turn_diff_tracker::TurnDiffTracker;
use crate::unified_exec::UnifiedExecSessionManager;
use crate::user_instructions::UserInstructions;
@@ -253,17 +253,7 @@ impl Codex {
}
}
/// Mutable state of the agent
#[derive(Default)]
struct State {
approved_commands: HashSet<Vec<String>>,
current_task: Option<AgentTask>,
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
pending_input: Vec<ResponseInputItem>,
history: ConversationHistory,
token_info: Option<TokenUsageInfo>,
latest_rate_limits: Option<RateLimitSnapshot>,
}
use crate::state::SessionState;
/// Context for an initialized model agent
///
@@ -271,21 +261,9 @@ struct State {
pub(crate) struct Session {
conversation_id: ConversationId,
tx_event: Sender<Event>,
/// Manager for external MCP servers/tools.
mcp_connection_manager: McpConnectionManager,
session_manager: ExecSessionManager,
unified_exec_manager: UnifiedExecSessionManager,
notifier: UserNotifier,
/// Optional rollout recorder for persisting the conversation transcript so
/// sessions can be replayed or inspected later.
rollout: Mutex<Option<RolloutRecorder>>,
state: Mutex<State>,
codex_linux_sandbox_exe: Option<PathBuf>,
user_shell: shell::Shell,
show_raw_agent_reasoning: bool,
state: Mutex<SessionState>,
active_turn: Mutex<Option<ActiveTurn>>,
services: SessionServices,
next_internal_sub_id: AtomicU64,
}
@@ -413,10 +391,7 @@ impl Session {
})?;
let rollout_path = rollout_recorder.rollout_path.clone();
// Create the mutable state for the Session.
let state = State {
history: ConversationHistory::new(),
..Default::default()
};
let state = SessionState::new();
// Handle MCP manager result and record any startup failures.
let (mcp_connection_manager, failed_clients) = match mcp_res {
@@ -474,18 +449,23 @@ impl Session {
is_review_mode: false,
final_output_json_schema: None,
};
let sess = Arc::new(Session {
conversation_id,
tx_event: tx_event.clone(),
let services = SessionServices {
mcp_connection_manager,
session_manager: ExecSessionManager::default(),
unified_exec_manager: UnifiedExecSessionManager::default(),
notifier: notify,
state: Mutex::new(state),
rollout: Mutex::new(Some(rollout_recorder)),
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
user_shell: default_shell,
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
};
let sess = Arc::new(Session {
conversation_id,
tx_event: tx_event.clone(),
state: Mutex::new(state),
active_turn: Mutex::new(None),
services,
next_internal_sub_id: AtomicU64::new(0),
});
@@ -521,6 +501,15 @@ impl Session {
current_task.abort(TurnAbortReason::Replaced);
}
state.current_task = Some(task);
if let Some(current_task) = &state.current_task {
let mut active = self.active_turn.lock().await;
*active = Some(ActiveTurn {
sub_id: current_task.sub_id.clone(),
turn_state: std::sync::Arc::new(tokio::sync::Mutex::new(
crate::state::TurnState::default(),
)),
});
}
}
pub async fn remove_task(&self, sub_id: &str) {
@@ -530,6 +519,12 @@ impl Session {
{
state.current_task.take();
}
let mut active = self.active_turn.lock().await;
if let Some(at) = &*active
&& at.sub_id == sub_id
{
*active = None;
}
}
fn next_internal_sub_id(&self) -> String {
@@ -591,8 +586,14 @@ impl Session {
let (tx_approve, rx_approve) = oneshot::channel();
let event_id = sub_id.clone();
let prev_entry = {
let mut state = self.state.lock().await;
state.pending_approvals.insert(sub_id, tx_approve)
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.insert_pending_approval(sub_id, tx_approve)
}
None => None,
}
};
if prev_entry.is_some() {
warn!("Overwriting existing pending approval for sub_id: {event_id}");
@@ -623,8 +624,14 @@ impl Session {
let (tx_approve, rx_approve) = oneshot::channel();
let event_id = sub_id.clone();
let prev_entry = {
let mut state = self.state.lock().await;
state.pending_approvals.insert(sub_id, tx_approve)
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.insert_pending_approval(sub_id, tx_approve)
}
None => None,
}
};
if prev_entry.is_some() {
warn!("Overwriting existing pending approval for sub_id: {event_id}");
@@ -645,8 +652,14 @@ impl Session {
pub async fn notify_approval(&self, sub_id: &str, decision: ReviewDecision) {
let entry = {
let mut state = self.state.lock().await;
state.pending_approvals.remove(sub_id)
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.remove_pending_approval(sub_id)
}
None => None,
}
};
match entry {
Some(tx_approve) => {
@@ -660,7 +673,7 @@ impl Session {
pub async fn add_approved_command(&self, cmd: Vec<String>) {
let mut state = self.state.lock().await;
state.approved_commands.insert(cmd);
state.add_approved_command(cmd);
}
/// Records input items: always append to conversation history and
@@ -700,7 +713,12 @@ impl Session {
/// Append ResponseItems to the in-memory conversation history only.
async fn record_into_history(&self, items: &[ResponseItem]) {
let mut state = self.state.lock().await;
state.history.record_items(items.iter());
state.record_items(items.iter());
}
async fn replace_history(&self, items: Vec<ResponseItem>) {
let mut state = self.state.lock().await;
state.replace_history(items);
}
async fn persist_rollout_response_items(&self, items: &[ResponseItem]) {
@@ -721,14 +739,14 @@ impl Session {
Some(turn_context.cwd.clone()),
Some(turn_context.approval_policy),
Some(turn_context.sandbox_policy.clone()),
Some(self.user_shell.clone()),
Some(self.user_shell().clone()),
)));
items
}
async fn persist_rollout_items(&self, items: &[RolloutItem]) {
let recorder = {
let guard = self.rollout.lock().await;
let guard = self.services.rollout.lock().await;
guard.clone()
};
if let Some(rec) = recorder
@@ -738,6 +756,11 @@ impl Session {
}
}
pub(crate) async fn history_snapshot(&self) -> Vec<ResponseItem> {
let state = self.state.lock().await;
state.history_snapshot()
}
async fn update_token_usage_info(
&self,
sub_id: &str,
@@ -747,12 +770,10 @@ impl Session {
{
let mut state = self.state.lock().await;
if let Some(token_usage) = token_usage {
let info = TokenUsageInfo::new_or_append(
&state.token_info,
&Some(token_usage.clone()),
state.update_token_info_from_usage(
token_usage,
turn_context.client.get_model_context_window(),
);
state.token_info = info;
}
}
self.send_token_count_event(sub_id).await;
@@ -761,7 +782,7 @@ impl Session {
async fn update_rate_limits(&self, sub_id: &str, new_rate_limits: RateLimitSnapshot) {
{
let mut state = self.state.lock().await;
state.latest_rate_limits = Some(new_rate_limits);
state.set_rate_limits(new_rate_limits);
}
self.send_token_count_event(sub_id).await;
}
@@ -769,7 +790,7 @@ impl Session {
async fn send_token_count_event(&self, sub_id: &str) {
let (info, rate_limits) = {
let state = self.state.lock().await;
(state.token_info.clone(), state.latest_rate_limits.clone())
state.token_info_and_rate_limits()
};
let event = Event {
id: sub_id.to_string(),
@@ -788,7 +809,7 @@ impl Session {
// Derive user message events and persist only UserMessage to rollout
let msgs =
map_response_item_to_event_messages(&response_item, self.show_raw_agent_reasoning);
map_response_item_to_event_messages(&response_item, self.show_raw_agent_reasoning());
let user_msgs: Vec<RolloutItem> = msgs
.into_iter()
.filter_map(|m| match m {
@@ -987,16 +1008,20 @@ impl Session {
pub async fn turn_input_with_history(&self, extra: Vec<ResponseItem>) -> Vec<ResponseItem> {
let history = {
let state = self.state.lock().await;
state.history.contents()
state.history_snapshot()
};
[history, extra].concat()
}
/// Returns the input if there was no task running to inject into
pub async fn inject_input(&self, input: Vec<InputItem>) -> Result<(), Vec<InputItem>> {
let mut state = self.state.lock().await;
let state = self.state.lock().await;
if state.current_task.is_some() {
state.pending_input.push(input.into());
let mut active = self.active_turn.lock().await;
if let Some(at) = active.as_mut() {
let mut ts = at.turn_state.lock().await;
ts.push_pending_input(input.into());
}
Ok(())
} else {
Err(input)
@@ -1004,13 +1029,12 @@ impl Session {
}
pub async fn get_pending_input(&self) -> Vec<ResponseInputItem> {
let mut state = self.state.lock().await;
if state.pending_input.is_empty() {
Vec::with_capacity(0)
let mut active = self.active_turn.lock().await;
if let Some(at) = active.as_mut() {
let mut ts = at.turn_state.lock().await;
ts.take_pending_input()
} else {
let mut ret = Vec::new();
std::mem::swap(&mut ret, &mut state.pending_input);
ret
Vec::with_capacity(0)
}
}
@@ -1020,7 +1044,8 @@ impl Session {
tool: &str,
arguments: Option<serde_json::Value>,
) -> anyhow::Result<CallToolResult> {
self.mcp_connection_manager
self.services
.mcp_connection_manager
.call_tool(server, tool, arguments)
.await
}
@@ -1028,8 +1053,11 @@ impl Session {
pub async fn interrupt_task(&self) {
info!("interrupt received: abort current task, if any");
let mut state = self.state.lock().await;
state.pending_approvals.clear();
state.pending_input.clear();
let mut active = self.active_turn.lock().await;
if let Some(at) = active.as_mut() {
let mut ts = at.turn_state.lock().await;
ts.clear_pending();
}
if let Some(task) = state.current_task.take() {
task.abort(TurnAbortReason::Interrupted);
}
@@ -1037,8 +1065,12 @@ impl Session {
fn interrupt_task_sync(&self) {
if let Ok(mut state) = self.state.try_lock() {
state.pending_approvals.clear();
state.pending_input.clear();
if let Ok(mut active) = self.active_turn.try_lock()
&& let Some(at) = active.as_mut()
&& let Ok(mut ts) = at.turn_state.try_lock()
{
ts.clear_pending();
}
if let Some(task) = state.current_task.take() {
task.abort(TurnAbortReason::Interrupted);
}
@@ -1046,7 +1078,15 @@ impl Session {
}
pub(crate) fn notifier(&self) -> &UserNotifier {
&self.notifier
&self.services.notifier
}
fn user_shell(&self) -> &shell::Shell {
&self.services.user_shell
}
fn show_raw_agent_reasoning(&self) -> bool {
self.services.show_raw_agent_reasoning
}
}
@@ -1152,15 +1192,19 @@ impl AgentTask {
// TOCTOU?
if !self.handle.is_finished() {
self.handle.abort();
let sub_id = self.sub_id.clone();
let is_review = self.kind == AgentTaskKind::Review;
let sess = self.sess;
let event = Event {
id: self.sub_id.clone(),
id: sub_id.clone(),
msg: EventMsg::TurnAborted(TurnAbortedEvent { reason }),
};
let sess = self.sess;
tokio::spawn(async move {
if self.kind == AgentTaskKind::Review {
exit_review_mode(sess.clone(), self.sub_id, None).await;
if is_review {
exit_review_mode(sess.clone(), sub_id.clone(), None).await;
}
// Ensure active turn state is cleared when a task is aborted.
sess.remove_task(&sub_id).await;
sess.send_event(event).await;
});
}
@@ -1418,7 +1462,7 @@ async fn submission_loop(
let sub_id = sub.id.clone();
// This is a cheap lookup from the connection manager's cache.
let tools = sess.mcp_connection_manager.list_all_tools();
let tools = sess.services.mcp_connection_manager.list_all_tools();
let event = Event {
id: sub_id,
msg: EventMsg::McpListToolsResponse(
@@ -1468,7 +1512,7 @@ async fn submission_loop(
// Gracefully flush and shutdown rollout recorder on session end so tests
// that inspect the rollout file do not race with the background writer.
let recorder_opt = {
let mut guard = sess.rollout.lock().await;
let mut guard = sess.services.rollout.lock().await;
guard.take()
};
if let Some(rec) = recorder_opt
@@ -1495,7 +1539,7 @@ async fn submission_loop(
let sub_id = sub.id.clone();
// Flush rollout writes before returning the path so readers observe a consistent file.
let (path, rec_opt) = {
let guard = sess.rollout.lock().await;
let guard = sess.services.rollout.lock().await;
match guard.as_ref() {
Some(rec) => (rec.get_rollout_path(), Some(rec.clone())),
None => {
@@ -1953,7 +1997,7 @@ async fn run_turn(
) -> CodexResult<TurnRunResult> {
let tools = get_openai_tools(
&turn_context.tools_config,
Some(sess.mcp_connection_manager.list_all_tools()),
Some(sess.services.mcp_connection_manager.list_all_tools()),
);
let prompt = Prompt {
@@ -2203,7 +2247,7 @@ async fn try_run_turn(
sess.send_event(event).await;
}
ResponseEvent::ReasoningContentDelta(delta) => {
if sess.show_raw_agent_reasoning {
if sess.show_raw_agent_reasoning() {
let event = Event {
id: sub_id.to_string(),
msg: EventMsg::AgentReasoningRawContentDelta(
@@ -2233,7 +2277,9 @@ async fn handle_response_item(
..
} => {
info!("FunctionCall: {name}({arguments})");
if let Some((server, tool_name)) = sess.mcp_connection_manager.parse_tool_name(&name) {
if let Some((server, tool_name)) =
sess.services.mcp_connection_manager.parse_tool_name(&name)
{
let resp = handle_mcp_tool_call(
sess,
sub_id,
@@ -2369,7 +2415,7 @@ async fn handle_response_item(
trace!("suppressing assistant Message in review mode");
Vec::new()
}
_ => map_response_item_to_event_messages(&item, sess.show_raw_agent_reasoning),
_ => map_response_item_to_event_messages(&item, sess.show_raw_agent_reasoning()),
};
for msg in msgs {
let event = Event {
@@ -2411,6 +2457,7 @@ async fn handle_unified_exec_tool_call(
};
let value = sess
.services
.unified_exec_manager
.handle_request(request)
.await
@@ -2529,6 +2576,7 @@ async fn handle_function_call(
))
})?;
let result = sess
.services
.session_manager
.handle_exec_command_request(exec_params)
.await;
@@ -2546,6 +2594,7 @@ async fn handle_function_call(
})?;
let result = sess
.services
.session_manager
.handle_write_stdin_request(write_stdin_params)
.await
@@ -2636,12 +2685,12 @@ fn maybe_translate_shell_command(
sess: &Session,
turn_context: &TurnContext,
) -> ExecParams {
let should_translate = matches!(sess.user_shell, crate::shell::Shell::PowerShell(_))
let should_translate = matches!(sess.user_shell(), crate::shell::Shell::PowerShell(_))
|| turn_context.shell_environment_policy.use_profile;
if should_translate
&& let Some(command) = sess
.user_shell
.user_shell()
.format_default_shell_invocation(params.command.clone())
{
return ExecParams { command, ..params };
@@ -2741,7 +2790,7 @@ async fn handle_container_exec_with_params(
&params.command,
turn_context.approval_policy,
&turn_context.sandbox_policy,
&state.approved_commands,
state.approved_commands_ref(),
params.with_escalated_permissions.unwrap_or(false),
)
};
@@ -2812,7 +2861,7 @@ async fn handle_container_exec_with_params(
sandbox_type,
sandbox_policy: &turn_context.sandbox_policy,
sandbox_cwd: &turn_context.cwd,
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
codex_linux_sandbox_exe: &sess.services.codex_linux_sandbox_exe,
stdout_stream: if exec_command_context.apply_patch.is_some() {
None
} else {
@@ -2927,7 +2976,7 @@ async fn handle_sandbox_error(
sandbox_type: SandboxType::None,
sandbox_policy: &turn_context.sandbox_policy,
sandbox_cwd: &turn_context.cwd,
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
codex_linux_sandbox_exe: &sess.services.codex_linux_sandbox_exe,
stdout_stream: if exec_command_context.apply_patch.is_some() {
None
} else {
@@ -3268,7 +3317,7 @@ mod tests {
}),
));
let actual = tokio_test::block_on(async { session.state.lock().await.history.contents() });
let actual = tokio_test::block_on(async { session.state.lock().await.history_snapshot() });
assert_eq!(expected, actual);
}
@@ -3281,7 +3330,7 @@ mod tests {
session.record_initial_history(&turn_context, InitialHistory::Forked(rollout_items)),
);
let actual = tokio_test::block_on(async { session.state.lock().await.history.contents() });
let actual = tokio_test::block_on(async { session.state.lock().await.history_snapshot() });
assert_eq!(expected, actual);
}
@@ -3507,21 +3556,22 @@ mod tests {
is_review_mode: false,
final_output_json_schema: None,
};
let session = Session {
conversation_id,
tx_event,
let services = SessionServices {
mcp_connection_manager: McpConnectionManager::default(),
session_manager: ExecSessionManager::default(),
unified_exec_manager: UnifiedExecSessionManager::default(),
notifier: UserNotifier::default(),
rollout: Mutex::new(None),
state: Mutex::new(State {
history: ConversationHistory::new(),
..Default::default()
}),
codex_linux_sandbox_exe: None,
user_shell: shell::Shell::Unknown,
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
};
let session = Session {
conversation_id,
tx_event,
state: Mutex::new(SessionState::new()),
active_turn: Mutex::new(None),
services,
next_internal_sub_id: AtomicU64::new(0),
};
(session, turn_context)
+3 -10
View File
@@ -151,18 +151,12 @@ async fn run_compact_task_inner(
if remove_task_on_completion {
sess.remove_task(&sub_id).await;
}
let history_snapshot = {
let state = sess.state.lock().await;
state.history.contents()
};
let history_snapshot = sess.history_snapshot().await;
let summary_text = get_last_assistant_message_from_turn(&history_snapshot).unwrap_or_default();
let user_messages = collect_user_messages(&history_snapshot);
let initial_context = sess.build_initial_context(turn_context.as_ref());
let new_history = build_compacted_history(initial_context, &user_messages, &summary_text);
{
let mut state = sess.state.lock().await;
state.history.replace(new_history);
}
sess.replace_history(new_history).await;
let rollout_item = RolloutItem::Compacted(CompactedItem {
message: summary_text.clone(),
@@ -270,8 +264,7 @@ async fn drain_to_completed(
};
match event {
Ok(ResponseEvent::OutputItemDone(item)) => {
let mut state = sess.state.lock().await;
state.history.record_items(std::slice::from_ref(&item));
sess.record_into_history(std::slice::from_ref(&item)).await;
}
Ok(ResponseEvent::Completed { .. }) => {
return Ok(());
+1
View File
@@ -76,6 +76,7 @@ pub use rollout::list::ConversationItem;
pub use rollout::list::ConversationsPage;
pub use rollout::list::Cursor;
mod function_tool;
mod state;
mod user_notification;
pub mod util;
+8
View File
@@ -0,0 +1,8 @@
mod service;
mod session;
mod turn;
pub(crate) use service::SessionServices;
pub(crate) use session::SessionState;
pub(crate) use turn::ActiveTurn;
pub(crate) use turn::TurnState;
+18
View File
@@ -0,0 +1,18 @@
use crate::RolloutRecorder;
use crate::exec_command::ExecSessionManager;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::unified_exec::UnifiedExecSessionManager;
use crate::user_notification::UserNotifier;
use std::path::PathBuf;
use tokio::sync::Mutex;
pub(crate) struct SessionServices {
pub(crate) mcp_connection_manager: McpConnectionManager,
pub(crate) session_manager: ExecSessionManager,
pub(crate) unified_exec_manager: UnifiedExecSessionManager,
pub(crate) notifier: UserNotifier,
pub(crate) rollout: Mutex<Option<RolloutRecorder>>,
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
pub(crate) user_shell: crate::shell::Shell,
pub(crate) show_raw_agent_reasoning: bool,
}
+82
View File
@@ -0,0 +1,82 @@
//! Session-wide mutable state.
use std::collections::HashSet;
use codex_protocol::models::ResponseItem;
use crate::codex::AgentTask;
use crate::conversation_history::ConversationHistory;
use crate::protocol::RateLimitSnapshot;
use crate::protocol::TokenUsage;
use crate::protocol::TokenUsageInfo;
/// Persistent, session-scoped state previously stored directly on `Session`.
#[derive(Default)]
pub(crate) struct SessionState {
pub(crate) approved_commands: HashSet<Vec<String>>,
pub(crate) current_task: Option<AgentTask>,
pub(crate) history: ConversationHistory,
pub(crate) token_info: Option<TokenUsageInfo>,
pub(crate) latest_rate_limits: Option<RateLimitSnapshot>,
}
impl SessionState {
/// Create a new session state mirroring previous `State::default()` semantics.
pub(crate) fn new() -> Self {
Self {
history: ConversationHistory::new(),
..Default::default()
}
}
// History helpers
pub(crate) fn record_items<I>(&mut self, items: I)
where
I: IntoIterator,
I::Item: std::ops::Deref<Target = ResponseItem>,
{
self.history.record_items(items)
}
pub(crate) fn history_snapshot(&self) -> Vec<ResponseItem> {
self.history.contents()
}
pub(crate) fn replace_history(&mut self, items: Vec<ResponseItem>) {
self.history.replace(items);
}
// Approved command helpers
pub(crate) fn add_approved_command(&mut self, cmd: Vec<String>) {
self.approved_commands.insert(cmd);
}
pub(crate) fn approved_commands_ref(&self) -> &HashSet<Vec<String>> {
&self.approved_commands
}
// Token/rate limit helpers
pub(crate) fn update_token_info_from_usage(
&mut self,
usage: &TokenUsage,
model_context_window: Option<u64>,
) {
self.token_info = TokenUsageInfo::new_or_append(
&self.token_info,
&Some(usage.clone()),
model_context_window,
);
}
pub(crate) fn set_rate_limits(&mut self, snapshot: RateLimitSnapshot) {
self.latest_rate_limits = Some(snapshot);
}
pub(crate) fn token_info_and_rate_limits(
&self,
) -> (Option<TokenUsageInfo>, Option<RateLimitSnapshot>) {
(self.token_info.clone(), self.latest_rate_limits.clone())
}
// Pending input/approval moved to TurnState.
}
+60
View File
@@ -0,0 +1,60 @@
//! Turn-scoped state and active turn metadata scaffolding.
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use codex_protocol::models::ResponseInputItem;
use tokio::sync::oneshot;
use crate::protocol::ReviewDecision;
/// Metadata about the currently running turn.
#[derive(Default)]
pub(crate) struct ActiveTurn {
pub(crate) sub_id: String,
pub(crate) turn_state: Arc<Mutex<TurnState>>,
}
/// Mutable state for a single turn.
#[derive(Default)]
pub(crate) struct TurnState {
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
pending_input: Vec<ResponseInputItem>,
}
impl TurnState {
pub(crate) fn insert_pending_approval(
&mut self,
key: String,
tx: oneshot::Sender<ReviewDecision>,
) -> Option<oneshot::Sender<ReviewDecision>> {
self.pending_approvals.insert(key, tx)
}
pub(crate) fn remove_pending_approval(
&mut self,
key: &str,
) -> Option<oneshot::Sender<ReviewDecision>> {
self.pending_approvals.remove(key)
}
pub(crate) fn clear_pending(&mut self) {
self.pending_approvals.clear();
self.pending_input.clear();
}
pub(crate) fn push_pending_input(&mut self, input: ResponseInputItem) {
self.pending_input.push(input);
}
pub(crate) fn take_pending_input(&mut self) -> Vec<ResponseInputItem> {
if self.pending_input.is_empty() {
Vec::with_capacity(0)
} else {
let mut ret = Vec::new();
std::mem::swap(&mut ret, &mut self.pending_input);
ret
}
}
}
+11 -2
View File
@@ -30,7 +30,16 @@ const SCHEMA: &str = r#"
"#;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn codex_returns_json_result() -> anyhow::Result<()> {
async fn codex_returns_json_result_for_gpt5() -> anyhow::Result<()> {
codex_returns_json_result("gpt-5".to_string()).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn codex_returns_json_result_for_gpt5_codex() -> anyhow::Result<()> {
codex_returns_json_result("gpt-5-codex".to_string()).await
}
async fn codex_returns_json_result(model: String) -> anyhow::Result<()> {
non_sandbox_test!(result);
let server = start_mock_server().await;
@@ -72,7 +81,7 @@ async fn codex_returns_json_result() -> anyhow::Result<()> {
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: "gpt-5".to_string(),
model,
effort: None,
summary: ReasoningSummary::Auto,
})
+6 -1
View File
@@ -10,6 +10,7 @@
//! This allows us to ship a completely separate set of functionality as part
//! of the `codex-exec` binary.
use clap::Parser;
use codex_arg0::PreMainArgs;
use codex_arg0::arg0_dispatch_or_else;
use codex_common::CliConfigOverrides;
use codex_exec::Cli;
@@ -25,7 +26,11 @@ struct TopCli {
}
fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
arg0_dispatch_or_else(|pre_main_args| async move {
let PreMainArgs {
codex_linux_sandbox_exe,
openai_api_key: _,
} = pre_main_args;
let top_cli = TopCli::parse();
// Merge root-level overrides into inner CLI struct so downstream logic remains unchanged.
let mut inner = top_cli.inner;
+6 -1
View File
@@ -1,9 +1,14 @@
use codex_arg0::PreMainArgs;
use codex_arg0::arg0_dispatch_or_else;
use codex_common::CliConfigOverrides;
use codex_mcp_server::run_main;
fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
arg0_dispatch_or_else(|pre_main_args| async move {
let PreMainArgs {
codex_linux_sandbox_exe,
openai_api_key: _,
} = pre_main_args;
run_main(codex_linux_sandbox_exe, CliConfigOverrides::default()).await?;
Ok(())
})
@@ -264,7 +264,6 @@ impl ChatComposer {
}
/// Get the current composer text.
#[cfg(test)]
pub(crate) fn current_text(&self) -> String {
self.textarea.text().to_string()
}
-1
View File
@@ -262,7 +262,6 @@ impl BottomPane {
}
/// Get the current composer text (for tests and programmatic checks).
#[cfg(test)]
pub(crate) fn composer_text(&self) -> String {
self.composer.current_text()
}
+9 -1
View File
@@ -427,12 +427,20 @@ impl ChatWidget {
// If any messages were queued during the task, restore them into the composer.
if !self.queued_user_messages.is_empty() {
let combined = self
let queued_text = self
.queued_user_messages
.iter()
.map(|m| m.text.clone())
.collect::<Vec<_>>()
.join("\n");
let existing_text = self.bottom_pane.composer_text();
let combined = if existing_text.is_empty() {
queued_text
} else if queued_text.is_empty() {
existing_text
} else {
format!("{queued_text}\n{existing_text}")
};
self.bottom_pane.set_composer_text(combined);
// Clear the queue and update the status indicator list.
self.queued_user_messages.clear();
+34
View File
@@ -1299,6 +1299,40 @@ fn interrupt_restores_queued_messages_into_composer() {
let _ = drain_insert_history(&mut rx);
}
#[test]
fn interrupt_prepends_queued_messages_before_existing_composer_text() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual();
chat.bottom_pane.set_task_running(true);
chat.bottom_pane
.set_composer_text("current draft".to_string());
chat.queued_user_messages
.push_back(UserMessage::from("first queued".to_string()));
chat.queued_user_messages
.push_back(UserMessage::from("second queued".to_string()));
chat.refresh_queued_user_messages();
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent {
reason: TurnAbortReason::Interrupted,
}),
});
assert_eq!(
chat.bottom_pane.composer_text(),
"first queued\nsecond queued\ncurrent draft"
);
assert!(chat.queued_user_messages.is_empty());
assert!(
op_rx.try_recv().is_err(),
"unexpected outbound op after interrupt"
);
let _ = drain_insert_history(&mut rx);
}
// Snapshot test: ChatWidget at very small heights (idle)
// Ensures overall layout behaves when terminal height is extremely constrained.
#[test]
+43 -341
View File
@@ -5,36 +5,26 @@ use crate::markdown::append_markdown;
use crate::render::line_utils::line_to_static;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
pub(crate) use crate::status::RateLimitSnapshotDisplay;
pub(crate) use crate::status::new_status_output;
pub(crate) use crate::status::rate_limit_snapshot_display;
use crate::text_formatting::format_and_truncate_tool_result;
use crate::ui_consts::LIVE_PREFIX_COLS;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use crate::wrapping::word_wrap_lines;
use base64::Engine;
use chrono::DateTime;
use chrono::Duration as ChronoDuration;
use chrono::Local;
use codex_ansi_escape::ansi_escape_line;
use codex_common::create_config_summary_entries;
use codex_common::elapsed::format_duration;
use codex_core::auth::get_auth_file;
use codex_core::auth::try_read_auth_json;
use codex_core::config::Config;
use codex_core::config_types::ReasoningSummaryFormat;
use codex_core::plan_tool::PlanItemArg;
use codex_core::plan_tool::StepStatus;
use codex_core::plan_tool::UpdatePlanArgs;
use codex_core::project_doc::discover_project_doc_paths;
use codex_core::protocol::FileChange;
use codex_core::protocol::McpInvocation;
use codex_core::protocol::RateLimitSnapshot;
use codex_core::protocol::RateLimitWindow;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::protocol::TokenUsage;
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::num_format::format_with_separators;
use codex_protocol::parse_command::ParsedCommand;
use image::DynamicImage;
use image::ImageReader;
@@ -51,7 +41,6 @@ use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use std::any::Any;
use std::collections::HashMap;
use std::convert::TryFrom;
use std::io::Cursor;
use std::path::Path;
use std::path::PathBuf;
@@ -60,10 +49,6 @@ use std::time::Instant;
use tracing::error;
use unicode_width::UnicodeWidthStr;
const STATUS_LIMIT_BAR_SEGMENTS: usize = 20;
const STATUS_LIMIT_BAR_FILLED: &str = "█";
const STATUS_LIMIT_BAR_EMPTY: &str = " ";
#[derive(Clone, Debug)]
pub(crate) struct CommandOutput {
pub(crate) exit_code: i32,
@@ -229,6 +214,12 @@ pub(crate) struct PlainHistoryCell {
lines: Vec<Line<'static>>,
}
impl PlainHistoryCell {
pub(crate) fn new(lines: Vec<Line<'static>>) -> Self {
Self { lines }
}
}
impl HistoryCell for PlainHistoryCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone()
@@ -637,9 +628,9 @@ impl HistoryCell for CompletedMcpToolCallWithImageOutput {
}
const TOOL_CALL_MAX_LINES: usize = 5;
const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value
pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value
fn card_inner_width(width: u16, max_inner_width: usize) -> Option<usize> {
pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option<usize> {
if width < 4 {
return None;
}
@@ -647,8 +638,28 @@ fn card_inner_width(width: u16, max_inner_width: usize) -> Option<usize> {
Some(inner_width)
}
fn with_border(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
let content_width = lines
/// Render `lines` inside a border sized to the widest span in the content.
pub(crate) fn with_border(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
with_border_internal(lines, None)
}
/// Render `lines` inside a border whose inner width is at least `inner_width`.
///
/// This is useful when callers have already clamped their content to a
/// specific width and want the border math centralized here instead of
/// duplicating padding logic in the TUI widgets themselves.
pub(crate) fn with_border_with_inner_width(
lines: Vec<Line<'static>>,
inner_width: usize,
) -> Vec<Line<'static>> {
with_border_internal(lines, Some(inner_width))
}
fn with_border_internal(
lines: Vec<Line<'static>>,
forced_inner_width: Option<usize>,
) -> Vec<Line<'static>> {
let max_line_width = lines
.iter()
.map(|line| {
line.iter()
@@ -657,6 +668,9 @@ fn with_border(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
})
.max()
.unwrap_or(0);
let content_width = forced_inner_width
.unwrap_or(max_line_width)
.max(max_line_width);
let mut out = Vec::with_capacity(lines.len() + 2);
let border_inner_width = content_width + 2;
@@ -683,30 +697,10 @@ fn with_border(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
out
}
fn title_case(s: &str) -> String {
if s.is_empty() {
return String::new();
}
let mut chars = s.chars();
let first = match chars.next() {
Some(c) => c,
None => return String::new(),
};
let rest: String = chars.as_str().to_ascii_lowercase();
first.to_uppercase().collect::<String>() + &rest
}
fn pretty_provider_name(id: &str) -> String {
if id.eq_ignore_ascii_case("openai") {
"OpenAI".to_string()
} else {
title_case(id)
}
}
/// Return the emoji followed by a hair space (U+200A).
/// Using only the hair space avoids excessive padding after the emoji while
/// still providing a small visual gap across terminals.
fn padded_emoji(emoji: &str) -> String {
pub(crate) fn padded_emoji(emoji: &str) -> String {
format!("{emoji}\u{200A}")
}
@@ -925,6 +919,12 @@ pub(crate) struct CompositeHistoryCell {
parts: Vec<Box<dyn HistoryCell>>,
}
impl CompositeHistoryCell {
pub(crate) fn new(parts: Vec<Box<dyn HistoryCell>>) -> Self {
Self { parts }
}
}
impl HistoryCell for CompositeHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
@@ -1184,228 +1184,6 @@ pub(crate) fn new_warning_event(message: String) -> PlainHistoryCell {
}
}
#[derive(Debug, Clone)]
pub(crate) struct RateLimitWindowDisplay {
pub used_percent: f64,
pub resets_at: Option<String>,
}
impl RateLimitWindowDisplay {
fn from_window(window: &RateLimitWindow, captured_at: DateTime<Local>) -> Self {
let resets_at = window
.resets_in_seconds
.and_then(|seconds| i64::try_from(seconds).ok())
.and_then(|secs| captured_at.checked_add_signed(ChronoDuration::seconds(secs)))
.map(|dt| dt.format("%b %-d, %Y %-I:%M %p").to_string());
Self {
used_percent: window.used_percent,
resets_at,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct RateLimitSnapshotDisplay {
pub primary: Option<RateLimitWindowDisplay>,
pub secondary: Option<RateLimitWindowDisplay>,
}
pub(crate) fn rate_limit_snapshot_display(
snapshot: &RateLimitSnapshot,
captured_at: DateTime<Local>,
) -> RateLimitSnapshotDisplay {
RateLimitSnapshotDisplay {
primary: snapshot
.primary
.as_ref()
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
secondary: snapshot
.secondary
.as_ref()
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
}
}
pub(crate) fn new_status_output(
config: &Config,
usage: &TokenUsage,
session_id: &Option<ConversationId>,
rate_limits: Option<&RateLimitSnapshotDisplay>,
) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push("/status".magenta().into());
let config_entries = create_config_summary_entries(config);
let lookup = |k: &str| -> String {
config_entries
.iter()
.find(|(key, _)| *key == k)
.map(|(_, v)| v.clone())
.unwrap_or_default()
};
// 📂 Workspace
lines.push(vec![padded_emoji("📂").into(), "Workspace".bold()].into());
// 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() => {
let sep = std::path::MAIN_SEPARATOR;
format!("~{sep}{}", rel.display())
}
Some(_) => "~".to_string(),
None => config.cwd.display().to_string(),
};
lines.push(vec![" • Path: ".into(), cwd_str.into()].into());
// Approval mode (as-is)
lines.push(vec![" • Approval Mode: ".into(), lookup("approval").into()].into());
// Sandbox (simplified name only)
let sandbox_name = match &config.sandbox_policy {
SandboxPolicy::DangerFullAccess => "danger-full-access",
SandboxPolicy::ReadOnly => "read-only",
SandboxPolicy::WorkspaceWrite { .. } => "workspace-write",
};
lines.push(vec![" • Sandbox: ".into(), sandbox_name.into()].into());
// AGENTS.md files discovered via core's project_doc logic
let agents_list = {
match discover_project_doc_paths(config) {
Ok(paths) => {
let mut rels: Vec<String> = Vec::new();
for p in paths {
let display = if let Some(parent) = p.parent() {
if parent == config.cwd {
"AGENTS.md".to_string()
} else {
let mut cur = config.cwd.as_path();
let mut ups = 0usize;
let mut reached = false;
while let Some(c) = cur.parent() {
if cur == parent {
reached = true;
break;
}
cur = c;
ups += 1;
}
if reached {
let up = format!("..{}", std::path::MAIN_SEPARATOR);
format!("{}AGENTS.md", up.repeat(ups))
} else if let Ok(stripped) = p.strip_prefix(&config.cwd) {
stripped.display().to_string()
} else {
p.display().to_string()
}
}
} else {
p.display().to_string()
};
rels.push(display);
}
rels
}
Err(_) => Vec::new(),
}
};
if agents_list.is_empty() {
lines.push(" • AGENTS files: (none)".into());
} else {
lines.push(vec![" • AGENTS files: ".into(), agents_list.join(", ").into()].into());
}
lines.push("".into());
// 👤 Account (only if ChatGPT tokens exist), shown under the first block
let auth_file = get_auth_file(&config.codex_home);
let auth = try_read_auth_json(&auth_file).ok();
let is_chatgpt_auth = auth
.as_ref()
.and_then(|auth| auth.tokens.as_ref())
.is_some();
if is_chatgpt_auth
&& let Some(auth) = auth.as_ref()
&& let Some(tokens) = auth.tokens.clone()
{
lines.push(vec![padded_emoji("👤").into(), "Account".bold()].into());
lines.push(" • Signed in with ChatGPT".into());
let info = tokens.id_token;
if let Some(email) = &info.email {
lines.push(vec![" • Login: ".into(), email.clone().into()].into());
}
match auth.openai_api_key.as_deref() {
Some(key) if !key.is_empty() => {
lines.push(" • Using API key. Run codex login to use ChatGPT plan".into());
}
_ => {
let plan_text = info
.get_chatgpt_plan_type()
.map(|s| title_case(&s))
.unwrap_or_else(|| "Unknown".to_string());
lines.push(vec![" • Plan: ".into(), plan_text.into()].into());
}
}
lines.push("".into());
}
// 🧠 Model
lines.push(vec![padded_emoji("🧠").into(), "Model".bold()].into());
lines.push(vec![" • Name: ".into(), config.model.clone().into()].into());
let provider_disp = pretty_provider_name(&config.model_provider_id);
lines.push(vec![" • Provider: ".into(), provider_disp.into()].into());
// Only show Reasoning fields if present in config summary
let reff = lookup("reasoning effort");
if !reff.is_empty() {
lines.push(vec![" • Reasoning Effort: ".into(), title_case(&reff).into()].into());
}
let rsum = lookup("reasoning summaries");
if !rsum.is_empty() {
lines.push(vec![" • Reasoning Summaries: ".into(), title_case(&rsum).into()].into());
}
lines.push("".into());
// 💻 Client
let cli_version = crate::version::CODEX_CLI_VERSION;
lines.push(vec![padded_emoji("💻").into(), "Client".bold()].into());
lines.push(vec![" • CLI Version: ".into(), cli_version.into()].into());
lines.push("".into());
// 📊 Token Usage
lines.push(vec!["📊 ".into(), "Token Usage".bold()].into());
if let Some(session_id) = session_id {
lines.push(vec![" • Session ID: ".into(), session_id.to_string().into()].into());
}
// Input: <input> [+ <cached> cached]
let mut input_line_spans: Vec<Span<'static>> = vec![
" • Input: ".into(),
format_with_separators(usage.non_cached_input()).into(),
];
if usage.cached_input_tokens > 0 {
let cached = usage.cached_input_tokens;
input_line_spans.push(format!(" (+ {cached} cached)").into());
}
lines.push(Line::from(input_line_spans));
// Output: <output>
lines.push(Line::from(vec![
" • Output: ".into(),
format_with_separators(usage.output_tokens).into(),
]));
// Total: <total>
lines.push(Line::from(vec![
" • Total: ".into(),
format_with_separators(usage.blended_total()).into(),
]));
if is_chatgpt_auth {
lines.push("".into());
lines.extend(build_status_limit_lines(rate_limits));
}
PlainHistoryCell { lines }
}
/// Render a summary of configured MCP servers from the current `Config`.
pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
let lines: Vec<Line<'static>> = vec![
@@ -1760,82 +1538,6 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
invocation_spans.into()
}
fn build_status_limit_lines(snapshot: Option<&RateLimitSnapshotDisplay>) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> =
vec![vec![padded_emoji("⏱️").into(), "Usage Limits".bold()].into()];
match snapshot {
Some(snapshot) => {
let mut windows: Vec<(&str, &RateLimitWindowDisplay)> = Vec::new();
if let Some(primary) = snapshot.primary.as_ref() {
windows.push(("5h limit", primary));
}
if let Some(secondary) = snapshot.secondary.as_ref() {
windows.push(("Weekly limit", secondary));
}
if windows.is_empty() {
lines.push(" • No rate limit data available.".into());
} else {
let label_width = windows
.iter()
.map(|(label, _)| UnicodeWidthStr::width(*label))
.max()
.unwrap_or(0);
for (label, window) in windows {
lines.push(build_status_limit_line(
label,
window.used_percent,
label_width,
));
if let Some(resets_at) = window.resets_at.as_deref() {
lines.push(build_status_reset_line(resets_at));
}
}
}
}
None => lines.push(" • Send a message to load usage data.".into()),
}
lines
}
fn build_status_limit_line(label: &str, percent_used: f64, label_width: usize) -> Line<'static> {
let clamped_percent = percent_used.clamp(0.0, 100.0);
let progress = render_status_limit_progress_bar(clamped_percent);
let summary = format_status_limit_summary(clamped_percent);
let mut spans: Vec<Span<'static>> = Vec::with_capacity(5);
let padded_label = format!("{label:<label_width$}");
spans.push(format!(" • {padded_label}: ").into());
spans.push(progress.into());
spans.push(" ".into());
spans.push(summary.into());
Line::from(spans)
}
fn build_status_reset_line(resets_at: &str) -> Line<'static> {
vec![" ".into(), format!("Resets at: {resets_at}").dim()].into()
}
fn render_status_limit_progress_bar(percent_used: f64) -> String {
let ratio = (percent_used / 100.0).clamp(0.0, 1.0);
let filled = (ratio * STATUS_LIMIT_BAR_SEGMENTS as f64).round() as usize;
let filled = filled.min(STATUS_LIMIT_BAR_SEGMENTS);
let empty = STATUS_LIMIT_BAR_SEGMENTS.saturating_sub(filled);
format!(
"[{}{}]",
STATUS_LIMIT_BAR_FILLED.repeat(filled),
STATUS_LIMIT_BAR_EMPTY.repeat(empty)
)
}
fn format_status_limit_summary(percent_used: f64) -> String {
format!("{percent_used:.0}% used")
}
#[cfg(test)]
mod tests {
use super::*;
+1
View File
@@ -60,6 +60,7 @@ mod resume_picker;
mod session_log;
mod shimmer;
mod slash_command;
mod status;
mod status_indicator_widget;
mod streaming;
mod text_formatting;
+6 -1
View File
@@ -1,4 +1,5 @@
use clap::Parser;
use codex_arg0::PreMainArgs;
use codex_arg0::arg0_dispatch_or_else;
use codex_common::CliConfigOverrides;
use codex_tui::Cli;
@@ -14,7 +15,11 @@ struct TopCli {
}
fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
arg0_dispatch_or_else(|pre_main_args| async move {
let PreMainArgs {
codex_linux_sandbox_exe,
openai_api_key: _,
} = pre_main_args;
let top_cli = TopCli::parse();
let mut inner = top_cli.inner;
inner
@@ -0,0 +1,19 @@
---
source: tui/src/status.rs
expression: sanitized
---
/status
╭──────────────────────────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ Model : gpt-5-codex (reasoning high, summaries detailed) │
│ Directory : /workspace/tests │
│ Approval : on-request │
│ Sandbox : workspace-write │
│ Agents.md : <none> │
│ │
│ Token Usage : 1.9K total (1K input + 900 output) │
│ 5h limit : [███████████████░░░░░] 72% used · resets 03:14 │
│ Weekly limit : [█████████░░░░░░░░░░░] 45% used · resets 03:24 │
╰──────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,19 @@
---
source: tui/src/status.rs
expression: sanitized
---
/status
╭────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ Model : gpt-5-codex (reasoning high │
│ Directory : /workspace/tests │
│ Approval : on-request │
│ Sandbox : read-only │
│ Agents.md : <none> │
│ │
│ Token Usage : 1.9K total (1K input + 900 │
│ 5h limit : [███████████████░░░░░] 72% │
│ · resets 03:14 │
╰────────────────────────────────────────────╯
+8
View File
@@ -0,0 +1,8 @@
#[derive(Debug, Clone)]
pub(crate) enum StatusAccountDisplay {
ChatGpt {
email: Option<String>,
plan: Option<String>,
},
ApiKey,
}
+280
View File
@@ -0,0 +1,280 @@
use crate::history_cell::CompositeHistoryCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::with_border_with_inner_width;
use crate::version::CODEX_CLI_VERSION;
use codex_common::create_config_summary_entries;
use codex_core::config::Config;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::TokenUsage;
use codex_protocol::mcp_protocol::ConversationId;
use ratatui::prelude::*;
use ratatui::style::Stylize;
use std::collections::BTreeSet;
use std::path::PathBuf;
use super::account::StatusAccountDisplay;
use super::format::FieldFormatter;
use super::format::line_display_width;
use super::format::push_label;
use super::format::truncate_line_to_width;
use super::helpers::compose_account_display;
use super::helpers::compose_agents_summary;
use super::helpers::compose_model_display;
use super::helpers::format_directory_display;
use super::helpers::format_tokens_compact;
use super::rate_limits::RESET_BULLET;
use super::rate_limits::RateLimitSnapshotDisplay;
use super::rate_limits::StatusRateLimitData;
use super::rate_limits::compose_rate_limit_data;
use super::rate_limits::format_status_limit_summary;
use super::rate_limits::render_status_limit_progress_bar;
#[derive(Debug, Clone)]
pub(crate) struct StatusTokenUsageData {
total: u64,
input: u64,
output: u64,
}
#[derive(Debug)]
struct StatusHistoryCell {
model_name: String,
model_details: Vec<String>,
directory: PathBuf,
approval: String,
sandbox: String,
agents_summary: String,
account: Option<StatusAccountDisplay>,
session_id: Option<String>,
token_usage: StatusTokenUsageData,
rate_limits: StatusRateLimitData,
}
pub(crate) fn new_status_output(
config: &Config,
usage: &TokenUsage,
session_id: &Option<ConversationId>,
rate_limits: Option<&RateLimitSnapshotDisplay>,
) -> CompositeHistoryCell {
let command = PlainHistoryCell::new(vec!["/status".magenta().into()]);
let card = StatusHistoryCell::new(config, usage, session_id, rate_limits);
CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)])
}
impl StatusHistoryCell {
fn new(
config: &Config,
usage: &TokenUsage,
session_id: &Option<ConversationId>,
rate_limits: Option<&RateLimitSnapshotDisplay>,
) -> Self {
let config_entries = create_config_summary_entries(config);
let (model_name, model_details) = compose_model_display(config, &config_entries);
let approval = config_entries
.iter()
.find(|(k, _)| *k == "approval")
.map(|(_, v)| v.clone())
.unwrap_or_else(|| "<unknown>".to_string());
let sandbox = match &config.sandbox_policy {
SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(),
SandboxPolicy::ReadOnly => "read-only".to_string(),
SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(),
};
let agents_summary = compose_agents_summary(config);
let account = compose_account_display(config);
let session_id = session_id.as_ref().map(std::string::ToString::to_string);
let token_usage = StatusTokenUsageData {
total: usage.blended_total(),
input: usage.non_cached_input(),
output: usage.output_tokens,
};
let rate_limits = compose_rate_limit_data(rate_limits);
Self {
model_name,
model_details,
directory: config.cwd.clone(),
approval,
sandbox,
agents_summary,
account,
session_id,
token_usage,
rate_limits,
}
}
fn token_usage_spans(&self) -> Vec<Span<'static>> {
let total_fmt = format_tokens_compact(self.token_usage.total);
let input_fmt = format_tokens_compact(self.token_usage.input);
let output_fmt = format_tokens_compact(self.token_usage.output);
vec![
Span::from(total_fmt),
Span::from(" total "),
Span::from(" (").dim(),
Span::from(input_fmt).dim(),
Span::from(" input").dim(),
Span::from(" + ").dim(),
Span::from(output_fmt).dim(),
Span::from(" output").dim(),
Span::from(")").dim(),
]
}
fn rate_limit_lines(
&self,
available_inner_width: usize,
formatter: &FieldFormatter,
) -> Vec<Line<'static>> {
match &self.rate_limits {
StatusRateLimitData::Available(rows_data) => {
if rows_data.is_empty() {
return vec![
formatter.line("Limits", vec![Span::from("data not available yet").dim()]),
];
}
let mut lines = Vec::with_capacity(rows_data.len() * 2);
for row in rows_data {
let value_spans = vec![
Span::from(render_status_limit_progress_bar(row.percent_used)),
Span::from(" "),
Span::from(format_status_limit_summary(row.percent_used)),
];
let base_spans = formatter.full_spans(row.label, value_spans);
let base_line = Line::from(base_spans.clone());
if let Some(resets_at) = row.resets_at.as_ref() {
let resets_span =
Span::from(format!("{RESET_BULLET} resets {resets_at}")).dim();
let mut inline_spans = base_spans.clone();
inline_spans.push(Span::from(" ").dim());
inline_spans.push(resets_span.clone());
if line_display_width(&Line::from(inline_spans.clone()))
<= available_inner_width
{
lines.push(Line::from(inline_spans));
} else {
lines.push(base_line);
lines.push(formatter.continuation(vec![resets_span]));
}
} else {
lines.push(base_line);
}
}
lines
}
StatusRateLimitData::Missing => {
vec![formatter.line("Limits", vec![Span::from("data not available yet").dim()])]
}
}
}
fn collect_rate_limit_labels(
&self,
seen: &mut BTreeSet<&'static str>,
labels: &mut Vec<&'static str>,
) {
match &self.rate_limits {
StatusRateLimitData::Available(rows) => {
if rows.is_empty() {
push_label(labels, seen, "Limits");
} else {
for row in rows {
push_label(labels, seen, row.label);
}
}
}
StatusRateLimitData::Missing => push_label(labels, seen, "Limits"),
}
}
}
impl HistoryCell for StatusHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(vec![
Span::from(format!("{}>_ ", FieldFormatter::INDENT)).dim(),
Span::from("OpenAI Codex").bold(),
Span::from(" ").dim(),
Span::from(format!("(v{CODEX_CLI_VERSION})")).dim(),
]));
lines.push(Line::from(Vec::<Span<'static>>::new()));
let available_inner_width = usize::from(width.saturating_sub(4));
if available_inner_width == 0 {
return Vec::new();
}
let account_value = self.account.as_ref().map(|account| match account {
StatusAccountDisplay::ChatGpt { email, plan } => match (email, plan) {
(Some(email), Some(plan)) => format!("{email} ({plan})"),
(Some(email), None) => email.clone(),
(None, Some(plan)) => plan.clone(),
(None, None) => "ChatGPT".to_string(),
},
StatusAccountDisplay::ApiKey => {
"API key configured (run codex login to use ChatGPT)".to_string()
}
});
let mut labels: Vec<&'static str> =
vec!["Model", "Directory", "Approval", "Sandbox", "Agents.md"];
let mut seen: BTreeSet<&'static str> = labels.iter().copied().collect();
if account_value.is_some() {
push_label(&mut labels, &mut seen, "Account");
}
if self.session_id.is_some() {
push_label(&mut labels, &mut seen, "Session");
}
push_label(&mut labels, &mut seen, "Token Usage");
self.collect_rate_limit_labels(&mut seen, &mut labels);
let formatter = FieldFormatter::from_labels(labels.iter().copied());
let value_width = formatter.value_width(available_inner_width);
let mut model_spans = vec![Span::from(self.model_name.clone())];
if !self.model_details.is_empty() {
model_spans.push(Span::from(" (").dim());
model_spans.push(Span::from(self.model_details.join(", ")).dim());
model_spans.push(Span::from(")").dim());
}
let directory_value = format_directory_display(&self.directory, Some(value_width));
lines.push(formatter.line("Model", model_spans));
lines.push(formatter.line("Directory", vec![Span::from(directory_value)]));
lines.push(formatter.line("Approval", vec![Span::from(self.approval.clone())]));
lines.push(formatter.line("Sandbox", vec![Span::from(self.sandbox.clone())]));
lines.push(formatter.line("Agents.md", vec![Span::from(self.agents_summary.clone())]));
if let Some(account_value) = account_value {
lines.push(formatter.line("Account", vec![Span::from(account_value)]));
}
if let Some(session) = self.session_id.as_ref() {
lines.push(formatter.line("Session", vec![Span::from(session.clone())]));
}
lines.push(Line::from(Vec::<Span<'static>>::new()));
lines.push(formatter.line("Token Usage", self.token_usage_spans()));
lines.extend(self.rate_limit_lines(available_inner_width, &formatter));
let content_width = lines.iter().map(line_display_width).max().unwrap_or(0);
let inner_width = content_width.min(available_inner_width);
let truncated_lines: Vec<Line<'static>> = lines
.into_iter()
.map(|line| truncate_line_to_width(line, inner_width))
.collect();
with_border_with_inner_width(truncated_lines, inner_width)
}
}
+144
View File
@@ -0,0 +1,144 @@
use ratatui::prelude::*;
use ratatui::style::Stylize;
use std::collections::BTreeSet;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone)]
pub(crate) struct FieldFormatter {
indent: &'static str,
label_width: usize,
value_offset: usize,
value_indent: String,
}
impl FieldFormatter {
pub(crate) const INDENT: &'static str = " ";
pub(crate) fn from_labels(labels: impl IntoIterator<Item = &'static str>) -> Self {
let label_width = labels
.into_iter()
.map(UnicodeWidthStr::width)
.max()
.unwrap_or(0);
let indent_width = UnicodeWidthStr::width(Self::INDENT);
let value_offset = indent_width + label_width + 1 + 3;
Self {
indent: Self::INDENT,
label_width,
value_offset,
value_indent: " ".repeat(value_offset),
}
}
pub(crate) fn line(
&self,
label: &'static str,
value_spans: Vec<Span<'static>>,
) -> Line<'static> {
Line::from(self.full_spans(label, value_spans))
}
pub(crate) fn continuation(&self, mut spans: Vec<Span<'static>>) -> Line<'static> {
let mut all_spans = Vec::with_capacity(spans.len() + 1);
all_spans.push(Span::from(self.value_indent.clone()).dim());
all_spans.append(&mut spans);
Line::from(all_spans)
}
pub(crate) fn value_width(&self, available_inner_width: usize) -> usize {
available_inner_width.saturating_sub(self.value_offset)
}
pub(crate) fn full_spans(
&self,
label: &'static str,
mut value_spans: Vec<Span<'static>>,
) -> Vec<Span<'static>> {
let mut spans = Vec::with_capacity(value_spans.len() + 1);
spans.push(self.label_span(label));
spans.append(&mut value_spans);
spans
}
fn label_span(&self, label: &str) -> Span<'static> {
let mut buf = String::with_capacity(self.value_offset);
buf.push_str(self.indent);
buf.push_str(label);
buf.push(':');
let label_width = UnicodeWidthStr::width(label);
let padding = 3 + self.label_width.saturating_sub(label_width);
for _ in 0..padding {
buf.push(' ');
}
Span::from(buf).dim()
}
}
pub(crate) fn push_label(
labels: &mut Vec<&'static str>,
seen: &mut BTreeSet<&'static str>,
label: &'static str,
) {
if seen.insert(label) {
labels.push(label);
}
}
pub(crate) fn line_display_width(line: &Line<'static>) -> usize {
line.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.sum()
}
pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> Line<'static> {
if max_width == 0 {
return Line::from(Vec::<Span<'static>>::new());
}
let mut used = 0usize;
let mut spans_out: Vec<Span<'static>> = Vec::new();
for span in line.spans {
let text = span.content.into_owned();
let style = span.style;
let span_width = UnicodeWidthStr::width(text.as_str());
if span_width == 0 {
spans_out.push(Span::styled(text, style));
continue;
}
if used >= max_width {
break;
}
if used + span_width <= max_width {
used += span_width;
spans_out.push(Span::styled(text, style));
continue;
}
let mut truncated = String::new();
for ch in text.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + ch_width > max_width {
break;
}
truncated.push(ch);
used += ch_width;
}
if !truncated.is_empty() {
spans_out.push(Span::styled(truncated, style));
}
break;
}
Line::from(spans_out)
}
+180
View File
@@ -0,0 +1,180 @@
use crate::exec_command::relativize_to_home;
use crate::text_formatting;
use chrono::DateTime;
use chrono::Local;
use codex_core::auth::get_auth_file;
use codex_core::auth::try_read_auth_json;
use codex_core::config::Config;
use codex_core::project_doc::discover_project_doc_paths;
use std::path::Path;
use unicode_width::UnicodeWidthStr;
use super::account::StatusAccountDisplay;
pub(crate) fn compose_model_display(
config: &Config,
entries: &[(&str, String)],
) -> (String, Vec<String>) {
let mut details: Vec<String> = Vec::new();
if let Some((_, effort)) = entries.iter().find(|(k, _)| *k == "reasoning effort") {
details.push(format!("reasoning {}", effort.to_ascii_lowercase()));
}
if let Some((_, summary)) = entries.iter().find(|(k, _)| *k == "reasoning summaries") {
let summary = summary.trim();
if summary.eq_ignore_ascii_case("none") || summary.eq_ignore_ascii_case("off") {
details.push("summaries off".to_string());
} else if !summary.is_empty() {
details.push(format!("summaries {}", summary.to_ascii_lowercase()));
}
}
(config.model.clone(), details)
}
pub(crate) fn compose_agents_summary(config: &Config) -> String {
match discover_project_doc_paths(config) {
Ok(paths) => {
let mut rels: Vec<String> = Vec::new();
for p in paths {
let display = if let Some(parent) = p.parent() {
if parent == config.cwd {
"AGENTS.md".to_string()
} else {
let mut cur = config.cwd.as_path();
let mut ups = 0usize;
let mut reached = false;
while let Some(c) = cur.parent() {
if cur == parent {
reached = true;
break;
}
cur = c;
ups += 1;
}
if reached {
let up = format!("..{}", std::path::MAIN_SEPARATOR);
format!("{}AGENTS.md", up.repeat(ups))
} else if let Ok(stripped) = p.strip_prefix(&config.cwd) {
stripped.display().to_string()
} else {
p.display().to_string()
}
}
} else {
p.display().to_string()
};
rels.push(display);
}
if rels.is_empty() {
"<none>".to_string()
} else {
rels.join(", ")
}
}
Err(_) => "<none>".to_string(),
}
}
pub(crate) fn compose_account_display(config: &Config) -> Option<StatusAccountDisplay> {
let auth_file = get_auth_file(&config.codex_home);
let auth = try_read_auth_json(&auth_file).ok()?;
if let Some(tokens) = auth.tokens.as_ref() {
let info = &tokens.id_token;
let email = info.email.clone();
let plan = info.get_chatgpt_plan_type().map(|plan| title_case(&plan));
return Some(StatusAccountDisplay::ChatGpt { email, plan });
}
if let Some(key) = auth.openai_api_key
&& !key.is_empty()
{
return Some(StatusAccountDisplay::ApiKey);
}
None
}
pub(crate) fn format_tokens_compact(value: u64) -> String {
if value == 0 {
return "0".to_string();
}
if value < 1_000 {
return value.to_string();
}
let (scaled, suffix) = if value >= 1_000_000_000_000 {
(value as f64 / 1_000_000_000_000.0, "T")
} else if value >= 1_000_000_000 {
(value as f64 / 1_000_000_000.0, "B")
} else if value >= 1_000_000 {
(value as f64 / 1_000_000.0, "M")
} else {
(value as f64 / 1_000.0, "K")
};
let decimals = if scaled < 10.0 {
2
} else if scaled < 100.0 {
1
} else {
0
};
let mut formatted = format!("{scaled:.decimals$}");
if formatted.contains('.') {
while formatted.ends_with('0') {
formatted.pop();
}
if formatted.ends_with('.') {
formatted.pop();
}
}
format!("{formatted}{suffix}")
}
pub(crate) fn format_directory_display(directory: &Path, max_width: Option<usize>) -> String {
let formatted = if let Some(rel) = relativize_to_home(directory) {
if rel.as_os_str().is_empty() {
"~".to_string()
} else {
format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display())
}
} else {
directory.display().to_string()
};
if let Some(max_width) = max_width {
if max_width == 0 {
return String::new();
}
if UnicodeWidthStr::width(formatted.as_str()) > max_width {
return text_formatting::center_truncate_path(&formatted, max_width);
}
}
formatted
}
pub(crate) fn format_reset_timestamp(dt: DateTime<Local>, captured_at: DateTime<Local>) -> String {
let time = dt.format("%H:%M").to_string();
if dt.date_naive() == captured_at.date_naive() {
time
} else {
format!("{} ({time})", dt.format("%-d %b"))
}
}
pub(crate) fn title_case(s: &str) -> String {
if s.is_empty() {
return String::new();
}
let mut chars = s.chars();
let first = match chars.next() {
Some(c) => c,
None => return String::new(),
};
let rest: String = chars.as_str().to_ascii_lowercase();
first.to_uppercase().collect::<String>() + &rest
}
+12
View File
@@ -0,0 +1,12 @@
mod account;
mod card;
mod format;
mod helpers;
mod rate_limits;
pub(crate) use card::new_status_output;
pub(crate) use rate_limits::RateLimitSnapshotDisplay;
pub(crate) use rate_limits::rate_limit_snapshot_display;
#[cfg(test)]
mod tests;
+117
View File
@@ -0,0 +1,117 @@
use super::helpers::format_reset_timestamp;
use chrono::DateTime;
use chrono::Duration as ChronoDuration;
use chrono::Local;
use codex_core::protocol::RateLimitSnapshot;
use codex_core::protocol::RateLimitWindow;
use std::convert::TryFrom;
const STATUS_LIMIT_BAR_SEGMENTS: usize = 20;
const STATUS_LIMIT_BAR_FILLED: &str = "█";
const STATUS_LIMIT_BAR_EMPTY: &str = "░";
pub(crate) const RESET_BULLET: &str = "·";
#[derive(Debug, Clone)]
pub(crate) struct StatusRateLimitRow {
pub label: &'static str,
pub percent_used: f64,
pub resets_at: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) enum StatusRateLimitData {
Available(Vec<StatusRateLimitRow>),
Missing,
}
#[derive(Debug, Clone)]
pub(crate) struct RateLimitWindowDisplay {
pub used_percent: f64,
pub resets_at: Option<String>,
}
impl RateLimitWindowDisplay {
fn from_window(window: &RateLimitWindow, captured_at: DateTime<Local>) -> Self {
let resets_at = window
.resets_in_seconds
.and_then(|seconds| i64::try_from(seconds).ok())
.and_then(|secs| captured_at.checked_add_signed(ChronoDuration::seconds(secs)))
.map(|dt| format_reset_timestamp(dt, captured_at));
Self {
used_percent: window.used_percent,
resets_at,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct RateLimitSnapshotDisplay {
pub primary: Option<RateLimitWindowDisplay>,
pub secondary: Option<RateLimitWindowDisplay>,
}
pub(crate) fn rate_limit_snapshot_display(
snapshot: &RateLimitSnapshot,
captured_at: DateTime<Local>,
) -> RateLimitSnapshotDisplay {
RateLimitSnapshotDisplay {
primary: snapshot
.primary
.as_ref()
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
secondary: snapshot
.secondary
.as_ref()
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
}
}
pub(crate) fn compose_rate_limit_data(
snapshot: Option<&RateLimitSnapshotDisplay>,
) -> StatusRateLimitData {
match snapshot {
Some(snapshot) => {
let mut rows = Vec::with_capacity(2);
if let Some(primary) = snapshot.primary.as_ref() {
rows.push(StatusRateLimitRow {
label: "5h limit",
percent_used: primary.used_percent,
resets_at: primary.resets_at.clone(),
});
}
if let Some(secondary) = snapshot.secondary.as_ref() {
rows.push(StatusRateLimitRow {
label: "Weekly limit",
percent_used: secondary.used_percent,
resets_at: secondary.resets_at.clone(),
});
}
if rows.is_empty() {
StatusRateLimitData::Missing
} else {
StatusRateLimitData::Available(rows)
}
}
None => StatusRateLimitData::Missing,
}
}
pub(crate) fn render_status_limit_progress_bar(percent_used: f64) -> String {
let ratio = (percent_used / 100.0).clamp(0.0, 1.0);
let filled = (ratio * STATUS_LIMIT_BAR_SEGMENTS as f64).round() as usize;
let filled = filled.min(STATUS_LIMIT_BAR_SEGMENTS);
let empty = STATUS_LIMIT_BAR_SEGMENTS.saturating_sub(filled);
format!(
"[{}{}]",
STATUS_LIMIT_BAR_FILLED.repeat(filled),
STATUS_LIMIT_BAR_EMPTY.repeat(empty)
)
}
pub(crate) fn format_status_limit_summary(percent_used: f64) -> String {
format!("{percent_used:.0}% used")
}
@@ -0,0 +1,19 @@
---
source: tui/src/status/tests.rs
expression: sanitized
---
/status
╭───────────────────────────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ Model: gpt-5-codex (reasoning high, summaries detailed) │
│ Directory: [[workspace]] │
│ Approval: on-request │
│ Sandbox: workspace-write │
│ Agents.md: <none> │
│ │
│ Token Usage: 1.9K total (1K input + 900 output) │
│ 5h limit: [███████████████░░░░░] 72% used · resets 03:14 │
│ Weekly limit: [█████████░░░░░░░░░░░] 45% used · resets 03:24 │
╰───────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,19 @@
---
source: tui/src/status/tests.rs
expression: sanitized
---
/status
╭────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ Model: gpt-5-codex (reasoning hig │
│ Directory: [[workspace]] │
│ Approval: on-request │
│ Sandbox: read-only │
│ Agents.md: <none> │
│ │
│ Token Usage: 1.9K total (1K input + 90 │
│ 5h limit: [███████████████░░░░░] 72% │
│ · resets 03:14 │
╰────────────────────────────────────────────╯
+183
View File
@@ -0,0 +1,183 @@
use super::new_status_output;
use super::rate_limit_snapshot_display;
use crate::history_cell::HistoryCell;
use chrono::TimeZone;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use codex_core::protocol::RateLimitSnapshot;
use codex_core::protocol::RateLimitWindow;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::TokenUsage;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
use insta::assert_snapshot;
use ratatui::prelude::*;
use std::path::PathBuf;
use tempfile::TempDir;
fn test_config(temp_home: &TempDir) -> Config {
Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
temp_home.path().to_path_buf(),
)
.expect("load config")
}
fn render_lines(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect()
}
fn sanitize_directory(lines: Vec<String>) -> Vec<String> {
lines
.into_iter()
.map(|line| {
if let (Some(dir_pos), Some(pipe_idx)) = (line.find("Directory: "), line.rfind('│')) {
let prefix = &line[..dir_pos + "Directory: ".len()];
let suffix = &line[pipe_idx..];
let content_width = pipe_idx.saturating_sub(dir_pos + "Directory: ".len());
let replacement = "[[workspace]]";
let mut rebuilt = prefix.to_string();
rebuilt.push_str(replacement);
if content_width > replacement.len() {
rebuilt.push_str(&" ".repeat(content_width - replacement.len()));
}
rebuilt.push_str(suffix);
rebuilt
} else {
line
}
})
.collect()
}
#[test]
fn status_snapshot_includes_reasoning_details() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home);
config.model = "gpt-5-codex".to_string();
config.model_provider_id = "openai".to_string();
config.model_reasoning_effort = Some(ReasoningEffort::High);
config.model_reasoning_summary = ReasoningSummary::Detailed;
config.sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
config.cwd = PathBuf::from("/workspace/tests");
let usage = TokenUsage {
input_tokens: 1_200,
cached_input_tokens: 200,
output_tokens: 900,
reasoning_output_tokens: 150,
total_tokens: 2_250,
};
let snapshot = RateLimitSnapshot {
primary: Some(RateLimitWindow {
used_percent: 72.5,
window_minutes: Some(300),
resets_in_seconds: Some(600),
}),
secondary: Some(RateLimitWindow {
used_percent: 45.0,
window_minutes: Some(1_440),
resets_in_seconds: Some(1_200),
}),
};
let captured_at = chrono::Local
.with_ymd_and_hms(2024, 1, 2, 3, 4, 5)
.single()
.expect("timestamp");
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let composite = new_status_output(&config, &usage, &None, Some(&rate_display));
let mut rendered_lines = render_lines(&composite.display_lines(80));
if cfg!(windows) {
for line in &mut rendered_lines {
*line = line.replace('\\', "/");
}
}
let sanitized = sanitize_directory(rendered_lines).join("\n");
assert_snapshot!(sanitized);
}
#[test]
fn status_card_token_usage_excludes_cached_tokens() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home);
config.model = "gpt-5-codex".to_string();
config.cwd = PathBuf::from("/workspace/tests");
let usage = TokenUsage {
input_tokens: 1_200,
cached_input_tokens: 200,
output_tokens: 900,
reasoning_output_tokens: 0,
total_tokens: 2_100,
};
let composite = new_status_output(&config, &usage, &None, None);
let rendered = render_lines(&composite.display_lines(120));
assert!(
rendered.iter().all(|line| !line.contains("cached")),
"cached tokens should not be displayed, got: {rendered:?}"
);
}
#[test]
fn status_snapshot_truncates_in_narrow_terminal() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home);
config.model = "gpt-5-codex".to_string();
config.model_provider_id = "openai".to_string();
config.model_reasoning_effort = Some(ReasoningEffort::High);
config.model_reasoning_summary = ReasoningSummary::Detailed;
config.cwd = PathBuf::from("/workspace/tests");
let usage = TokenUsage {
input_tokens: 1_200,
cached_input_tokens: 200,
output_tokens: 900,
reasoning_output_tokens: 150,
total_tokens: 2_250,
};
let snapshot = RateLimitSnapshot {
primary: Some(RateLimitWindow {
used_percent: 72.5,
window_minutes: Some(300),
resets_in_seconds: Some(600),
}),
secondary: None,
};
let captured_at = chrono::Local
.with_ymd_and_hms(2024, 1, 2, 3, 4, 5)
.single()
.expect("timestamp");
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let composite = new_status_output(&config, &usage, &None, Some(&rate_display));
let mut rendered_lines = render_lines(&composite.display_lines(46));
if cfg!(windows) {
for line in &mut rendered_lines {
*line = line.replace('\\', "/");
}
}
let sanitized = sanitize_directory(rendered_lines).join("\n");
assert_snapshot!(sanitized);
}