merge code-branch-20250925-045812 via /merge

This commit is contained in:
James Peter
2025-09-25 16:30:29 +10:00
18 changed files with 334 additions and 286 deletions
+1
View File
@@ -217,6 +217,7 @@ model_reasoning_summary = "detailed"
- `CODEX_HOME`: Override config directory location
- `OPENAI_API_KEY`: Use API key instead of ChatGPT auth
- `OPENAI_BASE_URL`: Use alternative API endpoints
- `OPENAI_WIRE_API`: Force the built-in OpenAI provider to use `chat` or `responses` wiring
 
## FAQ
+27 -4
View File
@@ -51,8 +51,15 @@ resolve_bin_path() {
fi
TARGET_DIR_ABS="${target_root}"
BIN_SUBPATH="${BIN_SUBDIR}/code"
BIN_CARGO_FILENAME="code"
BIN_FILENAME="code"
if [ "$PROFILE" = "perf" ]; then
BIN_FILENAME="code-perf"
fi
BIN_SUBPATH="${BIN_SUBDIR}/${BIN_FILENAME}"
BIN_CARGO_SUBPATH="${BIN_SUBDIR}/${BIN_CARGO_FILENAME}"
BIN_PATH="${TARGET_DIR_ABS}/${BIN_SUBPATH}"
BIN_CARGO_PATH="${TARGET_DIR_ABS}/${BIN_CARGO_SUBPATH}"
BIN_LINK_PATH="./target/${BIN_SUBPATH}"
if [ -n "${REPO_TARGET_ABS:-}" ] && [ "${TARGET_DIR_ABS}" = "${REPO_TARGET_ABS}" ]; then
@@ -382,15 +389,31 @@ ${USE_CARGO} build ${USE_LOCKED} --profile "${PROFILE}" --bin code --bin code-tu
if [ $? -eq 0 ]; then
resolve_bin_path
if [ "$PROFILE" = "perf" ]; then
PERF_SOURCE="${BIN_CARGO_PATH}"
PERF_TARGET="${BIN_PATH}"
if [ -e "${PERF_SOURCE}" ]; then
PERF_DIR="$(dirname "${PERF_TARGET}")"
mkdir -p "${PERF_DIR}"
if [ -e "${PERF_TARGET}" ] || [ -L "${PERF_TARGET}" ]; then
rm -f "${PERF_TARGET}"
fi
(
cd "${PERF_DIR}" >/dev/null 2>&1
ln -sf "$(basename "${PERF_SOURCE}")" "$(basename "${PERF_TARGET}")"
)
fi
fi
echo "✅ Build successful!"
echo "Binary location: ${BIN_DISPLAY_PATH}"
echo ""
# Keep old symlink locations working for compatibility
# Create symlink in target/release for npm wrapper expectations
release_link_target="../${BIN_SUBDIR}/code"
cli_link_target="../../codex-rs/target/${BIN_SUBDIR}/code"
dev_fast_link_target="../${BIN_SUBDIR}/code"
release_link_target="../${BIN_SUBDIR}/${BIN_FILENAME}"
cli_link_target="../../codex-rs/target/${BIN_SUBDIR}/${BIN_FILENAME}"
dev_fast_link_target="../${BIN_SUBDIR}/${BIN_FILENAME}"
if [ "${TARGET_DIR_ABS}" != "${REPO_TARGET_ABS}" ]; then
release_link_target="${BIN_PATH}"
+10 -1
View File
@@ -169,6 +169,11 @@ function resolveGlobalBinDir() {
return '';
}
function looksLikeOurCodeShim(contents) {
if (!contents) return false;
return contents.includes('@just-every/code') || contents.includes('$(dirname "$0")/coder') || contents.includes('%~dp0coder');
}
async function downloadBinary(url, dest, maxRedirects = 5, maxRetries = 3) {
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
@@ -711,8 +716,12 @@ async function main() {
skippedCmds.push({ name: 'code', reason: `existing: ${reason}` });
} else if (!shimLooksOurs) {
console.error(`✓ Skipped global 'code' shim (different CLI at ${ourShim})`);
skippedCmds.push({ name: 'code', reason: `existing: ${ourShim}` });
const reason = conflictPaths[0] || ourShim;
skippedCmds.push({ name: 'code', reason: `existing: ${reason}` });
}
} else {
const reason = conflictPaths[0] || 'another command on PATH';
skippedCmds.push({ name: 'code', reason: `existing: ${reason}` });
}
} catch (e) {
console.error(`⚠ Could not remove npm shim '${ourShim}': ${e.message}`);
+65 -28
View File
@@ -55,6 +55,18 @@ pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_too
const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"];
fn looks_like_bash(cmd: &str) -> bool {
let trimmed = cmd.trim_matches('"').trim_matches('\'');
if trimmed.eq_ignore_ascii_case("bash") || trimmed.eq_ignore_ascii_case("bash.exe") {
return true;
}
Path::new(trimmed)
.file_name()
.and_then(|s| s.to_str())
.map(|name| name.eq_ignore_ascii_case("bash") || name.eq_ignore_ascii_case("bash.exe"))
.unwrap_or(false)
}
#[derive(Debug, Error, PartialEq)]
pub enum ApplyPatchError {
#[error(transparent)]
@@ -128,7 +140,7 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
Err(e) => MaybeApplyPatch::PatchParseError(e),
},
// Bash heredoc form: (optional `cd <path> &&`) apply_patch <<'EOF' ...
[bash, flag, script] if bash == "bash" && flag == "-lc" => {
[bash, flag, script] if looks_like_bash(bash) && flag == "-lc" => {
match extract_apply_patch_from_bash(script) {
Ok((body, workdir)) => match parse_patch(&body) {
Ok(mut source) => {
@@ -248,7 +260,7 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp
);
}
}
[bash, flag, script] if bash == "bash" && flag == "-lc" => {
[bash, flag, script] if looks_like_bash(bash) && flag == "-lc" => {
if parse_patch(script).is_ok() {
return MaybeApplyPatchVerified::CorrectnessError(
ApplyPatchError::ImplicitInvocation,
@@ -886,6 +898,10 @@ mod tests {
strs_to_strings(&["bash", "-lc", script])
}
fn args_abs_bash(script: &str) -> Vec<String> {
strs_to_strings(&["/bin/bash", "-lc", script])
}
fn heredoc_script(prefix: &str) -> String {
format!(
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"
@@ -905,8 +921,7 @@ mod tests {
}]
}
fn assert_match(script: &str, expected_workdir: Option<&str>) {
let args = args_bash(script);
fn assert_match_args(args: Vec<String>, expected_workdir: Option<&str>) {
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
assert_eq!(workdir.as_deref(), expected_workdir);
@@ -916,14 +931,23 @@ mod tests {
}
}
fn assert_not_match(script: &str) {
let args = args_bash(script);
fn assert_match(script: &str, expected_workdir: Option<&str>) {
assert_match_args(args_bash(script), expected_workdir);
assert_match_args(args_abs_bash(script), expected_workdir);
}
fn assert_not_match_args(args: Vec<String>) {
assert!(matches!(
maybe_parse_apply_patch(&args),
MaybeApplyPatch::NotApplyPatch
));
}
fn assert_not_match(script: &str) {
assert_not_match_args(args_bash(script));
assert_not_match_args(args_abs_bash(script));
}
#[test]
fn test_implicit_patch_single_arg_is_error() {
let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string();
@@ -938,12 +962,13 @@ mod tests {
#[test]
fn test_implicit_patch_bash_script_is_error() {
let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch";
let args = args_bash(script);
let dir = tempdir().unwrap();
assert!(matches!(
maybe_parse_apply_patch_verified(&args, dir.path()),
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
));
for args in [args_bash(script), args_abs_bash(script)] {
assert!(matches!(
maybe_parse_apply_patch_verified(&args, dir.path()),
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
));
}
}
#[test]
@@ -1003,29 +1028,41 @@ mod tests {
#[test]
fn test_heredoc_applypatch() {
let args = strs_to_strings(&[
"bash",
"-lc",
r#"applypatch <<'PATCH'
for args in [
strs_to_strings(&[
"bash",
"-lc",
r#"applypatch <<'PATCH'
*** Begin Patch
*** Add File: foo
+hi
*** End Patch
PATCH"#,
]);
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
assert_eq!(workdir, None);
assert_eq!(
hunks,
vec![Hunk::AddFile {
path: PathBuf::from("foo"),
contents: "hi\n".to_string()
}]
);
]),
strs_to_strings(&[
"/bin/bash",
"-lc",
r#"applypatch <<'PATCH'
*** Begin Patch
*** Add File: foo
+hi
*** End Patch
PATCH"#,
]),
] {
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
assert_eq!(workdir, None);
assert_eq!(
hunks,
vec![Hunk::AddFile {
path: PathBuf::from("foo"),
contents: "hi\n".to_string()
}]
);
}
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
}
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
}
}
+1 -1
View File
@@ -123,7 +123,7 @@ How long Codex will wait for activity on a streaming response before treating th
## model_provider
Identifies which provider to use from the `model_providers` map. Defaults to `"openai"`. You can override the `base_url` for the built-in `openai` provider via the `OPENAI_BASE_URL` environment variable.
Identifies which provider to use from the `model_providers` map. Defaults to `"openai"`. You can override the `base_url` for the built-in `openai` provider via the `OPENAI_BASE_URL` environment variable and force the wire protocol (`"responses"` or `"chat"`) with `OPENAI_WIRE_API`.
Note that if you override `model_provider`, then you likely want to override
`model`, as well. For example, if you are running ollama with Mistral locally,
+19 -4
View File
@@ -72,8 +72,10 @@ struct RateLimitInfo {
snapshot: Option<RateLimitSnapshotEvent>,
#[serde(default)]
observed_at: Option<DateTime<Utc>>,
#[serde(default, alias = "next_reset_at")]
primary_next_reset_at: Option<DateTime<Utc>>,
#[serde(default)]
next_reset_at: Option<DateTime<Utc>>,
secondary_next_reset_at: Option<DateTime<Utc>>,
#[serde(default)]
last_usage_limit_hit_at: Option<DateTime<Utc>>,
}
@@ -141,7 +143,8 @@ pub struct StoredRateLimitSnapshot {
pub plan: Option<String>,
pub snapshot: Option<RateLimitSnapshotEvent>,
pub observed_at: Option<DateTime<Utc>>,
pub next_reset_at: Option<DateTime<Utc>>,
pub primary_next_reset_at: Option<DateTime<Utc>>,
pub secondary_next_reset_at: Option<DateTime<Utc>>,
pub last_usage_limit_hit_at: Option<DateTime<Utc>>,
}
@@ -252,6 +255,12 @@ pub fn record_rate_limit_snapshot(
let mut info = data.rate_limit.take().unwrap_or_default();
info.snapshot = Some(snapshot.clone());
info.observed_at = Some(observed_at);
info.primary_next_reset_at = snapshot
.primary_reset_after_seconds
.map(|seconds| observed_at + Duration::seconds(seconds as i64));
info.secondary_next_reset_at = snapshot
.secondary_reset_after_seconds
.map(|seconds| observed_at + Duration::seconds(seconds as i64));
data.rate_limit = Some(info);
})
}
@@ -294,12 +303,17 @@ pub fn list_rate_limit_snapshots(
Err(_) => continue,
};
let rate = data.rate_limit.unwrap_or_default();
let primary_next_reset_at = rate.primary_next_reset_at;
let secondary_next_reset_at = rate
.secondary_next_reset_at
.or(rate.primary_next_reset_at);
results.push(StoredRateLimitSnapshot {
account_id: data.account_id,
plan: data.plan,
snapshot: rate.snapshot,
observed_at: rate.observed_at,
next_reset_at: rate.next_reset_at,
primary_next_reset_at,
secondary_next_reset_at,
last_usage_limit_hit_at: rate.last_usage_limit_hit_at,
});
}
@@ -330,7 +344,8 @@ pub fn record_usage_limit_hint(
info.last_usage_limit_hit_at = Some(observed_at);
if let Some(seconds) = resets_in_seconds {
let reset_at = observed_at + Duration::seconds(seconds as i64);
info.next_reset_at = Some(reset_at);
info.primary_next_reset_at = Some(reset_at);
info.secondary_next_reset_at = Some(reset_at);
}
data.rate_limit = Some(info);
})
+13 -1
View File
@@ -15,6 +15,7 @@ use tokio::time::timeout;
use tracing::debug;
use tracing::trace;
use crate::auth::AuthManager;
use crate::ModelProviderInfo;
use crate::client_common::Prompt;
use crate::client_common::ResponseEvent;
@@ -26,6 +27,7 @@ use crate::model_family::ModelFamily;
use crate::openai_tools::create_tools_json_for_chat_completions_api;
use crate::util::backoff;
use std::sync::{Arc, Mutex};
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ResponseItem;
@@ -38,6 +40,7 @@ pub(crate) async fn stream_chat_completions(
client: &reqwest::Client,
provider: &ModelProviderInfo,
debug_logger: &Arc<Mutex<DebugLogger>>,
auth_manager: Option<Arc<AuthManager>>,
) -> Result<ResponseStream> {
if prompt.output_schema.is_some() {
return Err(CodexErr::UnsupportedOperation(
@@ -350,7 +353,16 @@ pub(crate) async fn stream_chat_completions(
loop {
attempt += 1;
let req_builder = provider.create_request_builder(client, &None).await?;
let auth = auth_manager.as_ref().and_then(|m| m.auth());
let mut req_builder = provider.create_request_builder(client, &auth).await?;
if let Some(auth) = auth.as_ref() {
if auth.mode == AuthMode::ChatGPT {
if let Some(account_id) = auth.get_account_id() {
req_builder = req_builder.header("chatgpt-account-id", account_id);
}
}
}
let res = req_builder
.header(reqwest::header::ACCEPT, "text/event-stream")
+7
View File
@@ -160,6 +160,7 @@ impl ModelClient {
&self.client,
&self.provider,
&self.debug_logger,
self.auth_manager.clone(),
)
.await?;
@@ -703,6 +704,10 @@ fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option<RateLimitSnapshotEve
parse_header_f64(headers, "x-codex-primary-over-secondary-limit-percent")?;
let primary_window_minutes = parse_header_u64(headers, "x-codex-primary-window-minutes")?;
let secondary_window_minutes = parse_header_u64(headers, "x-codex-secondary-window-minutes")?;
let primary_reset_after_seconds =
parse_header_u64(headers, "x-codex-primary-reset-after-seconds");
let secondary_reset_after_seconds =
parse_header_u64(headers, "x-codex-secondary-reset-after-seconds");
Some(RateLimitSnapshotEvent {
primary_used_percent,
@@ -710,6 +715,8 @@ fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option<RateLimitSnapshotEve
primary_to_secondary_ratio_percent,
primary_window_minutes,
secondary_window_minutes,
primary_reset_after_seconds,
secondary_reset_after_seconds,
})
}
+1 -7
View File
@@ -4754,13 +4754,7 @@ async fn try_run_turn(
}
ResponseEvent::RateLimits(snapshot) => {
let mut state = sess.state.lock().unwrap();
state.latest_rate_limits = Some(RateLimitSnapshotEvent {
primary_used_percent: snapshot.primary_used_percent,
secondary_used_percent: snapshot.secondary_used_percent,
primary_to_secondary_ratio_percent: snapshot.primary_to_secondary_ratio_percent,
primary_window_minutes: snapshot.primary_window_minutes,
secondary_window_minutes: snapshot.secondary_window_minutes,
});
state.latest_rate_limits = Some(snapshot.clone());
if let Some(ctx) = account_usage_context(sess) {
let usage_home = ctx.codex_home.clone();
let usage_account = ctx.account_id.clone();
-1
View File
@@ -14,7 +14,6 @@ use serde_json::Value;
use std::collections::{BTreeMap, HashMap};
use std::env::VarError;
use std::time::Duration;
use crate::error::EnvVarError;
const DEFAULT_STREAM_IDLE_TIMEOUT_MS: u64 = 300_000;
const DEFAULT_STREAM_MAX_RETRIES: u64 = 5;
+18 -2
View File
@@ -596,12 +596,12 @@ fn rate_limit_snapshot_to_protocol(
let primary = codex_protocol::protocol::RateLimitWindow {
used_percent: snapshot.primary_used_percent,
window_minutes: Some(snapshot.primary_window_minutes),
resets_in_seconds: None,
resets_in_seconds: snapshot.primary_reset_after_seconds,
};
let secondary = codex_protocol::protocol::RateLimitWindow {
used_percent: snapshot.secondary_used_percent,
window_minutes: Some(snapshot.secondary_window_minutes),
resets_in_seconds: None,
resets_in_seconds: snapshot.secondary_reset_after_seconds,
};
codex_protocol::protocol::RateLimitSnapshot {
primary: Some(primary),
@@ -629,11 +629,19 @@ fn rate_limit_snapshot_from_protocol(
.as_ref()
.and_then(|window| window.window_minutes)
.unwrap_or(0);
let primary_reset_after_seconds = snapshot
.primary
.as_ref()
.and_then(|window| window.resets_in_seconds);
let secondary_window_minutes = snapshot
.secondary
.as_ref()
.and_then(|window| window.window_minutes)
.unwrap_or(0);
let secondary_reset_after_seconds = snapshot
.secondary
.as_ref()
.and_then(|window| window.resets_in_seconds);
let ratio_percent = match (primary_window_minutes, secondary_window_minutes) {
(0, _) | (_, 0) => f64::NAN,
@@ -649,6 +657,8 @@ fn rate_limit_snapshot_from_protocol(
primary_to_secondary_ratio_percent: ratio_percent,
primary_window_minutes,
secondary_window_minutes,
primary_reset_after_seconds,
secondary_reset_after_seconds,
}
}
@@ -983,6 +993,12 @@ pub struct RateLimitSnapshotEvent {
pub primary_window_minutes: u64,
/// Rolling window duration for the secondary limit, in minutes.
pub secondary_window_minutes: u64,
/// Seconds until the primary window resets, if reported by the API.
#[serde(skip_serializing_if = "Option::is_none")]
pub primary_reset_after_seconds: Option<u64>,
/// Seconds until the secondary window resets, if reported by the API.
#[serde(skip_serializing_if = "Option::is_none")]
pub secondary_reset_after_seconds: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
+48 -4
View File
@@ -31,11 +31,13 @@ pub fn assess_patch_safety(
};
}
// In Read Only mode, write operations are not permitted. Reject immediately
// so the agent receives a clear failure without prompting for approval.
// In Read Only mode, we need explicit user approval before writing.
if matches!(sandbox_policy, SandboxPolicy::ReadOnly) {
return SafetyCheck::Reject {
reason: "write operations are disabled in read-only mode".to_string(),
return match policy {
AskForApproval::Never => SafetyCheck::Reject {
reason: "write operations require approval but approval policy is set to never".to_string(),
},
_ => SafetyCheck::AskUser,
};
}
@@ -316,6 +318,48 @@ mod tests {
));
}
#[test]
fn test_read_only_patch_requests_approval() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_path_buf();
let action = ApplyPatchAction::new_add_for_test(&cwd.join("file.txt"), "".to_string());
let result = assess_patch_safety(
&action,
AskForApproval::OnRequest,
&SandboxPolicy::ReadOnly,
&cwd,
);
assert_eq!(result, SafetyCheck::AskUser);
}
#[test]
fn test_read_only_patch_rejects_when_policy_never() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_path_buf();
let action = ApplyPatchAction::new_add_for_test(&cwd.join("file.txt"), "".to_string());
let result = assess_patch_safety(
&action,
AskForApproval::Never,
&SandboxPolicy::ReadOnly,
&cwd,
);
match result {
SafetyCheck::Reject { reason } => {
assert_eq!(
reason,
"write operations require approval but approval policy is set to never"
);
}
other => {
panic!("expected rejection, got {:?}", other);
}
}
}
#[test]
fn test_request_escalated_privileges() {
// Should not be a trusted command
+35 -5
View File
@@ -24,6 +24,7 @@ use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use crossterm::execute;
use crossterm::terminal::supports_keyboard_enhancement;
use crossterm::SynchronizedUpdate; // trait for stdout().sync_update
@@ -48,10 +49,10 @@ use tokio::sync::oneshot;
/// Time window for debouncing redraw requests.
///
/// Raising this slightly helps coalesce bursts of updates during typing and
/// reduces render thrash, improving perceived input latency while staying
/// comfortably under a 60 FPS refresh budget.
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(16);
/// Temporarily widened to ~30 FPS (33 ms) to coalesce bursts of updates while
/// we smooth out per-frame hotspots; keeps redraws responsive without pegging
/// the main thread.
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(33);
const DEFAULT_PTY_ROWS: u16 = 24;
const DEFAULT_PTY_COLS: u16 = 80;
@@ -121,6 +122,11 @@ pub(crate) struct App<'a> {
_transcript_saved_viewport: Option<Rect>,
enhanced_keys_supported: bool,
/// Last key reported as a physical `Press`/`Repeat` while keyboard
/// enhancements were unavailable. Used to drop the matching `Release` echo
/// that some Windows terminals emit, without impacting release-only
/// terminals whose characters arrive on key-up.
last_physical_press_without_enhancements: Option<(KeyCode, KeyModifiers)>,
/// Debug flag for logging LLM requests/responses
_debug: bool,
@@ -363,6 +369,7 @@ impl App<'_> {
_deferred_history_lines: Vec::new(),
_transcript_saved_viewport: None,
enhanced_keys_supported,
last_physical_press_without_enhancements: None,
_debug: debug,
show_order_overlay,
commit_anim_running: Arc::new(AtomicBool::new(false)),
@@ -995,7 +1002,30 @@ impl App<'_> {
// report only Release events. Normalize such events to Press so
// keys register consistently.
if !self.enhanced_keys_supported {
key_event = KeyEvent::new(key_event.code, key_event.modifiers);
match key_event.kind {
KeyEventKind::Press | KeyEventKind::Repeat => {
self.last_physical_press_without_enhancements = Some((
key_event.code.clone(),
key_event.modifiers,
));
}
KeyEventKind::Release => {
if let Some((code, modifiers)) =
&self.last_physical_press_without_enhancements
{
if *code == key_event.code && *modifiers == key_event.modifiers
{
self.last_physical_press_without_enhancements = None;
continue;
}
}
// Some terminals (notably Windows Git Bash/mintty without
// enhanced key support) synthesize characters only on key-up
// (e.g., Alt+NumPad) or never emit a physical press. Treat those
// as Press so they register once.
key_event.kind = KeyEventKind::Press;
}
}
}
// Reset double‑Esc timer on any non‑Esc key
if !matches!(key_event.code, KeyCode::Esc) {
+39 -181
View File
@@ -2,7 +2,7 @@ use once_cell::sync::Lazy;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::rc::{Rc, Weak};
use std::sync::Arc;
use std::sync::mpsc::Sender;
@@ -123,8 +123,6 @@ use ratatui_image::picker::Picker;
use std::cell::Cell;
use std::cell::RefCell;
use std::sync::mpsc;
use std::fs::OpenOptions;
use std::io::{self, Read, Seek, SeekFrom, Write};
use tokio::sync::mpsc::UnboundedSender;
fn history_cell_logging_enabled() -> bool {
@@ -142,7 +140,6 @@ fn history_cell_logging_enabled() -> bool {
use serde_json::Value as JsonValue;
use tokio::sync::mpsc::unbounded_channel;
use tracing::info;
use fs2::FileExt;
// use image::GenericImageView;
use crate::app_event::{
@@ -274,7 +271,6 @@ struct RunningCommand {
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [50.0, 75.0, 90.0];
const RATE_LIMIT_REFRESH_INTERVAL: chrono::Duration = chrono::Duration::minutes(10);
const RATE_LIMIT_RESET_FILE: &str = "state/rate_limit_resets.json";
const MAX_TRACKED_GHOST_COMMITS: usize = 20;
#[derive(Default)]
@@ -379,8 +375,8 @@ pub(crate) struct ChatWidget<'a> {
rate_limit_warnings: RateLimitWarningState,
rate_limit_fetch_inflight: bool,
rate_limit_last_fetch_at: Option<DateTime<Utc>>,
rate_limit_last_primary_reset_at: Option<chrono::DateTime<chrono::Utc>>,
rate_limit_last_weekly_reset_at: Option<chrono::DateTime<chrono::Utc>>,
rate_limit_primary_next_reset_at: Option<DateTime<Utc>>,
rate_limit_secondary_next_reset_at: Option<DateTime<Utc>>,
content_buffer: String,
// Buffer for streaming assistant answer text; we do not surface partial
// We wait for the final AgentMessage event and then emit the full text
@@ -2540,12 +2536,6 @@ impl ChatWidget<'_> {
// Initialize image protocol for rendering screenshots
let reset_cache = load_rate_limit_reset_cache(&config.codex_home);
let RateLimitResetCache {
primary_last_reset,
weekly_last_reset,
} = reset_cache;
let mut new_widget = Self {
app_event_tx: app_event_tx.clone(),
codex_op_tx,
@@ -2572,8 +2562,8 @@ impl ChatWidget<'_> {
rate_limit_warnings: RateLimitWarningState::default(),
rate_limit_fetch_inflight: false,
rate_limit_last_fetch_at: None,
rate_limit_last_primary_reset_at: primary_last_reset,
rate_limit_last_weekly_reset_at: weekly_last_reset,
rate_limit_primary_next_reset_at: None,
rate_limit_secondary_next_reset_at: None,
content_buffer: String::new(),
last_assistant_message: None,
exec: ExecState {
@@ -2694,6 +2684,14 @@ impl ChatWidget<'_> {
system_cell_by_id: HashMap::new(),
standard_terminal_mode: !config.tui.alternate_screen,
};
if let Ok(Some(active_id)) = auth_accounts::get_active_account_id(&config.codex_home) {
if let Ok(records) = account_usage::list_rate_limit_snapshots(&config.codex_home) {
if let Some(record) = records.into_iter().find(|r| r.account_id == active_id) {
new_widget.rate_limit_primary_next_reset_at = record.primary_next_reset_at;
new_widget.rate_limit_secondary_next_reset_at = record.secondary_next_reset_at;
}
}
}
// Seed footer access indicator based on current config
new_widget.apply_access_mode_indicator_from_config();
// Insert the welcome cell as top-of-first-request so future model output
@@ -2774,12 +2772,6 @@ impl ChatWidget<'_> {
// Basic widget state mirrors `new`
let history_cells: Vec<Box<dyn HistoryCell>> = Vec::new();
let reset_cache = load_rate_limit_reset_cache(&config.codex_home);
let RateLimitResetCache {
primary_last_reset,
weekly_last_reset,
} = reset_cache;
let mut w = Self {
app_event_tx: app_event_tx.clone(),
codex_op_tx,
@@ -2803,8 +2795,8 @@ impl ChatWidget<'_> {
rate_limit_warnings: RateLimitWarningState::default(),
rate_limit_fetch_inflight: false,
rate_limit_last_fetch_at: None,
rate_limit_last_primary_reset_at: primary_last_reset,
rate_limit_last_weekly_reset_at: weekly_last_reset,
rate_limit_primary_next_reset_at: None,
rate_limit_secondary_next_reset_at: None,
content_buffer: String::new(),
last_assistant_message: None,
exec: ExecState {
@@ -2923,6 +2915,14 @@ impl ChatWidget<'_> {
synthetic_system_req: None,
system_cell_by_id: HashMap::new(),
};
if let Ok(Some(active_id)) = auth_accounts::get_active_account_id(&config.codex_home) {
if let Ok(records) = account_usage::list_rate_limit_snapshots(&config.codex_home) {
if let Some(record) = records.into_iter().find(|r| r.account_id == active_id) {
w.rate_limit_primary_next_reset_at = record.primary_next_reset_at;
w.rate_limit_secondary_next_reset_at = record.secondary_next_reset_at;
}
}
}
w.set_standard_terminal_mode(!config.tui.alternate_screen);
if show_welcome {
w.history_push_top_next_req(history_cell::new_animated_welcome());
@@ -3490,17 +3490,15 @@ impl ChatWidget<'_> {
match record {
Some(record) => {
if let Some(snapshot) = record.snapshot.clone() {
let view_snapshot = RateLimitSnapshotEvent {
primary_used_percent: snapshot.primary_used_percent,
secondary_used_percent: snapshot.secondary_used_percent,
primary_to_secondary_ratio_percent:
snapshot.primary_to_secondary_ratio_percent,
primary_window_minutes: snapshot.primary_window_minutes,
secondary_window_minutes: snapshot.secondary_window_minutes,
let view_snapshot = snapshot.clone();
let view_reset = RateLimitResetInfo {
primary_next_reset: record.primary_next_reset_at,
secondary_next_reset: record.secondary_next_reset_at,
..RateLimitResetInfo::default()
};
let view = build_limits_view(
&view_snapshot,
RateLimitResetInfo::default(),
view_reset,
DEFAULT_GRID_CONFIG,
);
let header = Self::account_header_lines(
@@ -5871,8 +5869,7 @@ impl ChatWidget<'_> {
self.last_token_usage = info.last_token_usage.clone();
}
if let Some(snapshot) = event.rate_limits {
let previous_snapshot = self.rate_limit_snapshot.clone();
self.update_rate_limit_resets(previous_snapshot.as_ref(), &snapshot);
self.update_rate_limit_resets(&snapshot);
let warnings = self
.rate_limit_warnings
.take_warnings(snapshot.secondary_used_percent, snapshot.primary_used_percent);
@@ -7215,8 +7212,8 @@ impl ChatWidget<'_> {
let context_tokens_used = context_window.map(|_| self.last_token_usage.tokens_in_context_window());
RateLimitResetInfo {
primary_last_reset: self.rate_limit_last_primary_reset_at,
weekly_last_reset: self.rate_limit_last_weekly_reset_at,
primary_next_reset: self.rate_limit_primary_next_reset_at,
secondary_next_reset: self.rate_limit_secondary_next_reset_at,
session_tokens_used,
auto_compact_limit,
overflow_auto_compact: true,
@@ -7225,65 +7222,14 @@ impl ChatWidget<'_> {
}
}
fn update_rate_limit_resets(
&mut self,
previous: Option<&RateLimitSnapshotEvent>,
current: &RateLimitSnapshotEvent,
) {
fn update_rate_limit_resets(&mut self, current: &RateLimitSnapshotEvent) {
let now = Utc::now();
let mut changed_primary = false;
let mut changed_weekly = false;
if let Some(prev) = previous {
if current.primary_used_percent + 1.0 < prev.primary_used_percent {
if self
.rate_limit_last_primary_reset_at
.map_or(true, |existing| now > existing)
{
self.rate_limit_last_primary_reset_at = Some(now);
changed_primary = true;
}
}
if current.secondary_used_percent + 0.5 < prev.secondary_used_percent {
if self
.rate_limit_last_weekly_reset_at
.map_or(true, |existing| now > existing)
{
self.rate_limit_last_weekly_reset_at = Some(now);
changed_weekly = true;
}
}
} else {
if current.primary_used_percent <= 1.0
&& self.rate_limit_last_primary_reset_at.is_none()
{
self.rate_limit_last_primary_reset_at = Some(now);
changed_primary = true;
}
if current.secondary_used_percent <= 1.0
&& self.rate_limit_last_weekly_reset_at.is_none()
{
self.rate_limit_last_weekly_reset_at = Some(now);
changed_weekly = true;
}
}
if changed_primary || changed_weekly {
let candidate_info = self.rate_limit_reset_info();
let candidate_cache = RateLimitResetCache {
primary_last_reset: candidate_info.primary_last_reset,
weekly_last_reset: candidate_info.weekly_last_reset,
};
match persist_rate_limit_reset_cache(&self.config.codex_home, candidate_cache) {
Ok(merged) => {
self.rate_limit_last_primary_reset_at = merged.primary_last_reset;
self.rate_limit_last_weekly_reset_at = merged.weekly_last_reset;
}
Err(err) => {
tracing::warn!("failed to persist rate limit resets: {err:?}");
}
}
}
self.rate_limit_primary_next_reset_at = current
.primary_reset_after_seconds
.map(|secs| now + ChronoDuration::seconds(secs as i64));
self.rate_limit_secondary_next_reset_at = current
.secondary_reset_after_seconds
.map(|secs| now + ChronoDuration::seconds(secs as i64));
}
pub(crate) fn handle_update_command(&mut self) {
@@ -17203,94 +17149,6 @@ impl ChatWidget<'_> {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
struct RateLimitResetCache {
#[serde(default)]
primary_last_reset: Option<DateTime<Utc>>,
#[serde(default)]
weekly_last_reset: Option<DateTime<Utc>>,
}
fn load_rate_limit_reset_cache(codex_home: &Path) -> RateLimitResetCache {
let read_path = resolve_codex_path_for_read(codex_home, Path::new(RATE_LIMIT_RESET_FILE));
match std::fs::read_to_string(&read_path) {
Ok(contents) => match serde_json::from_str::<RateLimitResetCache>(&contents) {
Ok(cache) => cache,
Err(err) => {
tracing::debug!("failed to parse rate limit reset cache: {err:?}");
RateLimitResetCache::default()
}
},
Err(err) => {
if err.kind() != io::ErrorKind::NotFound {
tracing::debug!("failed to read rate limit reset cache: {err:?}");
}
RateLimitResetCache::default()
}
}
}
fn persist_rate_limit_reset_cache(
codex_home: &Path,
candidate: RateLimitResetCache,
) -> io::Result<RateLimitResetCache> {
if candidate.primary_last_reset.is_none() && candidate.weekly_last_reset.is_none() {
return Ok(candidate);
}
let path = rate_limit_reset_cache_path(codex_home);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open(&path)?;
FileExt::lock_exclusive(&file)?;
let mut buf = String::new();
file.seek(SeekFrom::Start(0))?;
file.read_to_string(&mut buf)?;
let mut existing: RateLimitResetCache = if buf.trim().is_empty() {
RateLimitResetCache::default()
} else {
serde_json::from_str(&buf).unwrap_or_default()
};
let mut changed = false;
changed |= merge_timestamp(&mut existing.primary_last_reset, candidate.primary_last_reset);
changed |= merge_timestamp(&mut existing.weekly_last_reset, candidate.weekly_last_reset);
if changed {
let serialized = serde_json::to_string_pretty(&existing)
.map_err(|err| io::Error::new(io::ErrorKind::Other, err))?;
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
file.write_all(serialized.as_bytes())?;
file.flush()?;
}
let _ = FileExt::unlock(&file);
Ok(existing)
}
fn rate_limit_reset_cache_path(codex_home: &Path) -> PathBuf {
codex_home.join(RATE_LIMIT_RESET_FILE)
}
fn merge_timestamp(dst: &mut Option<DateTime<Utc>>, candidate: Option<DateTime<Utc>>) -> bool {
match (dst.as_ref(), candidate) {
(_, None) => false,
(Some(existing), Some(candidate_dt)) if *existing >= candidate_dt => false,
_ => {
*dst = candidate;
true
}
}
}
impl WidgetRef for &ChatWidget<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Top-level widget render timing
@@ -5,7 +5,7 @@ use codex_core::{AuthManager, ModelClient, Prompt, ResponseEvent};
use codex_core::config::Config;
use codex_core::config_types::ReasoningEffort;
use codex_core::debug_logger::DebugLogger;
use codex_core::protocol::{Event, EventMsg, TokenCountEvent, RateLimitSnapshotEvent as CoreRateLimitSnapshotEvent};
use codex_core::protocol::{Event, EventMsg, RateLimitSnapshotEvent, TokenCountEvent};
use codex_protocol::models::{ContentItem, ResponseItem};
use futures::StreamExt;
use tokio::runtime::Runtime;
@@ -80,13 +80,7 @@ fn run_refresh(
let proto_snapshot = snapshot.context("rate limit snapshot missing from response")?;
let snapshot = CoreRateLimitSnapshotEvent {
primary_used_percent: proto_snapshot.primary_used_percent,
secondary_used_percent: proto_snapshot.secondary_used_percent,
primary_to_secondary_ratio_percent: proto_snapshot.primary_to_secondary_ratio_percent,
primary_window_minutes: proto_snapshot.primary_window_minutes,
secondary_window_minutes: proto_snapshot.secondary_window_minutes,
};
let snapshot: RateLimitSnapshotEvent = proto_snapshot.clone();
let event = Event {
id: "rate-limit-refresh".to_string(),
+8
View File
@@ -196,7 +196,10 @@ fn limits_overlay_renders_snapshot() {
primary_to_secondary_ratio_percent: 0.0,
primary_window_minutes: 300,
secondary_window_minutes: 10_080,
primary_reset_after_seconds: Some(600),
secondary_reset_after_seconds: Some(3_600),
});
chat.update_rate_limit_resets(chat.rate_limit_snapshot.as_ref().unwrap());
chat.rate_limit_last_fetch_at = Some(Utc::now());
chat.add_limits_output();
@@ -218,6 +221,11 @@ fn limits_overlay_renders_snapshot() {
assert!(joined.contains("Weekly Limit"), "overlay text: {joined}");
assert!(joined.contains(" Type: "));
assert!(joined.contains(" Plan: "));
assert!(joined.contains("Resets"), "overlay text: {joined}");
assert!(
!joined.contains("awaiting reset timing"),
"expected reset timings to render: {joined}"
);
assert!(joined.contains(" Total: "));
assert!(joined.contains("Chart"));
assert!(joined.contains("7 Day History"));
+39 -38
View File
@@ -87,8 +87,8 @@ pub(crate) struct GridConfig {
#[derive(Clone, Debug, Default)]
pub(crate) struct RateLimitResetInfo {
pub(crate) primary_last_reset: Option<DateTime<Utc>>,
pub(crate) weekly_last_reset: Option<DateTime<Utc>>,
pub(crate) primary_next_reset: Option<DateTime<Utc>>,
pub(crate) secondary_next_reset: Option<DateTime<Utc>>,
pub(crate) session_tokens_used: Option<u64>,
pub(crate) auto_compact_limit: Option<u64>,
pub(crate) overflow_auto_compact: bool,
@@ -178,11 +178,11 @@ fn build_summary_lines(
));
lines.push(build_hourly_window_line(
metrics,
reset_info.primary_last_reset,
reset_info.primary_next_reset,
));
lines.push(build_hourly_reset_line(
snapshot.primary_window_minutes,
reset_info.primary_last_reset,
reset_info.primary_next_reset,
));
lines.push("".into());
@@ -196,11 +196,11 @@ fn build_summary_lines(
));
lines.push(build_weekly_window_line(
metrics.weekly_window_minutes,
reset_info.weekly_last_reset,
reset_info.secondary_next_reset,
));
lines.push(build_weekly_reset_line(
snapshot.secondary_window_minutes,
reset_info.weekly_last_reset,
reset_info.secondary_next_reset,
));
lines.push("".into());
@@ -240,7 +240,7 @@ fn build_bar_line(label: &str, percent: f64, suffix: &str, style: Style) -> Line
fn build_hourly_window_line(
metrics: &RateLimitMetrics,
last_reset: Option<DateTime<Utc>>,
next_reset: Option<DateTime<Utc>>,
) -> Line<'static> {
let prefix = field_prefix("Window");
if metrics.primary_window_minutes == 0 {
@@ -253,8 +253,8 @@ fn build_hourly_window_line(
]);
}
if let Some(last) = last_reset {
if let Some(timing) = compute_window_timing(metrics.primary_window_minutes, last) {
if let Some(next) = next_reset {
if let Some(timing) = compute_window_timing(metrics.primary_window_minutes, next) {
let window_secs = timing.window.as_secs_f64();
if window_secs > 0.0 {
let elapsed = timing.elapsed();
@@ -278,25 +278,25 @@ fn build_hourly_window_line(
}
}
Line::from(vec![
Span::raw(prefix),
Span::styled(
format!(
"(unknown / {})",
format_minutes_round_units(metrics.primary_window_minutes)
),
Style::default().fg(colors::dim()),
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::raw(prefix));
spans.push(Span::styled(
format!(
"(unknown / {})",
format_minutes_round_units(metrics.primary_window_minutes)
),
])
Style::default().fg(colors::dim()),
));
Line::from(spans)
}
fn build_hourly_reset_line(
window_minutes: u64,
last_reset: Option<DateTime<Utc>>,
next_reset: Option<DateTime<Utc>>,
) -> Line<'static> {
if let (Some(last), true) = (last_reset, window_minutes > 0) {
if let (Some(next), true) = (next_reset, window_minutes > 0) {
let prefix = field_prefix("Resets");
if let Some(timing) = compute_window_timing(window_minutes, last) {
if let Some(timing) = compute_window_timing(window_minutes, next) {
let remaining = format_duration(timing.remaining);
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::raw(prefix.clone()));
@@ -320,7 +320,7 @@ fn build_hourly_reset_line(
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::raw(field_prefix("Resets")));
spans.push(Span::styled(
"shown once next window detected".to_string(),
"awaiting reset timing…".to_string(),
Style::default().fg(colors::dim()),
));
Line::from(spans)
@@ -328,7 +328,7 @@ fn build_hourly_reset_line(
fn build_weekly_window_line(
weekly_minutes: u64,
last_reset: Option<DateTime<Utc>>,
next_reset: Option<DateTime<Utc>>,
) -> Line<'static> {
let prefix = field_prefix("Window");
if weekly_minutes == 0 {
@@ -341,8 +341,8 @@ fn build_weekly_window_line(
]);
}
if let Some(last) = last_reset {
if let Some(timing) = compute_window_timing(weekly_minutes, last) {
if let Some(next) = next_reset {
if let Some(timing) = compute_window_timing(weekly_minutes, next) {
let window_secs = timing.window.as_secs_f64();
if window_secs > 0.0 {
let elapsed = timing.elapsed();
@@ -379,11 +379,11 @@ fn build_weekly_window_line(
fn build_weekly_reset_line(
window_minutes: u64,
last_reset: Option<DateTime<Utc>>,
next_reset: Option<DateTime<Utc>>,
) -> Line<'static> {
if let (Some(last), true) = (last_reset, window_minutes > 0) {
if let (Some(next), true) = (next_reset, window_minutes > 0) {
let prefix = field_prefix("Resets");
if let Some(timing) = compute_window_timing(window_minutes, last) {
if let Some(timing) = compute_window_timing(window_minutes, next) {
let remaining = format_duration(timing.remaining);
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::raw(prefix.clone()));
@@ -406,7 +406,7 @@ fn build_weekly_reset_line(
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::raw(field_prefix("Resets")));
spans.push(Span::styled(
"shown once next window detected".to_string(),
"awaiting reset timing…".to_string(),
Style::default().fg(colors::dim()),
));
Line::from(spans)
@@ -609,7 +609,7 @@ impl WindowTiming {
fn compute_window_timing(
window_minutes: u64,
last_reset: DateTime<Utc>,
next_reset: DateTime<Utc>,
) -> Option<WindowTiming> {
let window_seconds = (window_minutes as i64).checked_mul(60)?;
if window_seconds <= 0 {
@@ -617,17 +617,16 @@ fn compute_window_timing(
}
let now = Utc::now();
let elapsed_secs = now.signed_duration_since(last_reset).num_seconds();
let elapsed_within_window = if elapsed_secs <= 0 {
0
} else {
elapsed_secs.rem_euclid(window_seconds)
};
let remaining_secs = (window_seconds - elapsed_within_window).max(0);
let mut remaining_secs = next_reset.signed_duration_since(now).num_seconds();
if remaining_secs < 0 {
remaining_secs = 0;
}
if remaining_secs > window_seconds {
remaining_secs = window_seconds;
}
let window = Duration::from_secs(window_seconds as u64);
let remaining = Duration::from_secs(remaining_secs as u64);
let next_reset = now + chrono::Duration::seconds(remaining_secs);
Some(WindowTiming {
remaining,
window,
@@ -1020,6 +1019,8 @@ mod tests {
primary_to_secondary_ratio_percent: 40.0,
primary_window_minutes: 300,
secondary_window_minutes: 10_080,
primary_reset_after_seconds: None,
secondary_reset_after_seconds: None,
}
}
+1 -1
View File
@@ -133,7 +133,7 @@ How long Codex will wait for activity on a streaming response before treating th
## model_provider
Identifies which provider to use from the `model_providers` map. Defaults to `"openai"`. You can override the `base_url` for the built-in `openai` provider via the `OPENAI_BASE_URL` environment variable.
Identifies which provider to use from the `model_providers` map. Defaults to `"openai"`. You can override the `base_url` for the built-in `openai` provider via the `OPENAI_BASE_URL` environment variable and force the wire protocol (`"responses"` or `"chat"`) with `OPENAI_WIRE_API`.
Note that if you override `model_provider`, then you likely want to override
`model`, as well. For example, if you are running ollama with Mistral locally,