Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion app/src/ai/llms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -816,12 +816,25 @@ impl LLMPreferences {
&self,
app: &AppContext,
terminal_view_id: Option<EntityId>,
) -> &LLMInfo {
self.get_preferred_base_model_for_settings_mode(
settings::settings_mode(),
app,
terminal_view_id,
)
}

fn get_preferred_base_model_for_settings_mode(
&self,
settings_mode: settings::SettingsMode,
app: &AppContext,
terminal_view_id: Option<EntityId>,
) -> &LLMInfo {
// In the TUI, the file-backed `agents.model` setting is the source of
// truth for the base model: it overrides both per-surface overrides
// and the cloud-synced execution profile, keeping the TUI's TOML file
// the single place the model is configured.
if settings::settings_mode() == settings::SettingsMode::Tui {
if settings_mode == settings::SettingsMode::Tui {
return self.tui_agent_model_info(AISettings::as_ref(app).agent_model.value(), app);
}

Expand Down
72 changes: 72 additions & 0 deletions app/src/ai/llms_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::network::NetworkStatus;
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::server_api::ServerApiProvider;
use crate::server::sync_queue::SyncQueue;
use crate::terminal::input::models::query_model_picker_choices;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspaces::team_tester::TeamTesterStatus;
use crate::workspaces::user_workspaces::UserWorkspaces;
Expand Down Expand Up @@ -154,6 +155,13 @@ fn endpoint(
}
}

fn disabled_agent_llm(id: &str, display_name: &str) -> LLMInfo {
LLMInfo {
disable_reason: Some(DisableReason::Unavailable),
..agent_llm(id, display_name)
}
}

fn model(name: &str, alias: Option<&str>, config_key: &str) -> CustomEndpointModel {
CustomEndpointModel {
name: name.into(),
Expand Down Expand Up @@ -536,6 +544,38 @@ fn active_models_fall_back_to_usable_choice_or_custom_endpoint_when_default_disa
});
}

/// Runs picker-query assertions with searchable, selectable, and disabled model fixtures plus
/// the app singletons consulted by model eligibility logic.
fn with_model_picker_query_test_context(f: impl FnOnce(&LLMPreferences, &AppContext) + 'static) {
App::test((), |app| async move {
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(UserWorkspaces::default_mock);
app.read(|app_ctx| {
let agent_mode = AvailableLLMs::new(
"auto".into(),
vec![
agent_llm("auto", "auto (cost-efficient)"),
agent_llm("gpt-5", "GPT 5"),
disabled_agent_llm("disabled-gpt", "GPT Disabled"),
],
None,
)
.expect("choices are non-empty");
let preferences = LLMPreferences {
models_by_feature: ModelsByFeature {
agent_mode,
..Default::default()
},
last_update: None,
base_llm_for_terminal_view: HashMap::new(),
custom_llms: Vec::new(),
custom_model_routers: Vec::new(),
};
f(&preferences, app_ctx);
});
});
}

#[test]
fn active_models_use_default_when_usable() {
App::test((), |mut app| async move {
Expand Down Expand Up @@ -714,6 +754,38 @@ fn tui_agent_model_auto_resolves_to_the_default_model() {
});
}

#[test]
fn shared_model_picker_query_orders_filters_and_marks_disabled_choices() {
with_model_picker_query_test_context(|preferences, app| {
let all = query_model_picker_choices(
preferences,
preferences.get_base_llm_choices_for_agent_mode(app),
"",
app,
);
assert_eq!(
all.first().map(|choice| choice.llm.id.as_str()),
Some("auto")
);
assert_eq!(
all.last().map(|choice| choice.llm.id.as_str()),
Some("disabled-gpt")
);
assert!(!all.last().expect("disabled choice").is_selectable());

let filtered = query_model_picker_choices(
preferences,
preferences.get_base_llm_choices_for_agent_mode(app),
"gpt 5",
app,
);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].llm.id.as_str(), "gpt-5");
assert!(filtered[0].name_match_result.is_some());
assert!(filtered[0].is_selectable());
});
}

#[test]
fn tui_agent_model_known_id_resolves_to_that_model() {
tui_agent_model_test(|preferences, app| {
Expand Down
135 changes: 82 additions & 53 deletions app/src/terminal/input/models/data_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,78 @@ fn model_specs_width(app: &AppContext) -> f32 {
appearance.monospace_font_size(),
) * 34.
}
/// Frontend-neutral model picker result shared by GUI and TUI surfaces.
#[derive(Clone, Debug)]
pub struct ModelPickerChoice {
pub llm: LLMInfo,
pub disable_reason: Option<DisableReason>,
pub name_match_result: Option<FuzzyMatchResult>,
pub score: OrderedFloat<f64>,
}

impl ModelPickerChoice {
pub fn is_selectable(&self) -> bool {
self.disable_reason.is_none()
}

fn priority_tier(&self) -> u8 {
if self.is_selectable() {
0
} else {
1
}
}
}

/// Applies the GUI model picker's ordering, fuzzy filtering, and effective disabled state.
pub fn query_model_picker_choices<'a>(
llm_preferences: &LLMPreferences,
choices: impl IntoIterator<Item = &'a LLMInfo>,
query_text: &str,
app: &AppContext,
) -> Vec<ModelPickerChoice> {
let choices = ModelSelectorDataSource::order_model_choices(
llm_preferences,
choices.into_iter().collect(),
);
let query_text = query_text.trim().to_lowercase();
let mut results = choices
.into_iter()
.filter_map(|llm| {
let name_match_result = if query_text.is_empty() {
None
} else {
let result = match_indices_case_insensitive(
llm.display_name.to_lowercase().as_str(),
query_text.as_str(),
)?;
if query_text.len() > 1 && result.score < 10 {
return None;
}
Some(result)
};
let disable_reason = if llm.disable_reason == Some(DisableReason::RequiresUpgrade)
&& should_show_key_icon_for_model(llm, app)
{
None
} else {
llm.disable_reason.clone()
};
Some(ModelPickerChoice {
llm: llm.clone(),
disable_reason,
score: OrderedFloat(
name_match_result
.as_ref()
.map_or(f64::MIN, |result| result.score as f64),
),
name_match_result,
})
})
.collect::<Vec<_>>();
results.sort_by_key(|choice| (choice.priority_tier(), choice.score));
results
}

pub struct ModelSelectorDataSource {
terminal_view_id: EntityId,
Expand Down Expand Up @@ -247,37 +319,12 @@ impl SyncDataSource for ModelSelectorDataSource {
})
.collect_vec()
};
let choices = Self::order_model_choices(llm_preferences, choices);

let query_text = query.text.trim().to_lowercase();

if query_text.is_empty() {
return Ok(choices
Ok(
query_model_picker_choices(llm_preferences, choices, &query.text, app)
.into_iter()
.map(|llm| QueryResult::from(ModelSearchItem::new(llm, &active_llm_id, app)))
.collect());
}

Ok(choices
.into_iter()
.filter_map(|llm| {
let match_result = match_indices_case_insensitive(
llm.display_name.to_lowercase().as_str(),
query_text.as_str(),
)?;

// Avoid spamming results with extremely weak matches.
if query_text.len() > 1 && match_result.score < 10 {
return None;
}

Some(QueryResult::from(
ModelSearchItem::new(llm, &active_llm_id, app)
.with_name_match_result(Some(match_result.clone()))
.with_score(OrderedFloat(match_result.score as f64)),
))
})
.collect())
.map(|choice| QueryResult::from(ModelSearchItem::new(choice, &active_llm_id, app)))
.collect(),
)
}
}

Expand Down Expand Up @@ -310,16 +357,8 @@ struct ModelSearchItem {
}

impl ModelSearchItem {
fn new(llm: &LLMInfo, active_llm_id: &LLMId, app: &AppContext) -> Self {
// If the model requires an upgrade but the user already has a BYOK key
// for this provider, treat it as enabled by clearing the disable reason.
let disable_reason = if llm.disable_reason == Some(DisableReason::RequiresUpgrade)
&& should_show_key_icon_for_model(llm, app)
{
None
} else {
llm.disable_reason.clone()
};
fn new(choice: ModelPickerChoice, active_llm_id: &LLMId, app: &AppContext) -> Self {
let llm = &choice.llm;
let is_custom_router = is_custom_router_id(llm.id.as_str());
let is_auto = is_auto(llm);
let is_using_bedrock = should_show_bedrock_icon_for_model(llm, app);
Expand Down Expand Up @@ -347,27 +386,17 @@ impl ModelSearchItem {
is_selected: &llm.id == active_llm_id,
is_custom_router,
description: llm.description.clone(),
disable_reason,
disable_reason: choice.disable_reason,
is_auto,
is_using_bedrock,
name_match_result: None,
score: OrderedFloat(f64::MIN),
name_match_result: choice.name_match_result,
score: choice.score,
manage_api_key_mouse_state: Default::default(),
cost_row_tooltip_mouse_state: Default::default(),
reasoning_level: llm.reasoning_level(),
discount_percentage: llm.discount_percentage,
}
}

fn with_name_match_result(mut self, result: Option<FuzzyMatchResult>) -> Self {
self.name_match_result = result;
self
}

fn with_score(mut self, score: OrderedFloat<f64>) -> Self {
self.score = score;
self
}
}

impl SearchItem for ModelSearchItem {
Expand Down
4 changes: 3 additions & 1 deletion app/src/terminal/input/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@ mod data_source;
mod model_spec_scores;
mod view;

pub use data_source::{AcceptModel, ModelSelectorDataSource};
pub use data_source::{
query_model_picker_choices, AcceptModel, ModelPickerChoice, ModelSelectorDataSource,
};
pub use view::{InlineModelSelectorEvent, InlineModelSelectorTab, InlineModelSelectorView};
1 change: 1 addition & 0 deletions app/src/terminal/input/slash_commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ pub fn slash_command_is_supported_in_tui(command: &StaticCommand) -> bool {
|| command.name == commands::COMPACT.name
|| command.name == commands::PLAN.name
|| command.name == commands::CONVERSATIONS.name
|| command.name == commands::MODEL.name
}
/// Records a static slash command accepted from either the GUI or TUI surface.
pub fn record_static_slash_command_accepted(
Expand Down
9 changes: 8 additions & 1 deletion app/src/terminal/input/slash_commands/mod_tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use super::slash_command_is_submitted_as_prompt;
use super::{slash_command_is_submitted_as_prompt, slash_command_is_supported_in_tui};
use crate::features::FeatureFlag;
use crate::search::slash_command_menu::static_commands::{commands, Availability};
const BASELINE_AVAILABILITY: Availability = Availability::AGENT_VIEW
Expand Down Expand Up @@ -35,6 +35,13 @@ fn slash_command_is_submitted_as_prompt_only_for_prompt_commands() {
assert!(!slash_command_is_submitted_as_prompt(&commands::QUEUE));
}

#[test]
fn model_command_is_supported_in_tui_without_becoming_a_prompt_command() {
assert!(slash_command_is_supported_in_tui(&commands::MODEL));
assert!(!slash_command_is_submitted_as_prompt(&commands::MODEL));
assert!(commands::MODEL.argument.is_none());
}

#[test]
fn not_cloud_agent_commands_are_only_active_outside_cloud_mode() {
let local_context = BASELINE_AVAILABILITY | Availability::NOT_CLOUD_AGENT;
Expand Down
1 change: 1 addition & 0 deletions app/src/tui_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ pub use crate::terminal::conversation_restoration::{
RestoredConversationExchange,
};
pub use crate::terminal::event::AfterBlockCompletedEvent;
pub use crate::terminal::input::models::{query_model_picker_choices, ModelPickerChoice};
pub use crate::terminal::input::slash_command_model::{
slash_command_composition_filter, DetectedCommand, DetectedSkillCommand,
ParsedSlashCommandInput,
Expand Down
Loading