From b0b73176884575229f9bf157bed8ee98746af0bd Mon Sep 17 00:00:00 2001 From: Hunter B Date: Fri, 12 Jun 2026 02:07:39 -0700 Subject: [PATCH] fix(tui): normalize Codex reasoning effort labels --- CHANGELOG.md | 4 +- crates/tui/CHANGELOG.md | 4 +- .../tui/src/commands/groups/config/config.rs | 45 +++++++++-- crates/tui/src/tui/app.rs | 45 ++++++++++- crates/tui/src/tui/model_picker.rs | 81 ++++++++++++++++++- crates/tui/src/tui/views/mod.rs | 36 +++++++-- 6 files changed, 200 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a3b9574..ea4dd54f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,7 +73,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 reconcile stale running fanout counts from manager snapshots. - **OpenAI Codex reasoning tiers.** Switching from DeepSeek to `openai-codex` now normalizes stale reasoning state into Responses-compatible - `low`/`medium`/`high`/`xhigh` tiers and reports Codex as a Responses payload + `low`/`medium`/`high`/`xhigh` tiers. Startup, `/config`, and the model + picker now display Codex labels instead of leaking DeepSeek + `off`/`max` names, while Codex still reports as a Responses payload provider. - **OpenAI Codex context metadata (#3070).** The `gpt-5.5` default and CodeWhale aliases now use OpenAI's documented 1,050,000-token context window diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 91ad1a48..97c51be2 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -73,7 +73,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 reconcile stale running fanout counts from manager snapshots. - **OpenAI Codex reasoning tiers.** Switching from DeepSeek to `openai-codex` now normalizes stale reasoning state into Responses-compatible - `low`/`medium`/`high`/`xhigh` tiers and reports Codex as a Responses payload + `low`/`medium`/`high`/`xhigh` tiers. Startup, `/config`, and the model + picker now display Codex labels instead of leaking DeepSeek + `off`/`max` names, while Codex still reports as a Responses payload provider. - **OpenAI Codex context metadata (#3070).** The `gpt-5.5` default and CodeWhale aliases now use OpenAI's documented 1,050,000-token context window diff --git a/crates/tui/src/commands/groups/config/config.rs b/crates/tui/src/commands/groups/config/config.rs index 97e52f53..972e1a09 100644 --- a/crates/tui/src/commands/groups/config/config.rs +++ b/crates/tui/src/commands/groups/config/config.rs @@ -795,7 +795,9 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> settings .reasoning_effort .as_deref() - .map_or_else(ReasoningEffort::default, ReasoningEffort::from_setting) + .map_or_else(ReasoningEffort::default, |value| { + ReasoningEffort::from_setting_for_provider(value, app.api_provider) + }) }; app.last_effective_reasoning_effort = None; app.update_model_compaction_budget(); @@ -824,10 +826,14 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> .background_color .clone() .unwrap_or_else(|| "default".to_string()), - "reasoning_effort" | "effort" => settings - .reasoning_effort - .clone() - .unwrap_or_else(|| "config/default".to_string()), + "reasoning_effort" | "effort" => settings.reasoning_effort.as_deref().map_or_else( + || "config/default".to_string(), + |value| { + ReasoningEffort::from_setting_for_provider(value, app.api_provider) + .as_setting_for_provider(app.api_provider) + .to_string() + }, + ), "composer_vim_mode" | "vim_mode" | "vim" => settings.composer_vim_mode.clone(), _ => value.to_string(), }; @@ -1359,6 +1365,35 @@ mod tests { assert!(app.last_effective_reasoning_effort.is_none()); } + #[test] + fn config_reasoning_effort_uses_codex_provider_labels() { + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-codex-effort-config-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + let mut app = create_test_app(); + app.api_provider = ApiProvider::OpenaiCodex; + app.reasoning_effort = ReasoningEffort::High; + + let result = set_config_value(&mut app, "reasoning_effort", "off", false); + + assert_eq!(app.reasoning_effort, ReasoningEffort::Low); + assert_eq!( + result.message.as_deref(), + Some("reasoning_effort = low (session only, add --save to persist)") + ); + + let result = set_config_value(&mut app, "reasoning_effort", "xhigh", false); + + assert_eq!(app.reasoning_effort, ReasoningEffort::Max); + assert_eq!( + result.message.as_deref(), + Some("reasoning_effort = xhigh (session only, add --save to persist)") + ); + } + #[test] fn config_model_accepts_future_deepseek_model_id() { let mut app = create_test_app(); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 970e2220..74e3c19b 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -210,6 +210,11 @@ impl ReasoningEffort { } } + #[must_use] + pub fn from_setting_for_provider(value: &str, provider: ApiProvider) -> Self { + Self::from_setting(value).normalize_for_provider(provider) + } + /// Canonical lowercase label used for config storage and UI hints. #[must_use] pub fn as_setting(self) -> &'static str { @@ -2096,7 +2101,7 @@ impl App { ReasoningEffort::Auto } else { configured_reasoning_effort.map_or_else(ReasoningEffort::default, |s| { - ReasoningEffort::from_setting(s) + ReasoningEffort::from_setting_for_provider(s, provider) }) }; @@ -5528,6 +5533,44 @@ mod tests { assert_eq!(app.reasoning_effort_display_label(), "low"); } + #[test] + fn app_new_normalizes_saved_codex_reasoning_effort() { + let _lock = lock_test_env(); + let tmp = tempfile::TempDir::new().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path); + let _token = EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-codex-startup-token"); + let config = Config { + provider: Some("openai-codex".to_string()), + providers: Some(ProvidersConfig { + openai_codex: ProviderConfig { + model: Some(crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string()), + ..ProviderConfig::default() + }, + ..ProvidersConfig::default() + }), + ..Config::default() + }; + + for (raw, expected, display) in [ + ("off", ReasoningEffort::Low, "low"), + ("auto", ReasoningEffort::Medium, "medium"), + ("max", ReasoningEffort::Max, "xhigh"), + ] { + std::fs::write( + tmp.path().join("settings.toml"), + format!("reasoning_effort = \"{raw}\"\n"), + ) + .expect("settings"); + + let app = App::new(test_options(false), &config); + + assert_eq!(app.api_provider, ApiProvider::OpenaiCodex); + assert_eq!(app.reasoning_effort, expected, "raw setting {raw}"); + assert_eq!(app.reasoning_effort_display_label(), display); + } + } + #[test] fn settings_default_provider_auth_check_uses_provider_scoped_key() { let _lock = lock_test_env(); diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index 40111cf2..3910643d 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -155,11 +155,23 @@ impl ModelPickerView { ) } + fn select_effort_for_current_model(&mut self, effort: ReasoningEffort) { + let provider = self.resolved_provider().unwrap_or(self.initial_provider); + let model_is_auto = self.resolved_model().trim().eq_ignore_ascii_case("auto"); + let normalized = normalize_picker_effort(effort, provider, model_is_auto); + self.selected_effort_idx = picker_efforts_for_provider(provider, model_is_auto) + .iter() + .position(|candidate| *candidate == normalized) + .unwrap_or_else(|| default_picker_effort_idx(provider, model_is_auto)); + } + fn move_up(&mut self) -> bool { match self.focus { Pane::Model => { if self.selected_model_idx > 0 { + let effort = self.resolved_effort(); self.selected_model_idx -= 1; + self.select_effort_for_current_model(effort); return true; } } @@ -178,7 +190,9 @@ impl ModelPickerView { Pane::Model => { let max = self.model_row_count().saturating_sub(1); if self.selected_model_idx < max { + let effort = self.resolved_effort(); self.selected_model_idx += 1; + self.select_effort_for_current_model(effort); return true; } } @@ -503,7 +517,11 @@ impl ModalView for ModelPickerView { } KeyCode::Home => { match self.focus { - Pane::Model => self.selected_model_idx = 0, + Pane::Model => { + let effort = self.resolved_effort(); + self.selected_model_idx = 0; + self.select_effort_for_current_model(effort); + } Pane::Effort => self.selected_effort_idx = 0, } ViewAction::None @@ -511,7 +529,9 @@ impl ModalView for ModelPickerView { KeyCode::End => { match self.focus { Pane::Model => { + let effort = self.resolved_effort(); self.selected_model_idx = self.model_row_count().saturating_sub(1); + self.select_effort_for_current_model(effort); } Pane::Effort => { self.selected_effort_idx = self.current_efforts().len().saturating_sub(1); @@ -835,6 +855,65 @@ mod tests { assert_eq!(labels, vec!["low", "medium", "high", "xhigh"]); } + #[test] + fn picker_remaps_deepseek_off_when_highlighting_saved_codex_model() { + let (mut app, _lock) = create_test_app(); + app.api_provider = crate::config::ApiProvider::Deepseek; + app.model = "deepseek-v4-pro".to_string(); + app.auto_model = false; + app.reasoning_effort = ReasoningEffort::Off; + app.provider_models + .insert("openai-codex".to_string(), "gpt-5.5".to_string()); + + let mut view = ModelPickerView::new(&app); + assert_eq!(view.resolved_effort(), ReasoningEffort::Off); + + while view.resolved_provider() != Some(crate::config::ApiProvider::OpenaiCodex) { + assert!( + view.move_down(), + "saved Codex model row should be reachable" + ); + } + + assert_eq!(view.resolved_model(), "gpt-5.5"); + assert_eq!(view.resolved_effort(), ReasoningEffort::Low); + assert_eq!(view.selected_effort_idx, 0); + let labels = view + .current_efforts() + .iter() + .map(|effort| { + effort.display_label_for_provider(crate::config::ApiProvider::OpenaiCodex) + }) + .collect::>(); + assert_eq!(labels, vec!["low", "medium", "high", "xhigh"]); + } + + #[test] + fn picker_remaps_deepseek_max_to_codex_xhigh_when_model_provider_changes() { + let (mut app, _lock) = create_test_app(); + app.api_provider = crate::config::ApiProvider::Deepseek; + app.model = "deepseek-v4-pro".to_string(); + app.auto_model = false; + app.reasoning_effort = ReasoningEffort::Max; + app.provider_models + .insert("openai-codex".to_string(), "gpt-5.5".to_string()); + + let mut view = ModelPickerView::new(&app); + while view.resolved_provider() != Some(crate::config::ApiProvider::OpenaiCodex) { + assert!( + view.move_down(), + "saved Codex model row should be reachable" + ); + } + + assert_eq!(view.resolved_effort(), ReasoningEffort::Max); + assert_eq!( + view.resolved_effort() + .display_label_for_provider(crate::config::ApiProvider::OpenaiCodex), + "xhigh" + ); + } + #[test] fn picker_preserves_unknown_model_via_custom_row() { let (mut app, _lock) = create_test_app(); diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index a1d586b0..19df67ee 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -498,11 +498,17 @@ impl ConfigView { ConfigRow { section: ConfigSection::Model, key: "reasoning_effort".to_string(), - value: settings - .reasoning_effort - .as_deref() - .unwrap_or("(config/default)") - .to_string(), + value: settings.reasoning_effort.as_deref().map_or_else( + || "(config/default)".to_string(), + |value| { + crate::tui::app::ReasoningEffort::from_setting_for_provider( + value, + app.api_provider, + ) + .as_setting_for_provider(app.api_provider) + .to_string() + }, + ), editable: true, scope: ConfigScope::Saved, }, @@ -1146,7 +1152,9 @@ fn config_hint_for_key(key: &str) -> &'static str { "max_history" => "integer (0 allowed)", "auto_compact_threshold_percent" => "10..=100", "default_model" => "deepseek-v4-pro | deepseek-v4-flash | deepseek-* | none/default", - "reasoning_effort" => "auto | off | low | medium | high | max | xhigh | default", + "reasoning_effort" => { + "DeepSeek: auto/off/high/max; Codex: low/medium/high/xhigh; default clears saved value" + } "mcp_config_path" => "path to mcp.json", _ => "", } @@ -2455,6 +2463,22 @@ base_url = "https://api.xiaomimimo.com/v1" } } + #[test] + fn config_view_displays_saved_codex_reasoning_effort_label() { + let _guard = ConfigSettingsEnvGuard::new("reasoning_effort = \"max\"\n"); + let mut app = create_test_app(); + app.api_provider = crate::config::ApiProvider::OpenaiCodex; + + let view = ConfigView::new_for_app(&app); + let row = view + .rows + .iter() + .find(|row| row.key == "reasoning_effort") + .expect("reasoning_effort row"); + + assert_eq!(row.value, "xhigh"); + } + #[test] fn config_view_filter_matches_group_and_rows() { let mut view = create_config_view(Locale::En);