Compare commits

...

29 Commits

Author SHA1 Message Date
Ahmed Ibrahim
449df00c2b tui 2025-12-15 20:48:25 -08:00
Ahmed Ibrahim
ce023c0341 tui 2025-12-15 20:38:19 -08:00
Ahmed Ibrahim
2b9b689f9e tui 2025-12-15 20:37:54 -08:00
Ahmed Ibrahim
dca60646d8 tui 2025-12-15 20:37:37 -08:00
Ahmed Ibrahim
cc8d60c32f tui 2025-12-15 20:37:01 -08:00
Ahmed Ibrahim
baf075e49f tui 2025-12-15 20:36:23 -08:00
Ahmed Ibrahim
6bf3d3eccf config 2025-12-15 12:11:02 -08:00
Ahmed Ibrahim
405731caa8 tests 2025-12-15 11:47:06 -08:00
Ahmed Ibrahim
72502f2709 submit_id 2025-12-15 11:38:47 -08:00
Ahmed Ibrahim
c7a46d3f0d update 2025-12-15 11:19:55 -08:00
Ahmed Ibrahim
f43d6b2d11 update 2025-12-15 11:18:16 -08:00
Ahmed Ibrahim
afbd362a3e update 2025-12-15 11:12:50 -08:00
Ahmed Ibrahim
597ce69188 update 2025-12-15 11:12:25 -08:00
Ahmed Ibrahim
b4d69e985f update 2025-12-15 11:08:24 -08:00
Ahmed Ibrahim
0f20ba1dad update 2025-12-15 11:03:06 -08:00
Ahmed Ibrahim
483532f28d nudge-update 2025-12-15 11:02:24 -08:00
Ahmed Ibrahim
a6c980d4e8 nudge-update 2025-12-15 10:48:51 -08:00
Ahmed Ibrahim
48e9eeaa7a nudge-update 2025-12-15 10:47:35 -08:00
Ahmed Ibrahim
8255a75000 Merge branch 'main' into nudge-update 2025-12-15 10:46:14 -08:00
Ahmed Ibrahim
61b0ad6c45 nudge-update 2025-12-15 10:39:29 -08:00
Ahmed Ibrahim
6b2d26fbbd unrelated 2025-12-15 10:34:37 -08:00
Ahmed Ibrahim
22b02ea9f8 unify 2025-12-13 21:24:35 -08:00
Ahmed Ibrahim
210ab25aee unrelated 2025-12-13 21:07:56 -08:00
Ahmed Ibrahim
63e5498e24 unrelated 2025-12-13 21:07:31 -08:00
Ahmed Ibrahim
98e7b58beb unrelated 2025-12-13 21:05:51 -08:00
Ahmed Ibrahim
9ba67c9a29 unrelated 2025-12-13 21:05:13 -08:00
Ahmed Ibrahim
f4028287e3 unrelated 2025-12-13 21:04:41 -08:00
Ahmed Ibrahim
a4132d7523 error 2025-12-13 21:02:56 -08:00
Ahmed Ibrahim
309c2f5f94 error 2025-12-13 18:38:48 -08:00
11 changed files with 536 additions and 109 deletions

View File

@@ -2476,6 +2476,7 @@ async fn try_run_turn(
let mut needs_follow_up = false;
let mut last_agent_message: Option<String> = None;
let mut active_item: Option<TurnItem> = None;
let mut should_emit_turn_diff = false;
let receiving_span = info_span!("receiving_stream");
let outcome: CodexResult<TurnRunResult> = loop {
let handle_responses = info_span!(
@@ -2551,14 +2552,7 @@ async fn try_run_turn(
} => {
sess.update_token_usage_info(&turn_context, token_usage.as_ref())
.await;
let unified_diff = {
let mut tracker = turn_diff_tracker.lock().await;
tracker.get_unified_diff()
};
if let Ok(Some(unified_diff)) = unified_diff {
let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff });
sess.send_event(&turn_context, msg).await;
}
should_emit_turn_diff = true;
break Ok(TurnRunResult {
needs_follow_up,
@@ -2632,7 +2626,18 @@ async fn try_run_turn(
}
};
drain_in_flight(&mut in_flight, sess, turn_context).await?;
drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?;
if should_emit_turn_diff {
let unified_diff = {
let mut tracker = turn_diff_tracker.lock().await;
tracker.get_unified_diff()
};
if let Ok(Some(unified_diff)) = unified_diff {
let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff });
sess.clone().send_event(&turn_context, msg).await;
}
}
outcome
}

View File

@@ -80,6 +80,7 @@ pub mod spawn;
pub mod terminal;
mod tools;
pub mod turn_diff_tracker;
pub mod version;
pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
pub use rollout::INTERACTIVE_SESSION_SOURCES;
pub use rollout::RolloutRecorder;
@@ -94,6 +95,7 @@ pub use rollout::list::read_head_for_summary;
mod function_tool;
mod state;
mod tasks;
pub mod update_action;
mod user_notification;
mod user_shell_command;
pub mod util;

View File

@@ -1,4 +1,6 @@
/// Update action the CLI should perform after the TUI exits.
#[cfg(any(not(debug_assertions), test))]
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateAction {
/// Update via `npm install -g @openai/codex@latest`.
@@ -28,7 +30,7 @@ impl UpdateAction {
}
#[cfg(not(debug_assertions))]
pub(crate) fn get_update_action() -> Option<UpdateAction> {
pub fn get_update_action() -> Option<UpdateAction> {
let exe = std::env::current_exe().unwrap_or_default();
let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some();
@@ -41,10 +43,27 @@ pub(crate) fn get_update_action() -> Option<UpdateAction> {
)
}
#[cfg(debug_assertions)]
pub fn get_update_action() -> Option<UpdateAction> {
None
}
/// Returns the standard update-available message for clients to display.
pub fn update_available_nudge() -> String {
match get_update_action() {
Some(action) => {
let command = action.command_str();
format!("Update available. Run `{command}` to update.")
}
None => "Update available. See https://github.com/openai/codex for installation options."
.to_string(),
}
}
#[cfg(any(not(debug_assertions), test))]
fn detect_update_action(
is_macos: bool,
current_exe: &std::path::Path,
current_exe: &Path,
managed_by_npm: bool,
managed_by_bun: bool,
) -> Option<UpdateAction> {
@@ -68,33 +87,23 @@ mod tests {
#[test]
fn detects_update_action_without_env_mutation() {
assert_eq!(
detect_update_action(false, std::path::Path::new("/any/path"), false, false),
detect_update_action(false, Path::new("/any/path"), false, false),
None
);
assert_eq!(
detect_update_action(false, std::path::Path::new("/any/path"), true, false),
detect_update_action(false, Path::new("/any/path"), true, false),
Some(UpdateAction::NpmGlobalLatest)
);
assert_eq!(
detect_update_action(false, std::path::Path::new("/any/path"), false, true),
detect_update_action(false, Path::new("/any/path"), false, true),
Some(UpdateAction::BunGlobalLatest)
);
assert_eq!(
detect_update_action(
true,
std::path::Path::new("/opt/homebrew/bin/codex"),
false,
false
),
detect_update_action(true, Path::new("/opt/homebrew/bin/codex"), false, false),
Some(UpdateAction::BrewUpgrade)
);
assert_eq!(
detect_update_action(
true,
std::path::Path::new("/usr/local/bin/codex"),
false,
false
),
detect_update_action(true, Path::new("/usr/local/bin/codex"), false, false),
Some(UpdateAction::BrewUpgrade)
);
}

View File

@@ -0,0 +1,247 @@
use std::path::Path;
use chrono::DateTime;
use chrono::Utc;
use serde::Deserialize;
use serde::Serialize;
pub const VERSION_FILENAME: &str = "version.json";
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct VersionInfo {
pub latest_version: String,
// ISO-8601 timestamp (RFC3339)
pub last_checked_at: DateTime<Utc>,
#[serde(default)]
pub dismissed_version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Version {
major: u64,
minor: u64,
patch: u64,
pre: Option<Vec<PrereleaseIdent>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PrereleaseIdent {
Numeric(u64),
Alpha(String),
}
impl Version {
pub fn parse(input: &str) -> Option<Self> {
let mut input = input.trim();
if let Some(stripped) = input.strip_prefix("rust-v") {
input = stripped;
}
if let Some(stripped) = input.strip_prefix('v') {
input = stripped;
}
let input = input.split('+').next().unwrap_or(input);
let mut parts = input.splitn(2, '-');
let core = parts.next()?;
let pre = parts.next();
let mut nums = core.split('.');
let major = nums.next()?.parse::<u64>().ok()?;
let minor = nums.next()?.parse::<u64>().ok()?;
let patch = nums.next()?.parse::<u64>().ok()?;
if nums.next().is_some() {
return None;
}
let pre = match pre {
None => None,
Some("") => None,
Some(value) => {
let mut idents = Vec::new();
for ident in value.split('.') {
if ident.is_empty() {
return None;
}
let parsed = if ident.chars().all(|c| c.is_ascii_digit()) {
ident.parse::<u64>().ok().map(PrereleaseIdent::Numeric)
} else {
Some(PrereleaseIdent::Alpha(ident.to_string()))
};
idents.push(parsed?);
}
Some(idents)
}
};
Some(Self {
major,
minor,
patch,
pre,
})
}
}
impl Ord for Version {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
match self.major.cmp(&other.major) {
std::cmp::Ordering::Equal => {}
ordering => return ordering,
}
match self.minor.cmp(&other.minor) {
std::cmp::Ordering::Equal => {}
ordering => return ordering,
}
match self.patch.cmp(&other.patch) {
std::cmp::Ordering::Equal => {}
ordering => return ordering,
}
match (&self.pre, &other.pre) {
(None, None) => std::cmp::Ordering::Equal,
(None, Some(_)) => std::cmp::Ordering::Greater,
(Some(_), None) => std::cmp::Ordering::Less,
(Some(left), Some(right)) => compare_prerelease_idents(left, right),
}
}
}
impl PartialOrd for Version {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
pub fn is_newer(latest: &str, current: &str) -> Option<bool> {
let latest = Version::parse(latest)?;
if latest.pre.is_some() {
return Some(false);
}
let current = Version::parse(current)?;
let current = Version {
pre: None,
..current
};
Some(latest > current)
}
pub fn is_up_to_date(latest: &str, current: &str) -> Option<bool> {
let latest = Version::parse(latest)?;
if latest.pre.is_some() {
return Some(true);
}
let current = Version::parse(current)?;
let current = Version {
pre: None,
..current
};
Some(current >= latest)
}
pub fn read_version_info(version_file: &Path) -> anyhow::Result<VersionInfo> {
let contents = std::fs::read_to_string(version_file)?;
Ok(serde_json::from_str(&contents)?)
}
pub fn read_latest_version(version_file: &Path) -> Option<String> {
read_version_info(version_file)
.ok()
.map(|info| info.latest_version)
}
pub fn extract_version_from_cask(cask_contents: &str) -> anyhow::Result<String> {
cask_contents
.lines()
.find_map(|line| {
let line = line.trim();
line.strip_prefix("version \"")
.and_then(|rest| rest.strip_suffix('"'))
.map(ToString::to_string)
})
.ok_or_else(|| anyhow::anyhow!("Failed to find version in Homebrew cask file"))
}
pub fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::Result<String> {
latest_tag_name
.strip_prefix("rust-v")
.map(str::to_owned)
.ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'"))
}
fn compare_prerelease_idents(
left: &[PrereleaseIdent],
right: &[PrereleaseIdent],
) -> std::cmp::Ordering {
for (l, r) in left.iter().zip(right.iter()) {
let ordering = match (l, r) {
(PrereleaseIdent::Numeric(a), PrereleaseIdent::Numeric(b)) => a.cmp(b),
(PrereleaseIdent::Alpha(a), PrereleaseIdent::Alpha(b)) => a.cmp(b),
(PrereleaseIdent::Numeric(_), PrereleaseIdent::Alpha(_)) => std::cmp::Ordering::Less,
(PrereleaseIdent::Alpha(_), PrereleaseIdent::Numeric(_)) => std::cmp::Ordering::Greater,
};
if ordering != std::cmp::Ordering::Equal {
return ordering;
}
}
left.len().cmp(&right.len())
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn prerelease_current_is_ignored() {
assert_eq!(is_newer("1.2.3", "1.2.3-alpha.1"), Some(false));
assert_eq!(is_up_to_date("1.2.3", "1.2.3-alpha.1"), Some(true));
}
#[test]
fn prerelease_latest_is_ignored() {
assert_eq!(is_newer("1.2.4-alpha.1", "1.2.3"), Some(false));
assert_eq!(is_up_to_date("1.2.4-alpha.1", "1.2.3"), Some(true));
}
#[test]
fn prerelease_latest_is_not_considered_newer() {
assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), Some(false));
assert_eq!(is_newer("1.0.0-rc.1", "1.0.0"), Some(false));
}
#[test]
fn plain_semver_comparisons_work() {
assert_eq!(is_newer("0.11.1", "0.11.0"), Some(true));
assert_eq!(is_newer("0.11.0", "0.11.1"), Some(false));
assert_eq!(is_newer("1.0.0", "0.9.9"), Some(true));
assert_eq!(is_newer("0.9.9", "1.0.0"), Some(false));
}
#[test]
fn whitespace_is_ignored() {
assert_eq!(Version::parse(" 1.2.3 \n").is_some(), true);
assert_eq!(is_newer(" 1.2.3 ", "1.2.2"), Some(true));
}
#[test]
fn parses_version_from_cask_contents() {
let cask = r#"
cask "codex" do
version "0.55.0"
end
"#;
assert_eq!(
extract_version_from_cask(cask).expect("failed to parse version"),
"0.55.0"
);
}
#[test]
fn extracts_version_from_latest_tag() {
assert_eq!(
extract_version_from_latest_tag("rust-v1.5.0").expect("failed to parse version"),
"1.5.0"
);
}
#[test]
fn latest_tag_without_prefix_is_invalid() {
assert!(extract_version_from_latest_tag("v1.5.0").is_err());
}
}

View File

@@ -41,6 +41,7 @@ use core_test_support::skip_if_no_network;
use core_test_support::skip_if_sandbox;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::Duration;
@@ -298,6 +299,108 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_models_preserve_builtin_presets() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
let server = MockServer::start().await;
let remote_model = test_remote_model("remote-alpha", ModelVisibility::List, 0);
let models_mock = mount_models_once(
&server,
ModelsResponse {
models: vec![remote_model.clone()],
etag: String::new(),
},
)
.await;
let codex_home = TempDir::new()?;
let mut config = load_default_config_for_test(&codex_home);
config.features.enable(Feature::RemoteModels);
let auth = CodexAuth::from_api_key("dummy");
let provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let manager = ModelsManager::with_provider(
codex_core::auth::AuthManager::from_auth_for_testing(auth),
provider,
);
manager
.refresh_available_models(&config)
.await
.expect("refresh succeeds");
let available = manager.list_models(&config).await;
let remote = available
.iter()
.find(|model| model.model == "remote-alpha")
.expect("remote model should be listed");
let mut expected_remote: ModelPreset = remote_model.into();
expected_remote.is_default = true;
assert_eq!(*remote, expected_remote);
assert!(
available
.iter()
.any(|model| model.model == "gpt-5.1-codex-max"),
"builtin presets should remain available after refresh"
);
assert_eq!(
models_mock.requests().len(),
1,
"expected a single /models request"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_models_hide_picker_only_models() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
let server = MockServer::start().await;
let remote_model = test_remote_model("codex-auto-balanced", ModelVisibility::Hide, 0);
mount_models_once(
&server,
ModelsResponse {
models: vec![remote_model],
etag: String::new(),
},
)
.await;
let codex_home = TempDir::new()?;
let mut config = load_default_config_for_test(&codex_home);
config.features.enable(Feature::RemoteModels);
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let manager = ModelsManager::with_provider(
codex_core::auth::AuthManager::from_auth_for_testing(auth),
provider,
);
let selected = manager.get_model(&None, &config).await;
assert_eq!(selected, "gpt-5.1-codex-max");
let available = manager.list_models(&config).await;
assert!(
available
.iter()
.all(|model| model.model != "codex-auto-balanced"),
"hidden models should not appear in the picker list"
);
Ok(())
}
async fn wait_for_model_available(
manager: &Arc<ModelsManager>,
slug: &str,
@@ -362,3 +465,32 @@ where
conversation_manager,
})
}
fn test_remote_model(slug: &str, visibility: ModelVisibility, priority: i32) -> ModelInfo {
ModelInfo {
slug: slug.to_string(),
display_name: format!("{slug} display"),
description: Some(format!("{slug} description")),
default_reasoning_level: ReasoningEffort::Medium,
supported_reasoning_levels: vec![ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description: ReasoningEffort::Medium.to_string(),
}],
shell_type: ConfigShellToolType::ShellCommand,
visibility,
minimal_client_version: ClientVersion(0, 1, 0),
supported_in_api: true,
priority,
upgrade: None,
base_instructions: None,
supports_reasoning_summaries: false,
support_verbosity: false,
default_verbosity: None,
apply_patch_tool_type: None,
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
context_window: None,
reasoning_summary_format: ReasoningSummaryFormat::None,
experimental_supported_tools: Vec::new(),
}
}

View File

@@ -118,6 +118,15 @@ use crate::text_formatting::truncate_text;
use crate::tui::FrameRequester;
mod interrupts;
use self::interrupts::InterruptManager;
#[cfg(test)]
use crate::version::CODEX_CLI_VERSION;
#[cfg(test)]
use codex_core::version::VERSION_FILENAME;
#[cfg(test)]
use codex_core::version::is_newer;
#[cfg(test)]
use codex_core::version::read_version_info;
mod agent;
use self::agent::spawn_agent;
use self::agent::spawn_agent_from_existing;
@@ -676,7 +685,45 @@ impl ChatWidget {
self.model_family.clone()
}
fn maybe_append_update_nudge(&self, message: String) -> String {
if !self.should_show_update_nudge() {
return message;
}
let nudge = crate::update_action::update_available_nudge();
if message.is_empty() {
nudge
} else {
format!("{message}\n{nudge}")
}
}
#[cfg(not(debug_assertions))]
fn should_show_update_nudge(&self) -> bool {
if env!("CARGO_PKG_VERSION") == "0.0.0" {
return false;
}
crate::updates::get_upgrade_version(&self.config).is_some()
}
#[cfg(test)]
fn should_show_update_nudge(&self) -> bool {
if !self.config.check_for_update_on_startup {
return false;
}
let version_file = self.config.codex_home.join(VERSION_FILENAME);
read_version_info(&version_file)
.ok()
.and_then(|info| is_newer(&info.latest_version, CODEX_CLI_VERSION))
.unwrap_or(false)
}
#[cfg(all(debug_assertions, not(test)))]
fn should_show_update_nudge(&self) -> bool {
false
}
fn on_error(&mut self, message: String) {
let message = self.maybe_append_update_nudge(message);
self.finalize_turn();
self.add_to_history(history_cell::new_error_event(message));
self.request_redraw();
@@ -955,6 +1002,7 @@ impl ChatWidget {
}
fn on_stream_error(&mut self, message: String) {
let message = self.maybe_append_update_nudge(message);
if self.retry_status_header.is_none() {
self.retry_status_header = Some(self.current_status_header.clone());
}

View File

@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: last
---
■ Something failed.
Update available. See https://github.com/openai/codex for installation options.

View File

@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: status.header()
---
Reconnecting... 2/5
Update available. See https://github.com/openai/codex for installation options.

View File

@@ -4,6 +4,7 @@ use crate::app_event_sender::AppEventSender;
use crate::test_backend::VT100Backend;
use crate::tui::FrameRequester;
use assert_matches::assert_matches;
use chrono::Utc;
use codex_common::approval_presets::builtin_approval_presets;
use codex_core::AuthManager;
use codex_core::CodexAuth;
@@ -18,6 +19,7 @@ use codex_core::protocol::AgentReasoningEvent;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::CreditsSnapshot;
use codex_core::protocol::ErrorEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
@@ -49,6 +51,8 @@ use codex_core::protocol::UndoCompletedEvent;
use codex_core::protocol::UndoStartedEvent;
use codex_core::protocol::ViewImageToolCallEvent;
use codex_core::protocol::WarningEvent;
use codex_core::version::VERSION_FILENAME;
use codex_core::version::VersionInfo;
use codex_protocol::ConversationId;
use codex_protocol::account::PlanType;
use codex_protocol::openai_models::ModelPreset;
@@ -64,6 +68,7 @@ use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use serde_json;
use std::collections::HashSet;
use std::path::PathBuf;
use tempfile::NamedTempFile;
@@ -492,6 +497,24 @@ fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String {
s
}
fn set_update_available(config: &mut Config) -> tempfile::TempDir {
let codex_home = tempdir().expect("tempdir");
config.codex_home = codex_home.path().to_path_buf();
config.check_for_update_on_startup = true;
let info = VersionInfo {
latest_version: "9999.0.0".to_string(),
last_checked_at: Utc::now(),
dismissed_version: None,
};
let json_line = format!(
"{}\n",
serde_json::to_string(&info).expect("serialize version info")
);
std::fs::write(codex_home.path().join(VERSION_FILENAME), json_line)
.expect("write version info");
codex_home
}
fn make_token_info(total_tokens: i64, context_window: i64) -> TokenUsageInfo {
fn usage(total_tokens: i64) -> TokenUsage {
TokenUsage {
@@ -2924,6 +2947,7 @@ fn plan_update_renders_history_cell() {
#[test]
fn stream_error_updates_status_indicator() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None);
let _tempdir = set_update_available(&mut chat.config);
chat.bottom_pane.set_task_running(true);
let msg = "Reconnecting... 2/5";
chat.handle_codex_event(Event {
@@ -2943,7 +2967,10 @@ fn stream_error_updates_status_indicator() {
.bottom_pane
.status_widget()
.expect("status indicator should be visible");
assert_eq!(status.header(), msg);
let nudge = crate::update_action::update_available_nudge();
let expected = format!("{msg}\n{nudge}");
assert_eq!(status.header(), expected);
assert_snapshot!("stream_error_status_header", status.header());
}
#[test]
@@ -2965,6 +2992,23 @@ fn warning_event_adds_warning_history_cell() {
);
}
#[test]
fn error_event_renders_history_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None);
let _tempdir = set_update_available(&mut chat.config);
chat.handle_codex_event(Event {
id: "sub-1".into(),
msg: EventMsg::Error(ErrorEvent {
message: "Something failed.".to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
}),
});
let cells = drain_insert_history(&mut rx);
let last = lines_to_single_string(cells.last().expect("error history cell"));
assert_snapshot!("error_event_history", last);
}
#[test]
fn stream_recovery_restores_previous_status_header() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None);

View File

@@ -78,7 +78,7 @@ mod text_formatting;
mod tooltips;
mod tui;
mod ui_consts;
pub mod update_action;
pub use codex_core::update_action;
mod update_prompt;
mod updates;
mod version;

View File

@@ -2,13 +2,17 @@
use crate::update_action;
use crate::update_action::UpdateAction;
use chrono::DateTime;
use chrono::Duration;
use chrono::Utc;
use codex_core::config::Config;
use codex_core::default_client::create_client;
use codex_core::version::VERSION_FILENAME;
use codex_core::version::VersionInfo;
use codex_core::version::extract_version_from_cask;
use codex_core::version::extract_version_from_latest_tag;
use codex_core::version::is_newer;
use codex_core::version::read_version_info;
use serde::Deserialize;
use serde::Serialize;
use std::path::Path;
use std::path::PathBuf;
@@ -45,16 +49,6 @@ pub fn get_upgrade_version(config: &Config) -> Option<String> {
})
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct VersionInfo {
latest_version: String,
// ISO-8601 timestamp (RFC3339)
last_checked_at: DateTime<Utc>,
#[serde(default)]
dismissed_version: Option<String>,
}
const VERSION_FILENAME: &str = "version.json";
// We use the latest version from the cask if installation is via homebrew - homebrew does not immediately pick up the latest release and can lag behind.
const HOMEBREW_CASK_URL: &str =
"https://raw.githubusercontent.com/Homebrew/homebrew-cask/HEAD/Casks/c/codex.rb";
@@ -69,11 +63,6 @@ fn version_filepath(config: &Config) -> PathBuf {
config.codex_home.join(VERSION_FILENAME)
}
fn read_version_info(version_file: &Path) -> anyhow::Result<VersionInfo> {
let contents = std::fs::read_to_string(version_file)?;
Ok(serde_json::from_str(&contents)?)
}
async fn check_for_update(version_file: &Path) -> anyhow::Result<()> {
let latest_version = match update_action::get_update_action() {
Some(UpdateAction::BrewUpgrade) => {
@@ -116,32 +105,6 @@ async fn check_for_update(version_file: &Path) -> anyhow::Result<()> {
Ok(())
}
fn is_newer(latest: &str, current: &str) -> Option<bool> {
match (parse_version(latest), parse_version(current)) {
(Some(l), Some(c)) => Some(l > c),
_ => None,
}
}
fn extract_version_from_cask(cask_contents: &str) -> anyhow::Result<String> {
cask_contents
.lines()
.find_map(|line| {
let line = line.trim();
line.strip_prefix("version \"")
.and_then(|rest| rest.strip_suffix('"'))
.map(ToString::to_string)
})
.ok_or_else(|| anyhow::anyhow!("Failed to find version in Homebrew cask file"))
}
fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::Result<String> {
latest_tag_name
.strip_prefix("rust-v")
.map(str::to_owned)
.ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'"))
}
/// Returns the latest version to show in a popup, if it should be shown.
/// This respects the user's dismissal choice for the current latest version.
pub fn get_upgrade_version_for_popup(config: &Config) -> Option<String> {
@@ -177,48 +140,14 @@ pub async fn dismiss_version(config: &Config, version: &str) -> anyhow::Result<(
Ok(())
}
fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
let mut iter = v.trim().split('.');
let maj = iter.next()?.parse::<u64>().ok()?;
let min = iter.next()?.parse::<u64>().ok()?;
let pat = iter.next()?.parse::<u64>().ok()?;
Some((maj, min, pat))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_version_from_cask_contents() {
let cask = r#"
cask "codex" do
version "0.55.0"
end
"#;
assert_eq!(
extract_version_from_cask(cask).expect("failed to parse version"),
"0.55.0"
);
}
#[test]
fn extracts_version_from_latest_tag() {
assert_eq!(
extract_version_from_latest_tag("rust-v1.5.0").expect("failed to parse version"),
"1.5.0"
);
}
#[test]
fn latest_tag_without_prefix_is_invalid() {
assert!(extract_version_from_latest_tag("v1.5.0").is_err());
}
#[test]
fn prerelease_version_is_not_considered_newer() {
assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), None);
assert_eq!(is_newer("1.0.0-rc.1", "1.0.0"), None);
assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), Some(false));
assert_eq!(is_newer("1.0.0-rc.1", "1.0.0"), Some(false));
}
#[test]
@@ -231,7 +160,6 @@ mod tests {
#[test]
fn whitespace_is_ignored() {
assert_eq!(parse_version(" 1.2.3 \n"), Some((1, 2, 3)));
assert_eq!(is_newer(" 1.2.3 ", "1.2.2"), Some(true));
}
}