diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 3a150a3bea..2336a28191 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -5,6 +5,7 @@ use super::resize_reflow::trailing_run_start; use super::*; +use crate::chatwidget::service_tiers::service_tier_display_name; const SHUTDOWN_FIRST_EXIT_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 2); @@ -1265,7 +1266,8 @@ impl App { let mut message = match service_tier.as_ref() { Some(service_tier) => format!( "Service tier set to {}", - self.chat_widget.service_tier_display_name( + service_tier_display_name( + &self.chat_widget, self.chat_widget.current_model(), service_tier, ) diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 602c56cf09..ea0352f6ad 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -72,6 +72,8 @@ use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; +use codex_protocol::config_types::priority_service_tier; +use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; @@ -3703,7 +3705,7 @@ async fn clear_ui_header_shows_fast_status_for_fast_capable_models() { app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); app.chat_widget - .set_service_tier(Some(codex_protocol::config_types::ServiceTier::priority())); + .set_service_tier(Some(priority_service_tier())); set_chatgpt_auth(&mut app.chat_widget); set_fast_mode_test_catalog(&mut app.chat_widget); @@ -4323,14 +4325,11 @@ fn active_turn_steer_race_extracts_actual_turn_id_from_mismatch() { async fn fresh_session_config_uses_current_service_tier() { let mut app = make_test_app().await; app.chat_widget - .set_service_tier(Some(codex_protocol::config_types::ServiceTier::priority())); + .set_service_tier(Some(priority_service_tier())); let config = app.fresh_session_config(); - assert_eq!( - config.service_tier, - Some(codex_protocol::config_types::ServiceTier::priority()) - ); + assert_eq!(config.service_tier, Some(priority_service_tier())); } #[tokio::test] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index bcd78e1d86..105c4914a4 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -185,6 +185,10 @@ use super::slash_commands; use super::slash_commands::BuiltinCommandFlags; use super::slash_commands::ServiceTierCommand; use super::slash_commands::SlashCommandAction; +use super::slash_commands::command_as_builtin; +use super::slash_commands::command_available_during_task; +use super::slash_commands::command_name; +use super::slash_commands::command_supports_inline_args; use crate::bottom_pane::paste_burst::FlushResult; use crate::bottom_pane::prompt_args::parse_slash_name; use crate::key_hint::KeyBindingListExt; @@ -1741,19 +1745,19 @@ impl ChatComposer { popup.on_composer_text_change(first_line.to_string()); let selected_cmd = popup.selected_item(); if let Some(cmd) = selected_cmd { - if cmd.as_builtin() == Some(SlashCommand::Skills) { + if command_as_builtin(&cmd) == Some(SlashCommand::Skills) { self.stage_selected_slash_command_history(&cmd); self.textarea.set_text_clearing_elements(""); self.is_bash_mode = false; return (InputResult::Command(cmd), true); } - let selected_command_text = format!("/{}", cmd.command()); + let selected_command_text = format!("/{}", command_name(&cmd)); let starts_with_cmd = first_line.trim_start().starts_with(&selected_command_text); if !starts_with_cmd { self.textarea - .set_text_clearing_elements(&format!("/{} ", cmd.command())); + .set_text_clearing_elements(&format!("/{} ", command_name(&cmd))); if !self.textarea.text().is_empty() { self.textarea.set_cursor(self.textarea.text().len()); } @@ -1777,10 +1781,10 @@ impl ChatComposer { if let Some(cmd) = popup.selected_item() { let starts_with_cmd = first_line .trim_start() - .starts_with(&format!("/{}", cmd.command())); + .starts_with(&format!("/{}", command_name(&cmd))); if !starts_with_cmd { self.textarea - .set_text_clearing_elements(&format!("/{} ", cmd.command())); + .set_text_clearing_elements(&format!("/{} ", command_name(&cmd))); self.is_bash_mode = false; } if !self.textarea.text().is_empty() { @@ -2856,7 +2860,7 @@ impl ChatComposer { &self.service_tier_commands, )?; - if !cmd.supports_inline_args() { + if !command_supports_inline_args(&cmd) { return None; } if self.reject_slash_command_if_unavailable(&cmd) { @@ -2905,12 +2909,12 @@ impl ChatComposer { } fn reject_slash_command_if_unavailable(&self, cmd: &SlashCommandAction) -> bool { - if !self.is_task_running || cmd.available_during_task() { + if !self.is_task_running || command_available_during_task(cmd) { return false; } let message = format!( "'/{}' is disabled while a task is in progress.", - cmd.command() + command_name(cmd) ); self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_error_event(message), @@ -2950,7 +2954,7 @@ impl ChatComposer { /// Popup filtering text can be partial, so recording the selected command avoids recalling /// `/di` after the user actually accepted `/diff`. fn stage_selected_slash_command_history(&mut self, cmd: &SlashCommandAction) { - self.stage_slash_command_history_text(format!("/{}", cmd.command())); + self.stage_slash_command_history_text(format!("/{}", command_name(cmd))); } /// Store the provided command text and the current composer adornments in the pending slot. @@ -7451,7 +7455,7 @@ mod tests { match &composer.active_popup { ActivePopup::Command(popup) => match popup.selected_item() { - Some(command) => assert_eq!(command.command(), "model"), + Some(command) => assert_eq!(command_name(&command), "model"), None => panic!("no selected command for '/mo'"), }, _ => panic!("slash popup not active after typing '/mo'"), @@ -7501,7 +7505,7 @@ mod tests { match &composer.active_popup { ActivePopup::Command(popup) => match popup.selected_item() { - Some(command) => assert_eq!(command.command(), "resume"), + Some(command) => assert_eq!(command_name(&command), "resume"), None => panic!("no selected command for '/res'"), }, _ => panic!("slash popup not active after typing '/res'"), @@ -7560,7 +7564,7 @@ mod tests { // Command result (not submit literal text) and clear its textarea. match result { InputResult::Command(cmd) => { - assert_eq!(cmd.command(), "init"); + assert_eq!(command_name(&cmd), "init"); } InputResult::CommandWithArgs(_, _, _) => { panic!("expected command dispatch without args for '/init'") @@ -7637,7 +7641,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { InputResult::Command(cmd) => { - assert_eq!(cmd.command(), "diff"); + assert_eq!(command_name(&cmd), "diff"); } _ => panic!("expected Command result for '/diff'"), } @@ -8007,7 +8011,7 @@ mod tests { let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"), + InputResult::Command(cmd) => assert_eq!(command_name(&cmd), "diff"), InputResult::CommandWithArgs(_, _, _) => { panic!("expected command dispatch without args for '/diff'") } @@ -8200,7 +8204,7 @@ mod tests { match result { InputResult::Command(cmd) => { - assert_eq!(cmd.command(), "mention"); + assert_eq!(command_name(&cmd), "mention"); } InputResult::CommandWithArgs(_, _, _) => { panic!("expected command dispatch without args for '/mention'") @@ -8244,7 +8248,7 @@ mod tests { match result { InputResult::CommandWithArgs(cmd, args, text_elements) => { - assert_eq!(cmd.command(), "plan"); + assert_eq!(command_name(&cmd), "plan"); assert_eq!(args, placeholder); assert_eq!(text_elements.len(), 1); assert_eq!( @@ -9923,7 +9927,7 @@ mod tests { match result { InputResult::CommandWithArgs(cmd, args, text_elements) => { - assert_eq!(cmd.command(), "plan"); + assert_eq!(command_name(&cmd), "plan"); assert_eq!(args, "investigate this"); assert!(text_elements.is_empty()); } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 77557d74c5..b543b344a1 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -12,6 +12,9 @@ use super::selection_popup_common::render_rows_with_col_width_mode; use super::slash_commands; use super::slash_commands::ServiceTierCommand; use super::slash_commands::SlashCommandAction; +use super::slash_commands::command_as_builtin; +use super::slash_commands::command_description; +use super::slash_commands::command_name; use crate::render::Insets; use crate::render::RectExt; use crate::slash_command::SlashCommand; @@ -73,8 +76,8 @@ impl CommandPopup { // Keep command availability in sync with the composer. let commands = slash_commands::commands_for_input(flags.into(), service_tier_commands) .into_iter() - .filter(|command| !command.command().starts_with("debug")) - .filter(|command| command.as_builtin() != Some(SlashCommand::Apps)) + .filter(|command| !command_name(command).starts_with("debug")) + .filter(|command| command_as_builtin(command) != Some(SlashCommand::Apps)) .collect(); Self { command_filter: String::new(), @@ -135,8 +138,7 @@ impl CommandPopup { let mut out: Vec<(CommandItem, Option>)> = Vec::new(); if filter.is_empty() { for command in self.commands.iter() { - if command - .as_builtin() + if command_as_builtin(command) .is_some_and(|builtin| ALIAS_COMMANDS.contains(&builtin)) { continue; @@ -174,7 +176,7 @@ impl CommandPopup { }; for command in self.commands.iter() { - push_match(command.clone(), command.command(), None, 0); + push_match(command.clone(), command_name(command), None, 0); } out.extend(exact); @@ -193,8 +195,8 @@ impl CommandPopup { matches .into_iter() .map(|(item, indices)| { - let name = format!("/{}", item.command()); - let description = item.description().to_string(); + let name = format!("/{}", command_name(&item)); + let description = command_description(&item).to_string(); GenericDisplayRow { name, name_prefix_spans: Vec::new(), @@ -266,7 +268,7 @@ mod tests { // Access the filtered list via the selected command and ensure that // one of the matches is the new "init" command. let matches = popup.filtered_items(); - let has_init = matches.iter().any(|item| item.command() == "init"); + let has_init = matches.iter().any(|item| command_name(item) == "init"); assert!( has_init, "expected '/init' to appear among filtered commands" @@ -282,7 +284,7 @@ mod tests { // command by default. let selected = popup.selected_item(); match selected { - Some(command) => assert_eq!(command.command(), "init"), + Some(command) => assert_eq!(command_name(&command), "init"), None => panic!("expected a selected command for exact match"), } } @@ -293,7 +295,7 @@ mod tests { popup.on_composer_text_change("/mo".to_string()); let matches = popup.filtered_items(); match matches.first() { - Some(command) => assert_eq!(command.command(), "model"), + Some(command) => assert_eq!(command_name(command), "model"), None => panic!("expected at least one match for '/mo'"), } } @@ -303,12 +305,20 @@ mod tests { let mut popup = CommandPopup::new(CommandPopupFlags::default(), &[]); popup.on_composer_text_change("/m".to_string()); - let cmds: Vec<&str> = popup + let cmds: Vec = popup .filtered_items() .into_iter() - .map(|item| item.command()) + .map(|item| command_name(&item).to_string()) .collect(); - assert_eq!(cmds, vec!["model", "memories", "mention", "mcp"]); + assert_eq!( + cmds, + vec![ + "model".to_string(), + "memories".to_string(), + "mention".to_string(), + "mcp".to_string(), + ] + ); } #[test] @@ -316,13 +326,13 @@ mod tests { let mut popup = CommandPopup::new(CommandPopupFlags::default(), &[]); popup.on_composer_text_change("/ac".to_string()); - let cmds: Vec<&str> = popup + let cmds: Vec = popup .filtered_items() .into_iter() - .map(|item| item.command()) + .map(|item| command_name(&item).to_string()) .collect(); assert!( - !cmds.contains(&"compact"), + !cmds.iter().any(|command| command == "compact"), "expected prefix search for '/ac' to exclude 'compact', got {cmds:?}" ); } @@ -344,17 +354,17 @@ mod tests { let mut popup = CommandPopup::new(CommandPopupFlags::default(), &[]); popup.on_composer_text_change("/".to_string()); - let cmds: Vec<&str> = popup + let cmds: Vec = popup .filtered_items() .into_iter() - .map(|item| item.command()) + .map(|item| command_name(&item).to_string()) .collect(); assert!( - !cmds.contains(&"collab"), + !cmds.iter().any(|command| command == "collab"), "expected '/collab' to be hidden when collaboration modes are disabled, got {cmds:?}" ); assert!( - !cmds.contains(&"plan"), + !cmds.iter().any(|command| command == "plan"), "expected '/plan' to be hidden when collaboration modes are disabled, got {cmds:?}" ); } @@ -379,7 +389,7 @@ mod tests { popup.on_composer_text_change("/collab".to_string()); match popup.selected_item() { - Some(command) => assert_eq!(command.command(), "collab"), + Some(command) => assert_eq!(command_name(&command), "collab"), other => panic!("expected collab to be selected for exact match, got {other:?}"), } } @@ -404,7 +414,7 @@ mod tests { popup.on_composer_text_change("/plan".to_string()); match popup.selected_item() { - Some(command) => assert_eq!(command.command(), "plan"), + Some(command) => assert_eq!(command_name(&command), "plan"), other => panic!("expected plan to be selected for exact match, got {other:?}"), } } @@ -428,13 +438,13 @@ mod tests { ); popup.on_composer_text_change("/pers".to_string()); - let cmds: Vec<&str> = popup + let cmds: Vec = popup .filtered_items() .into_iter() - .map(|item| item.command()) + .map(|item| command_name(&item).to_string()) .collect(); assert!( - !cmds.contains(&"personality"), + !cmds.iter().any(|command| command == "personality"), "expected '/personality' to be hidden when disabled, got {cmds:?}" ); } @@ -459,7 +469,7 @@ mod tests { popup.on_composer_text_change("/personality".to_string()); match popup.selected_item() { - Some(command) => assert_eq!(command.command(), "personality"), + Some(command) => assert_eq!(command_name(&command), "personality"), other => panic!("expected personality to be selected for exact match, got {other:?}"), } } @@ -483,14 +493,14 @@ mod tests { ); popup.on_composer_text_change("/aud".to_string()); - let cmds: Vec<&str> = popup + let cmds: Vec = popup .filtered_items() .into_iter() - .map(|item| item.command()) + .map(|item| command_name(&item).to_string()) .collect(); assert!( - !cmds.contains(&"settings"), + !cmds.iter().any(|command| command == "settings"), "expected '/settings' to be hidden when audio device selection is disabled, got {cmds:?}" ); } @@ -498,10 +508,10 @@ mod tests { #[test] fn debug_commands_are_hidden_from_popup() { let popup = CommandPopup::new(CommandPopupFlags::default(), &[]); - let cmds: Vec<&str> = popup + let cmds: Vec = popup .filtered_items() .into_iter() - .map(|item| item.command()) + .map(|item| command_name(&item).to_string()) .collect(); assert!( diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index b3e44631fa..18025daf85 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -7,6 +7,7 @@ use std::collections::HashSet; use std::str::FromStr; use codex_protocol::config_types::ServiceTier; +use codex_protocol::config_types::priority_service_tier; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelServiceTier; use codex_utils_fuzzy_match::fuzzy_match; @@ -36,14 +37,15 @@ pub(crate) struct ServiceTierCommand { pub(crate) description: String, } -impl ServiceTierCommand { - fn from_model_service_tier(service_tier: &ModelServiceTier, command: String) -> Self { - Self { - service_tier: service_tier.id.clone(), - command, - name: service_tier.name.clone(), - description: service_tier.description.clone(), - } +fn service_tier_command_from_model_tier( + service_tier: &ModelServiceTier, + command: String, +) -> ServiceTierCommand { + ServiceTierCommand { + service_tier: service_tier.id.clone(), + command, + name: service_tier.name.clone(), + description: service_tier.description.clone(), } } @@ -53,47 +55,45 @@ pub(crate) enum SlashCommandAction { ServiceTier(ServiceTierCommand), } -impl SlashCommandAction { - pub(crate) fn command(&self) -> &str { - match self { - Self::Builtin(command) => command.command(), - Self::ServiceTier(command) => command.command.as_str(), - } +pub(crate) fn command_name(action: &SlashCommandAction) -> &str { + match action { + SlashCommandAction::Builtin(command) => command.command(), + SlashCommandAction::ServiceTier(command) => command.command.as_str(), } +} - pub(crate) fn description(&self) -> &str { - match self { - Self::Builtin(command) => command.description(), - Self::ServiceTier(command) => command.description.as_str(), - } +pub(crate) fn command_description(action: &SlashCommandAction) -> &str { + match action { + SlashCommandAction::Builtin(command) => command.description(), + SlashCommandAction::ServiceTier(command) => command.description.as_str(), } +} - pub(crate) fn supports_inline_args(&self) -> bool { - match self { - Self::Builtin(command) => command.supports_inline_args(), - Self::ServiceTier(_) => true, - } +pub(crate) fn command_supports_inline_args(action: &SlashCommandAction) -> bool { + match action { + SlashCommandAction::Builtin(command) => command.supports_inline_args(), + SlashCommandAction::ServiceTier(_) => true, } +} - pub(crate) fn available_in_side_conversation(&self) -> bool { - match self { - Self::Builtin(command) => command.available_in_side_conversation(), - Self::ServiceTier(_) => false, - } +pub(crate) fn command_available_in_side_conversation(action: &SlashCommandAction) -> bool { + match action { + SlashCommandAction::Builtin(command) => command.available_in_side_conversation(), + SlashCommandAction::ServiceTier(_) => false, } +} - pub(crate) fn available_during_task(&self) -> bool { - match self { - Self::Builtin(command) => command.available_during_task(), - Self::ServiceTier(_) => false, - } +pub(crate) fn command_available_during_task(action: &SlashCommandAction) -> bool { + match action { + SlashCommandAction::Builtin(command) => command.available_during_task(), + SlashCommandAction::ServiceTier(_) => false, } +} - pub(crate) fn as_builtin(&self) -> Option { - match self { - Self::Builtin(command) => Some(*command), - Self::ServiceTier(_) => None, - } +pub(crate) fn command_as_builtin(action: &SlashCommandAction) -> Option { + match action { + SlashCommandAction::Builtin(command) => Some(*command), + SlashCommandAction::ServiceTier(_) => None, } } @@ -180,7 +180,7 @@ pub(crate) fn has_command_prefix( ) -> bool { commands_for_input(flags, service_tier_commands) .into_iter() - .any(|command| fuzzy_match(command.command(), name).is_some()) + .any(|command| fuzzy_match(command_name(&command), name).is_some()) } pub(crate) fn service_tier_commands_for_model(model: &ModelPreset) -> Vec { @@ -191,10 +191,7 @@ pub(crate) fn service_tier_commands_for_model(model: &ModelPreset) -> Vec>(); assert_eq!( @@ -385,12 +382,12 @@ mod tests { supports_personality: false, service_tiers: vec![ ModelServiceTier { - id: ServiceTier::priority(), + id: priority_service_tier(), name: "Fast".to_string(), description: "Fast tier".to_string(), }, ModelServiceTier { - id: ServiceTier::new("express"), + id: ServiceTier::from("express"), name: "Model".to_string(), description: "Express tier".to_string(), }, @@ -409,13 +406,13 @@ mod tests { commands, vec![ ServiceTierCommand { - service_tier: ServiceTier::priority(), + service_tier: priority_service_tier(), command: "fast".to_string(), name: "Fast".to_string(), description: "Fast tier".to_string(), }, ServiceTierCommand { - service_tier: ServiceTier::new("express"), + service_tier: ServiceTier::from("express"), command: "express".to_string(), name: "Model".to_string(), description: "Express tier".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 08764ef822..89c1497b21 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -57,7 +57,6 @@ use crate::bottom_pane::StatusSurfacePreviewData; use crate::bottom_pane::StatusSurfacePreviewItem; use crate::bottom_pane::TerminalTitleItem; use crate::bottom_pane::TerminalTitleSetupView; -use crate::bottom_pane::slash_commands::ServiceTierCommand; use crate::diff_model::FileChange; use crate::legacy_core::DEFAULT_AGENTS_MD_FILENAME; use crate::legacy_core::config::Config; @@ -155,6 +154,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Settings; #[cfg(target_os = "windows")] use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::config_types::is_priority_service_tier; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; use codex_protocol::models::MessagePhase; @@ -343,6 +343,10 @@ use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE; mod realtime; use self::realtime::RealtimeConversationUiState; mod reasoning_shortcuts; +pub(crate) mod service_tiers; +use self::service_tiers::available_service_tier_commands; +use self::service_tiers::model_preset; +use self::service_tiers::model_supports_fast_mode; mod side; mod status_surfaces; use self::status_surfaces::CachedProjectRootName; @@ -9263,8 +9267,8 @@ impl ChatWidget { model: &str, service_tier: Option, ) -> bool { - self.model_supports_fast_mode(model) - && service_tier.as_ref().is_some_and(ServiceTier::is_priority) + model_supports_fast_mode(self, model) + && service_tier.as_ref().is_some_and(is_priority_service_tier) && self.has_chatgpt_account } @@ -9359,7 +9363,7 @@ impl ChatWidget { fn sync_service_tier_commands(&mut self) { self.bottom_pane - .set_service_tier_commands(self.available_service_tier_commands()); + .set_service_tier_commands(available_service_tier_commands(self)); } fn sync_personality_command_enabled(&mut self) { @@ -9377,64 +9381,18 @@ impl ChatWidget { .set_goal_command_enabled(self.config.features.enabled(Feature::Goals)); } - fn model_preset(&self, model: &str) -> Option { - self.model_catalog - .try_list_models() - .ok() - .and_then(|models| models.into_iter().find(|preset| preset.model == model)) - } - - fn available_service_tier_commands(&self) -> Vec { - self.model_preset(self.current_model()) - .map(|model| { - crate::bottom_pane::slash_commands::service_tier_commands_for_model(&model) - }) - .unwrap_or_default() - } - - pub(crate) fn service_tier_display_name( - &self, - model: &str, - service_tier: &ServiceTier, - ) -> String { - self.model_preset(model) - .and_then(|preset| { - preset - .service_tier(service_tier) - .map(|tier| tier.name.clone()) - }) - .unwrap_or_else(|| service_tier.to_string()) - } - - pub(crate) fn current_service_tier_name(&self) -> Option { - self.current_service_tier() - .map(|tier| self.service_tier_display_name(self.current_model(), &tier)) - } - - pub(crate) fn current_service_tier_status_label(&self) -> String { - self.current_service_tier_name() - .map(|name| format!("Tier {name}")) - .unwrap_or_else(|| "Tier default".to_string()) - } - fn current_model_supports_personality(&self) -> bool { - self.model_preset(self.current_model()) + model_preset(self, self.current_model()) .map(|preset| preset.supports_personality) .unwrap_or(false) } - fn model_supports_fast_mode(&self, model: &str) -> bool { - self.model_preset(model) - .map(|preset| preset.supports_service_tier(&ServiceTier::priority())) - .unwrap_or(false) - } - /// Return whether the effective model currently advertises image-input support. /// /// We intentionally default to `true` when model metadata cannot be read so transient catalog /// failures do not hard-block user input in the UI. fn current_model_supports_images(&self) -> bool { - self.model_preset(self.current_model()) + model_preset(self, self.current_model()) .map(|preset| preset.input_modalities.contains(&InputModality::Image)) .unwrap_or(true) } diff --git a/codex-rs/tui/src/chatwidget/service_tiers.rs b/codex-rs/tui/src/chatwidget/service_tiers.rs new file mode 100644 index 0000000000..45deab5705 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/service_tiers.rs @@ -0,0 +1,49 @@ +use codex_protocol::config_types::ServiceTier; +use codex_protocol::config_types::priority_service_tier; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::model_service_tier; +use codex_protocol::openai_models::model_supports_service_tier; + +use super::ChatWidget; +use crate::bottom_pane::slash_commands::ServiceTierCommand; +use crate::bottom_pane::slash_commands::service_tier_commands_for_model; + +pub(super) fn model_preset(chat: &ChatWidget, model: &str) -> Option { + chat.model_catalog + .try_list_models() + .ok() + .and_then(|models| models.into_iter().find(|preset| preset.model == model)) +} + +pub(crate) fn available_service_tier_commands(chat: &ChatWidget) -> Vec { + model_preset(chat, chat.current_model()) + .map(|model| service_tier_commands_for_model(&model)) + .unwrap_or_default() +} + +pub(crate) fn service_tier_display_name( + chat: &ChatWidget, + model: &str, + service_tier: &ServiceTier, +) -> String { + model_preset(chat, model) + .and_then(|preset| model_service_tier(&preset, service_tier).map(|tier| tier.name.clone())) + .unwrap_or_else(|| service_tier.to_string()) +} + +pub(crate) fn current_service_tier_name(chat: &ChatWidget) -> Option { + chat.current_service_tier() + .map(|tier| service_tier_display_name(chat, chat.current_model(), &tier)) +} + +pub(crate) fn current_service_tier_status_label(chat: &ChatWidget) -> String { + current_service_tier_name(chat) + .map(|name| format!("Tier {name}")) + .unwrap_or_else(|| "Tier default".to_string()) +} + +pub(crate) fn model_supports_fast_mode(chat: &ChatWidget, model: &str) -> bool { + model_preset(chat, model) + .map(|preset| model_supports_service_tier(&preset, &priority_service_tier())) + .unwrap_or(false) +} diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 504ff27cd9..8e55d17ac9 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -5,11 +5,17 @@ //! dispatch step and records the staged entry once the command has been handled, so //! slash-command recall follows the same submitted-input rule as ordinary text. +use super::service_tiers::available_service_tier_commands; use super::*; use crate::app_event::ThreadGoalSetMode; use crate::bottom_pane::prompt_args::parse_slash_name; use crate::bottom_pane::slash_commands; use crate::bottom_pane::slash_commands::SlashCommandAction; +use crate::bottom_pane::slash_commands::command_as_builtin; +use crate::bottom_pane::slash_commands::command_available_during_task; +use crate::bottom_pane::slash_commands::command_available_in_side_conversation; +use crate::bottom_pane::slash_commands::command_name; +use crate::bottom_pane::slash_commands::command_supports_inline_args; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SlashCommandDispatchSource { @@ -40,7 +46,7 @@ impl ChatWidget { /// that staged entry after dispatch so slash-command recall follows the same "submitted input" /// rule as normal text. pub(super) fn handle_slash_command_dispatch(&mut self, cmd: SlashCommandAction) { - let is_goal = cmd.as_builtin() == Some(SlashCommand::Goal); + let is_goal = command_as_builtin(&cmd) == Some(SlashCommand::Goal); self.dispatch_command(cmd); if is_goal { self.bottom_pane.drain_pending_submission_state(); @@ -112,10 +118,10 @@ impl ChatWidget { if !self.ensure_side_command_allowed_outside_review(&cmd) { return; } - if !cmd.available_during_task() && self.bottom_pane.is_task_running() { + if !command_available_during_task(&cmd) && self.bottom_pane.is_task_running() { let message = format!( "'/{}' is disabled while a task is in progress.", - cmd.command() + command_name(&cmd) ); self.add_to_history(history_cell::new_error_event(message)); self.bottom_pane.drain_pending_submission_state(); @@ -458,14 +464,14 @@ impl ChatWidget { if !self.ensure_side_command_allowed_outside_review(&cmd) { return; } - if !cmd.supports_inline_args() { + if !command_supports_inline_args(&cmd) { self.dispatch_command(cmd); return; } - if !cmd.available_during_task() && self.bottom_pane.is_task_running() { + if !command_available_during_task(&cmd) && self.bottom_pane.is_task_running() { let message = format!( "'/{}' is disabled while a task is in progress.", - cmd.command() + command_name(&cmd) ); self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); @@ -548,7 +554,7 @@ impl ChatWidget { source, } = prepared; let trimmed = args.trim(); - let is_goal_command = cmd.as_builtin() == Some(SlashCommand::Goal); + let is_goal_command = command_as_builtin(&cmd) == Some(SlashCommand::Goal); match cmd { SlashCommandAction::ServiceTier(command) => { match trimmed.to_ascii_lowercase().as_str() { @@ -769,7 +775,7 @@ impl ChatWidget { let Some(cmd) = slash_commands::find_command( name, self.builtin_command_flags(), - &self.available_service_tier_commands(), + &available_service_tier_commands(self), ) else { self.add_info_message( format!( @@ -785,7 +791,7 @@ impl ChatWidget { return self.queued_command_drain_result(cmd); } - if !cmd.supports_inline_args() { + if !command_supports_inline_args(&cmd) { self.submit_user_message(UserMessage { text, local_images, @@ -930,19 +936,19 @@ impl ChatWidget { &mut self, cmd: &SlashCommandAction, ) -> bool { - if !self.active_side_conversation || cmd.available_in_side_conversation() { + if !self.active_side_conversation || command_available_in_side_conversation(cmd) { return true; } self.add_error_message(format!( "'/{}' is unavailable in side conversations. {SIDE_SLASH_COMMAND_UNAVAILABLE_HINT}", - cmd.command() + command_name(cmd) )); self.bottom_pane.drain_pending_submission_state(); false } fn ensure_side_command_allowed_outside_review(&mut self, cmd: &SlashCommandAction) -> bool { - if cmd.as_builtin() != Some(SlashCommand::Side) || !self.is_review_mode { + if command_as_builtin(cmd) != Some(SlashCommand::Side) || !self.is_review_mode { return true; } diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index d25e5ec952..a8778fc314 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -3,6 +3,8 @@ //! Keeping this logic in a focused submodule makes the additive title/status //! behavior easier to review without paging through the rest of `chatwidget.rs`. +use super::service_tiers::current_service_tier_name; +use super::service_tiers::current_service_tier_status_label; use super::*; use crate::bottom_pane::status_line_from_segments; use crate::status::format_tokens_compact; @@ -557,7 +559,7 @@ impl ChatWidget { format_tokens_compact(self.status_line_total_usage().output_tokens) )), StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()), - StatusLineItem::FastMode => Some(self.current_service_tier_status_label()), + StatusLineItem::FastMode => Some(current_service_tier_status_label(self)), StatusLineItem::ThreadTitle => self.thread_name.as_ref().and_then(|name| { let trimmed = name.trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) @@ -671,8 +673,7 @@ impl ChatWidget { fn model_with_reasoning_display_name(&self) -> String { let label = Self::status_line_reasoning_effort_label(self.effective_reasoning_effort()); - let service_tier_label = self - .current_service_tier_name() + let service_tier_label = current_service_tier_name(self) .map(|name| format!(" {}", name.to_ascii_lowercase())) .unwrap_or_default(); format!("{} {label}{service_tier_label}", self.model_display_name()) diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2b09c0aadc..fbcebe97c3 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1,6 +1,7 @@ use super::*; use crate::bottom_pane::slash_commands::ServiceTierCommand; use crate::bottom_pane::slash_commands::SlashCommandAction; +use codex_protocol::config_types::priority_service_tier; use pretty_assertions::assert_eq; fn complete_turn_with_message(chat: &mut ChatWidget, turn_id: &str, message: Option<&str>) { @@ -31,7 +32,7 @@ fn queue_composer_text_with_tab(chat: &mut ChatWidget, text: &str) { fn fast_service_tier_command() -> SlashCommandAction { SlashCommandAction::ServiceTier(ServiceTierCommand { - service_tier: ServiceTier::priority(), + service_tier: priority_service_tier(), command: "fast".to_string(), name: "Fast".to_string(), description: "Fast tier".to_string(), @@ -1708,13 +1709,14 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() { chat.dispatch_command(fast_service_tier_command()); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + let priority_tier = priority_service_tier(); assert!( events.iter().any(|event| matches!( event, AppEvent::CodexOp(AppCommand::OverrideTurnContext { - service_tier: Some(Some(ServiceTier::priority())), + service_tier: Some(Some(tier)), .. - }) + }) if tier == &priority_tier )), "expected fast-mode override app event; events: {events:?}" ); @@ -1722,8 +1724,8 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() { events.iter().any(|event| matches!( event, AppEvent::PersistServiceTierSelection { - service_tier: Some(ServiceTier::priority()), - } + service_tier: Some(tier), + } if tier == &priority_tier )), "expected fast-mode persistence app event; events: {events:?}" ); @@ -1748,9 +1750,9 @@ async fn user_turn_carries_service_tier_after_fast_toggle() { match next_submit_op(&mut op_rx) { Op::UserTurn { - service_tier: Some(Some(ServiceTier::priority())), + service_tier: Some(Some(tier)), .. - } => {} + } if tier == priority_service_tier() => {} other => panic!("expected Op::UserTurn with fast service tier, got {other:?}"), } } @@ -1769,13 +1771,14 @@ async fn queued_fast_slash_applies_before_next_queued_message() { complete_turn_with_message(&mut chat, "turn-1", Some("done")); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + let priority_tier = priority_service_tier(); assert!( events.iter().any(|event| matches!( event, AppEvent::CodexOp(AppCommand::OverrideTurnContext { - service_tier: Some(Some(ServiceTier::priority())), + service_tier: Some(Some(tier)), .. - }) + }) if tier == &priority_tier )), "expected queued /fast to update service tier before next turn; events: {events:?}" ); @@ -1783,9 +1786,9 @@ async fn queued_fast_slash_applies_before_next_queued_message() { match next_submit_op(&mut op_rx) { Op::UserTurn { items, - service_tier: Some(Some(ServiceTier::priority())), + service_tier: Some(Some(tier)), .. - } => assert_eq!( + } if tier == priority_service_tier() => assert_eq!( items, vec![UserInput::Text { text: "hello after fast".to_string(), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index e1fb17ae4e..f95dcf9872 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1,5 +1,7 @@ use super::*; use crate::bottom_pane::goal_status_indicator_line; +use codex_protocol::config_types::priority_service_tier; +use codex_protocol::openai_models::model_supports_service_tier; use pretty_assertions::assert_eq; /// Receiving a token usage update without usage clears the context indicator. @@ -1016,14 +1018,20 @@ async fn commentary_completion_restores_status_indicator_before_exec_begin() { async fn fast_status_indicator_requires_chatgpt_auth() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); - chat.set_service_tier(Some(ServiceTier::priority())); + assert!(model_supports_service_tier( + &get_available_model(&chat, "gpt-5.4"), + &priority_service_tier() + )); + chat.set_service_tier(Some(priority_service_tier())); assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),)); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); + assert!(model_supports_service_tier( + &get_available_model(&chat, "gpt-5.4"), + &priority_service_tier() + )); assert!(chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),)); } @@ -1032,17 +1040,17 @@ async fn fast_status_indicator_requires_chatgpt_auth() { async fn fast_status_indicator_is_hidden_for_models_without_fast_support() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; set_fast_mode_test_catalog(&mut chat); - assert!( - !get_available_model(&chat, "gpt-5.3-codex") - .supports_service_tier(&ServiceTier::priority()) - ); - chat.set_service_tier(Some(ServiceTier::priority())); + assert!(!model_supports_service_tier( + &get_available_model(&chat, "gpt-5.3-codex"), + &priority_service_tier() + )); + chat.set_service_tier(Some(priority_service_tier())); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); - assert!( - !get_available_model(&chat, "gpt-5.3-codex") - .supports_service_tier(&ServiceTier::priority()) - ); + assert!(!model_supports_service_tier( + &get_available_model(&chat, "gpt-5.3-codex"), + &priority_service_tier() + )); assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),)); } @@ -1051,10 +1059,16 @@ async fn fast_status_indicator_is_hidden_for_models_without_fast_support() { async fn fast_status_indicator_is_hidden_when_fast_mode_is_off() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); + assert!(model_supports_service_tier( + &get_available_model(&chat, "gpt-5.4"), + &priority_service_tier() + )); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); + assert!(model_supports_service_tier( + &get_available_model(&chat, "gpt-5.4"), + &priority_service_tier() + )); assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),)); } @@ -1371,7 +1385,7 @@ async fn status_line_fast_mode_renders_on_and_off() { chat.refresh_status_line(); assert_eq!(status_line_text(&chat), Some("Tier default".to_string())); - chat.set_service_tier(Some(ServiceTier::priority())); + chat.set_service_tier(Some(priority_service_tier())); chat.refresh_status_line(); assert_eq!(status_line_text(&chat), Some("Tier Fast".to_string())); } @@ -1384,7 +1398,7 @@ async fn status_line_fast_mode_footer_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.show_welcome_banner = false; chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]); - chat.set_service_tier(Some(ServiceTier::priority())); + chat.set_service_tier(Some(priority_service_tier())); chat.refresh_status_line(); let width = 80; @@ -1403,7 +1417,10 @@ async fn status_line_fast_mode_footer_snapshot() { async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); + assert!(model_supports_service_tier( + &get_available_model(&chat, "gpt-5.4"), + &priority_service_tier() + )); chat.config.cwd = test_project_path().abs(); chat.config.tui_status_line = Some(vec![ "model-with-reasoning".to_string(), @@ -1411,10 +1428,13 @@ async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models( "current-dir".to_string(), ]); chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); - chat.set_service_tier(Some(ServiceTier::priority())); + chat.set_service_tier(Some(priority_service_tier())); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); + assert!(model_supports_service_tier( + &get_available_model(&chat, "gpt-5.4"), + &priority_service_tier() + )); chat.refresh_status_line(); let test_cwd = test_path_display("/tmp/project"); @@ -1550,7 +1570,10 @@ async fn status_line_model_with_reasoning_fast_footer_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); + assert!(model_supports_service_tier( + &get_available_model(&chat, "gpt-5.4"), + &priority_service_tier() + )); chat.show_welcome_banner = false; chat.config.cwd = test_project_path().abs(); chat.config.tui_status_line = Some(vec![ @@ -1559,10 +1582,13 @@ async fn status_line_model_with_reasoning_fast_footer_snapshot() { "current-dir".to_string(), ]); chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); - chat.set_service_tier(Some(ServiceTier::priority())); + chat.set_service_tier(Some(priority_service_tier())); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); + assert!(model_supports_service_tier( + &get_available_model(&chat, "gpt-5.4"), + &priority_service_tier() + )); chat.refresh_status_line(); let width = 80; @@ -1584,7 +1610,10 @@ async fn status_line_model_with_reasoning_context_remaining_footer_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); + assert!(model_supports_service_tier( + &get_available_model(&chat, "gpt-5.4"), + &priority_service_tier() + )); chat.show_welcome_banner = false; chat.config.cwd = test_project_path().abs(); chat.config.tui_status_line = Some(vec![ @@ -1593,10 +1622,13 @@ async fn status_line_model_with_reasoning_context_remaining_footer_snapshot() { "current-dir".to_string(), ]); chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); - chat.set_service_tier(Some(ServiceTier::priority())); + chat.set_service_tier(Some(priority_service_tier())); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); + assert!(model_supports_service_tier( + &get_available_model(&chat, "gpt-5.4"), + &priority_service_tier() + )); chat.refresh_status_line(); let width = 80;