Move TUI tier helpers out of widget types

This commit is contained in:
Ahmed Ibrahim
2026-05-04 01:15:49 +03:00
parent 402b1706bb
commit 9691d7b865
11 changed files with 269 additions and 208 deletions
+3 -1
View File
@@ -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,
)
+5 -6
View File
@@ -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]
+21 -17
View File
@@ -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());
}
+41 -31
View File
@@ -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<usize>>)> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = popup
.filtered_items()
.into_iter()
.map(|item| item.command())
.map(|item| command_name(&item).to_string())
.collect();
assert!(
+47 -50
View File
@@ -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<SlashCommand> {
match self {
Self::Builtin(command) => Some(*command),
Self::ServiceTier(_) => None,
}
pub(crate) fn command_as_builtin(action: &SlashCommandAction) -> Option<SlashCommand> {
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<ServiceTierCommand> {
@@ -191,10 +191,7 @@ pub(crate) fn service_tier_commands_for_model(model: &ModelPreset) -> Vec<Servic
continue;
};
reserved_names.insert(command.clone());
commands.push(ServiceTierCommand::from_model_service_tier(
service_tier,
command,
));
commands.push(service_tier_command_from_model_tier(service_tier, command));
}
commands
}
@@ -294,7 +291,7 @@ mod tests {
#[test]
fn service_tier_command_is_hidden_when_disabled() {
let service_tier_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(),
@@ -343,7 +340,7 @@ mod tests {
&[],
)
.into_iter()
.map(|command| command.command().to_string())
.map(|command| command_name(&command).to_string())
.collect::<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(),
+10 -52
View File
@@ -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<ServiceTier>,
) -> 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<ModelPreset> {
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<ServiceTierCommand> {
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<String> {
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)
}
@@ -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<ModelPreset> {
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<ServiceTierCommand> {
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<String> {
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)
}
+18 -12
View File
@@ -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;
}
@@ -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())
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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(),
@@ -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;