From 34cad0e03f1f49f06ed10695afd5f075059b1191 Mon Sep 17 00:00:00 2001 From: Moira Huang Date: Mon, 13 Jul 2026 17:25:57 -0700 Subject: [PATCH] Add TUI model selector Co-Authored-By: Oz --- app/src/ai/llms.rs | 15 +- app/src/ai/llms_tests.rs | 72 ++++++ app/src/terminal/input/models/data_source.rs | 135 ++++++---- app/src/terminal/input/models/mod.rs | 4 +- app/src/terminal/input/slash_commands/mod.rs | 1 + .../input/slash_commands/mod_tests.rs | 9 +- app/src/tui_export.rs | 1 + crates/warp_tui/src/conversation_menu.rs | 111 +++------ .../warp_tui/src/conversation_menu_tests.rs | 8 +- crates/warp_tui/src/inline_menu.rs | 156 +++++++++++- crates/warp_tui/src/inline_menu_tests.rs | 42 +++- crates/warp_tui/src/input/view.rs | 7 +- crates/warp_tui/src/input/view_tests.rs | 54 +++- crates/warp_tui/src/lib.rs | 1 + crates/warp_tui/src/model_menu.rs | 232 ++++++++++++++++++ crates/warp_tui/src/model_menu_tests.rs | 41 ++++ crates/warp_tui/src/slash_commands.rs | 134 ++++------ crates/warp_tui/src/terminal_session_view.rs | 31 ++- 18 files changed, 815 insertions(+), 239 deletions(-) create mode 100644 crates/warp_tui/src/model_menu.rs create mode 100644 crates/warp_tui/src/model_menu_tests.rs diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index 1ec384294f8..a420233dfb3 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -816,12 +816,25 @@ impl LLMPreferences { &self, app: &AppContext, terminal_view_id: Option, + ) -> &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, ) -> &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); } diff --git a/app/src/ai/llms_tests.rs b/app/src/ai/llms_tests.rs index 6637a67a3cc..0e6aa3c9501 100644 --- a/app/src/ai/llms_tests.rs +++ b/app/src/ai/llms_tests.rs @@ -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; @@ -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(), @@ -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 { @@ -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| { diff --git a/app/src/terminal/input/models/data_source.rs b/app/src/terminal/input/models/data_source.rs index 6b1efd9b33e..bd609ea0731 100644 --- a/app/src/terminal/input/models/data_source.rs +++ b/app/src/terminal/input/models/data_source.rs @@ -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, + pub name_match_result: Option, + pub score: OrderedFloat, +} + +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, + query_text: &str, + app: &AppContext, +) -> Vec { + 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::>(); + results.sort_by_key(|choice| (choice.priority_tier(), choice.score)); + results +} pub struct ModelSelectorDataSource { terminal_view_id: EntityId, @@ -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(), + ) } } @@ -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); @@ -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) -> Self { - self.name_match_result = result; - self - } - - fn with_score(mut self, score: OrderedFloat) -> Self { - self.score = score; - self - } } impl SearchItem for ModelSearchItem { diff --git a/app/src/terminal/input/models/mod.rs b/app/src/terminal/input/models/mod.rs index 8adc710fc18..880e0212099 100644 --- a/app/src/terminal/input/models/mod.rs +++ b/app/src/terminal/input/models/mod.rs @@ -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}; diff --git a/app/src/terminal/input/slash_commands/mod.rs b/app/src/terminal/input/slash_commands/mod.rs index 131d60650f0..5e078597fd2 100644 --- a/app/src/terminal/input/slash_commands/mod.rs +++ b/app/src/terminal/input/slash_commands/mod.rs @@ -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( diff --git a/app/src/terminal/input/slash_commands/mod_tests.rs b/app/src/terminal/input/slash_commands/mod_tests.rs index d3a5c044b65..e37ab36b3cd 100644 --- a/app/src/terminal/input/slash_commands/mod_tests.rs +++ b/app/src/terminal/input/slash_commands/mod_tests.rs @@ -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 @@ -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; diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 5aac8145069..7531eac0a29 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -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, diff --git a/crates/warp_tui/src/conversation_menu.rs b/crates/warp_tui/src/conversation_menu.rs index fb2ad3a0369..7c965edf852 100644 --- a/crates/warp_tui/src/conversation_menu.rs +++ b/crates/warp_tui/src/conversation_menu.rs @@ -13,11 +13,10 @@ use warp::tui_export::{ HarnessFilter, }; use warp_editor::model::CoreEditorModel; -use warp_search_core::inline_menu::InlineMenuSelection; use warpui_core::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity, WindowId}; use crate::inline_menu::{ - keep_selected_visible, result_row_capacity, TuiInlineMenuHeader, TuiInlineMenuRow, + result_row_capacity, TuiInlineMenuHeader, TuiInlineMenuListState, TuiInlineMenuRow, TuiInlineMenuSnapshot, TuiInlineMenuStatus, MAX_INLINE_MENU_ROWS, }; @@ -34,10 +33,7 @@ enum TuiConversationMenuState { #[default] Closed, Open { - rows: Vec, - selection: InlineMenuSelection, - scroll_offset: usize, - is_loading: bool, + list: TuiInlineMenuListState, }, } @@ -97,12 +93,9 @@ impl TuiConversationMenuModel { if self.is_open() { return; } - self.state = TuiConversationMenuState::Open { - rows: Vec::new(), - selection: InlineMenuSelection::default(), - scroll_offset: 0, - is_loading: true, - }; + let mut list = TuiInlineMenuListState::default(); + list.set_loading(true); + self.state = TuiConversationMenuState::Open { list }; self.cloud_warning_shown = false; let window_id = self.window_id; let model_id = ctx.model_id(); @@ -124,35 +117,19 @@ impl TuiConversationMenuModel { /// Moves selection to the previous row and keeps it visible. pub(crate) fn select_previous(&mut self, ctx: &mut ModelContext) { - let TuiConversationMenuState::Open { - rows, - selection, - scroll_offset, - .. - } = &mut self.state - else { + let TuiConversationMenuState::Open { list } = &mut self.state else { return; }; - if let Some(index) = selection.select_previous(rows.len(), |_| true) { - keep_selected_visible(rows.len(), index, MAX_VISIBLE_ROWS, scroll_offset); - } + list.select_previous(MAX_VISIBLE_ROWS, |_| true); ctx.emit(TuiConversationMenuEvent::Updated); } /// Moves selection to the next row and keeps it visible. pub(crate) fn select_next(&mut self, ctx: &mut ModelContext) { - let TuiConversationMenuState::Open { - rows, - selection, - scroll_offset, - .. - } = &mut self.state - else { + let TuiConversationMenuState::Open { list } = &mut self.state else { return; }; - if let Some(index) = selection.select_next(rows.len(), |_| true) { - keep_selected_visible(rows.len(), index, MAX_VISIBLE_ROWS, scroll_offset); - } + list.select_next(MAX_VISIBLE_ROWS, |_| true); ctx.emit(TuiConversationMenuEvent::Updated); } @@ -162,12 +139,7 @@ impl TuiConversationMenuModel { _ctx: &mut ModelContext, ) -> Option { let selected_id = match &self.state { - TuiConversationMenuState::Open { - rows, selection, .. - } => selection - .selected_index() - .and_then(|index| rows.get(index)) - .map(|row| row.id), + TuiConversationMenuState::Open { list } => list.selected_row().map(|row| row.id), TuiConversationMenuState::Closed => None, }; selected_id @@ -175,17 +147,11 @@ impl TuiConversationMenuModel { /// Returns the render snapshot for the open menu. pub(crate) fn snapshot(&self) -> Option { - let TuiConversationMenuState::Open { - rows, - selection, - scroll_offset, - is_loading, - } = &self.state - else { + let TuiConversationMenuState::Open { list } = &self.state else { return None; }; - let status = if rows.is_empty() { - Some(if *is_loading { + let status = if list.rows().is_empty() { + Some(if list.is_loading() { TuiInlineMenuStatus::Loading("Loading conversations…".to_owned()) } else { TuiInlineMenuStatus::Empty("No conversations found".to_owned()) @@ -198,7 +164,8 @@ impl TuiConversationMenuModel { title: Some("Conversations".to_owned()), tabs: Vec::new(), }), - rows: rows + rows: list + .rows() .iter() .map(|row| TuiInlineMenuRow { title: row.title.clone(), @@ -206,8 +173,8 @@ impl TuiConversationMenuModel { is_selectable: true, }) .collect(), - selected_index: selection.selected_index(), - scroll_offset: *scroll_offset, + selected_index: list.selected_index(), + scroll_offset: list.scroll_offset(), max_visible_rows: MAX_VISIBLE_ROWS, status, }) @@ -226,19 +193,12 @@ impl TuiConversationMenuModel { /// Rebuilds rows from the current query while preserving stable selection. fn refresh_rows(&mut self, ctx: &mut ModelContext) { - let previous_id = match &self.state { - TuiConversationMenuState::Open { - rows, selection, .. - } => selection - .selected_index() - .and_then(|index| rows.get(index)) - .map(|row| row.id), + let (previous_id, previous_index) = match &self.state { + TuiConversationMenuState::Open { list } => { + (list.selected_row().map(|row| row.id), list.selected_index()) + } TuiConversationMenuState::Closed => return, }; - let previous_index = match &self.state { - TuiConversationMenuState::Open { selection, .. } => selection.selected_index(), - TuiConversationMenuState::Closed => None, - }; let conversations_model = AgentConversationsModel::as_ref(ctx); let is_loading = conversations_model.is_loading(); let cloud_metadata_load_failed = agent_conversations_cloud_metadata_load_failed(ctx); @@ -266,17 +226,13 @@ impl TuiConversationMenuModel { .collect() }; - let selection = reconcile_selection(&rows, previous_id, previous_index); - let mut scroll_offset = 0; - if let Some(index) = selection.selected_index() { - keep_selected_visible(rows.len(), index, MAX_VISIBLE_ROWS, &mut scroll_offset); - } - self.state = TuiConversationMenuState::Open { - rows, - selection, - scroll_offset, - is_loading, + let preferred_index = reconciled_selection_index(&rows, previous_id, previous_index); + let TuiConversationMenuState::Open { list } = &mut self.state else { + return; }; + list.replace_rows(rows, is_loading, preferred_index, MAX_VISIBLE_ROWS, |_| { + true + }); if cloud_metadata_load_failed && !self.cloud_warning_shown { self.cloud_warning_shown = true; ctx.emit(TuiConversationMenuEvent::CloudMetadataUnavailable); @@ -286,13 +242,12 @@ impl TuiConversationMenuModel { } /// Preserves selection by ID, falling back to the nearest valid index. -fn reconcile_selection( +fn reconciled_selection_index( rows: &[TuiConversationMenuRow], previous_id: Option, previous_index: Option, -) -> InlineMenuSelection { - let mut selection = InlineMenuSelection::default(); - let index = previous_id +) -> Option { + previous_id .and_then(|id| rows.iter().position(|row| row.id == id)) .or_else(|| { (!rows.is_empty()).then(|| { @@ -300,11 +255,7 @@ fn reconcile_selection( .unwrap_or(rows.len().saturating_sub(1)) .min(rows.len().saturating_sub(1)) }) - }); - if let Some(index) = index { - selection.select(index, rows.len(), |_| true); - } - selection + }) } impl Entity for TuiConversationMenuModel { diff --git a/crates/warp_tui/src/conversation_menu_tests.rs b/crates/warp_tui/src/conversation_menu_tests.rs index 5e938b5b3e2..65ae73c8964 100644 --- a/crates/warp_tui/src/conversation_menu_tests.rs +++ b/crates/warp_tui/src/conversation_menu_tests.rs @@ -19,10 +19,10 @@ fn selection_reconciliation_preserves_id_then_uses_nearest_index() { }, ]; - let preserved = reconcile_selection(&rows, Some(rows[1].id), Some(0)); - assert_eq!(preserved.selected_index(), Some(1)); + let preserved = reconciled_selection_index(&rows, Some(rows[1].id), Some(0)); + assert_eq!(preserved, Some(1)); let missing = AgentConversationEntryId::Conversation(AIConversationId::new()); - let nearest = reconcile_selection(&rows[..2], Some(missing), Some(2)); - assert_eq!(nearest.selected_index(), Some(1)); + let nearest = reconciled_selection_index(&rows[..2], Some(missing), Some(2)); + assert_eq!(nearest, Some(1)); } diff --git a/crates/warp_tui/src/inline_menu.rs b/crates/warp_tui/src/inline_menu.rs index 4b16555168e..a59dd52a896 100644 --- a/crates/warp_tui/src/inline_menu.rs +++ b/crates/warp_tui/src/inline_menu.rs @@ -1,7 +1,8 @@ //! Reusable active-menu routing and character-cell presentation for TUI inline menus. use std::rc::Rc; -use warp::tui_export::{AcceptSlashCommandOrSavedPrompt, AgentConversationEntryId}; +use warp::tui_export::{AcceptSlashCommandOrSavedPrompt, AgentConversationEntryId, LLMId}; +use warp_search_core::inline_menu::{InlineMenuResultsUpdate, InlineMenuSelection}; use warpui_core::elements::tui::{ TuiBuffer, TuiConstraint, TuiContainer, TuiElement, TuiFlex, TuiLayoutContext, TuiPaintContext, TuiRect, TuiSize, TuiText, @@ -10,6 +11,7 @@ use warpui_core::elements::CrossAxisAlignment; use warpui_core::{AppContext, ModelHandle}; use crate::conversation_menu::TuiConversationMenuModel; +use crate::model_menu::TuiModelMenuModel; use crate::slash_commands::TuiSlashCommandModel; use crate::tui_builder::TuiUiBuilder; @@ -55,12 +57,136 @@ pub(crate) struct TuiInlineMenuSnapshot { pub(crate) max_visible_rows: usize, pub(crate) status: Option, } +/// Reusable list mechanics shared by the slash-command, conversation, and model menus. +#[derive(Debug, Clone)] +pub(crate) struct TuiInlineMenuListState { + rows: Vec, + selection: InlineMenuSelection, + is_loading: bool, + scroll_offset: usize, +} + +impl Default for TuiInlineMenuListState { + fn default() -> Self { + Self { + rows: Vec::new(), + selection: InlineMenuSelection::default(), + is_loading: false, + scroll_offset: 0, + } + } +} + +impl TuiInlineMenuListState { + pub(crate) fn rows(&self) -> &[Row] { + &self.rows + } + + pub(crate) fn is_loading(&self) -> bool { + self.is_loading + } + + pub(crate) fn set_loading(&mut self, is_loading: bool) { + self.is_loading = is_loading; + } + + pub(crate) fn selected_index(&self) -> Option { + self.selection.selected_index() + } + + pub(crate) fn selected_row(&self) -> Option<&Row> { + self.selected_index().and_then(|index| self.rows.get(index)) + } + + pub(crate) fn scroll_offset(&self) -> usize { + self.scroll_offset + } + + /// Replaces the current rows and applies a caller-selected preferred row. + pub(crate) fn replace_rows( + &mut self, + rows: Vec, + is_loading: bool, + preferred_index: Option, + max_visible_rows: usize, + mut is_selectable: impl FnMut(&Row) -> bool, + ) { + self.rows = rows; + self.is_loading = is_loading; + self.selection.clear(); + if let Some(index) = preferred_index { + self.selection.select(index, self.rows.len(), |index| { + self.rows.get(index).is_some_and(&mut is_selectable) + }); + } + self.keep_selected_visible(max_visible_rows); + } + + /// Reconciles mixer-ordered rows, preserving the previous results while loading. + pub(crate) fn reconcile_mixer_rows( + &mut self, + rows: Vec, + is_loading: bool, + max_visible_rows: usize, + mut is_selectable: impl FnMut(&Row) -> bool, + ) -> InlineMenuResultsUpdate { + self.is_loading = is_loading; + let update = self + .selection + .reconcile_results(is_loading, rows.len(), |index| { + rows.get(index).is_some_and(&mut is_selectable) + }); + if !matches!(update, InlineMenuResultsUpdate::Loading) { + self.rows = rows; + self.keep_selected_visible(max_visible_rows); + } + update + } + + pub(crate) fn select_next( + &mut self, + max_visible_rows: usize, + mut is_selectable: impl FnMut(&Row) -> bool, + ) { + self.selection.select_next(self.rows.len(), |index| { + self.rows.get(index).is_some_and(&mut is_selectable) + }); + self.keep_selected_visible(max_visible_rows); + } + + pub(crate) fn select_previous( + &mut self, + max_visible_rows: usize, + mut is_selectable: impl FnMut(&Row) -> bool, + ) { + self.selection.select_previous(self.rows.len(), |index| { + self.rows.get(index).is_some_and(&mut is_selectable) + }); + self.keep_selected_visible(max_visible_rows); + } + + fn keep_selected_visible(&mut self, max_visible_rows: usize) { + if let Some(selected_index) = self.selection.selected_index() { + keep_selected_visible( + self.rows.len(), + selected_index, + max_visible_rows, + &mut self.scroll_offset, + ); + } else { + self.scroll_offset = self + .scroll_offset + .min(self.rows.len().saturating_sub(max_visible_rows)); + } + } +} /// Domain action produced by accepting the selected item in an active menu. #[derive(Debug, Clone)] pub(crate) enum TuiInlineMenuAccepted { SlashCommand(AcceptSlashCommandOrSavedPrompt), Conversation(AgentConversationEntryId), + Model(LLMId), } /// Type-erased operations shared by TUI inline-menu model handles. @@ -172,6 +298,34 @@ impl TuiInlineMenuHandle for ModelHandle { } } +impl TuiInlineMenuHandle for ModelHandle { + fn is_open(&self, ctx: &AppContext) -> bool { + self.as_ref(ctx).is_open() + } + + fn select_previous(&self, ctx: &mut AppContext) { + self.update(ctx, |model, ctx| model.select_previous(ctx)); + } + + fn select_next(&self, ctx: &mut AppContext) { + self.update(ctx, |model, ctx| model.select_next(ctx)); + } + + fn accept(&self, ctx: &mut AppContext) -> Option { + self.as_ref(ctx) + .accept_selected() + .map(TuiInlineMenuAccepted::Model) + } + + fn dismiss(&self, ctx: &mut AppContext) { + self.update(ctx, |model, ctx| model.dismiss(ctx)); + } + + fn snapshot(&self, ctx: &AppContext) -> Option { + self.as_ref(ctx).snapshot() + } +} + pub(crate) fn render_inline_menu( snapshot: &TuiInlineMenuSnapshot, builder: &TuiUiBuilder, diff --git a/crates/warp_tui/src/inline_menu_tests.rs b/crates/warp_tui/src/inline_menu_tests.rs index c8a743cb7c6..5265e86e584 100644 --- a/crates/warp_tui/src/inline_menu_tests.rs +++ b/crates/warp_tui/src/inline_menu_tests.rs @@ -4,8 +4,8 @@ use warpui_core::presenter::tui::TuiPresenter; use warpui_core::App; use super::{ - render_inline_menu, TuiInlineMenuHeader, TuiInlineMenuRow, TuiInlineMenuSnapshot, - TuiInlineMenuStatus, TuiInlineMenuTab, + render_inline_menu, TuiInlineMenuHeader, TuiInlineMenuListState, TuiInlineMenuRow, + TuiInlineMenuSnapshot, TuiInlineMenuStatus, TuiInlineMenuTab, }; use crate::tui_builder::TuiUiBuilder; @@ -160,3 +160,41 @@ fn conversation_like_snapshot_keeps_selection_visible_within_production_height() assert!(rendered.contains("Conversation 2")); assert!(rendered.contains("Conversation 7")); } + +#[test] +fn shared_list_navigation_wraps_skips_disabled_rows_and_scrolls() { + let mut list = TuiInlineMenuListState::default(); + list.replace_rows(vec![true, false, true, true], false, Some(0), 2, |row| *row); + + list.select_next(2, |row| *row); + assert_eq!(list.selected_index(), Some(2)); + assert_eq!(list.scroll_offset(), 1); + + list.select_next(2, |row| *row); + assert_eq!(list.selected_index(), Some(3)); + assert_eq!(list.scroll_offset(), 2); + + list.select_next(2, |row| *row); + assert_eq!(list.selected_index(), Some(0)); + assert_eq!(list.scroll_offset(), 0); + + list.select_previous(2, |row| *row); + assert_eq!(list.selected_index(), Some(3)); + assert_eq!(list.scroll_offset(), 2); +} + +#[test] +fn shared_list_preserves_ready_rows_while_a_mixer_query_loads() { + let mut list = TuiInlineMenuListState::default(); + list.replace_rows(vec!["ready"], false, Some(0), 2, |_| true); + + let update = list.reconcile_mixer_rows(vec!["pending"], true, 2, |_| true); + + assert_eq!( + update, + warp_search_core::inline_menu::InlineMenuResultsUpdate::Loading + ); + assert_eq!(list.rows(), &["ready"]); + assert_eq!(list.selected_index(), Some(0)); + assert!(list.is_loading()); +} diff --git a/crates/warp_tui/src/input/view.rs b/crates/warp_tui/src/input/view.rs index e06cae84f3a..fbe6e7a9399 100644 --- a/crates/warp_tui/src/input/view.rs +++ b/crates/warp_tui/src/input/view.rs @@ -26,7 +26,7 @@ use std::ops::Range; use string_offset::CharOffset; use warp::editor::{CodeEditorModel, CodeEditorModelEvent}; use warp::tui_export::{ - AcceptSlashCommandOrSavedPrompt, BlocklistAIInputModel, InputTypeAutoDetectionSource, + AcceptSlashCommandOrSavedPrompt, BlocklistAIInputModel, InputTypeAutoDetectionSource, LLMId, }; use warp_editor::model::{CoreEditorModel, PlainTextEditorModel}; use warp_editor::selection::TextUnit; @@ -467,6 +467,8 @@ pub enum TuiInputViewEvent { AcceptedSlashCommand(AcceptSlashCommandOrSavedPrompt), /// The user selected a conversation menu item. AcceptedConversation(warp::tui_export::AgentConversationEntryId), + /// The user selected a model menu item. + AcceptedModel(LLMId), } // ───────────────────────────────────────────────────────────────────────────── @@ -1101,6 +1103,9 @@ impl TuiInputView { TuiInlineMenuAccepted::Conversation(entry_id) => { ctx.emit(TuiInputViewEvent::AcceptedConversation(entry_id)); } + TuiInlineMenuAccepted::Model(id) => { + ctx.emit(TuiInputViewEvent::AcceptedModel(id)); + } } } } diff --git a/crates/warp_tui/src/input/view_tests.rs b/crates/warp_tui/src/input/view_tests.rs index 383570cb37c..067db6bb372 100644 --- a/crates/warp_tui/src/input/view_tests.rs +++ b/crates/warp_tui/src/input/view_tests.rs @@ -10,7 +10,8 @@ use string_offset::CharOffset; use warp::appearance::Appearance; use warp::editor::CodeEditorModel; use warp::tui_export::{ - AcceptSlashCommandOrSavedPrompt, BlocklistAIInputModel, SlashCommandId, SlashCommandMixer, + AcceptSlashCommandOrSavedPrompt, BlocklistAIInputModel, LLMId, SlashCommandId, + SlashCommandMixer, }; use warp_editor::model::CoreEditorModel; use warpui::EntityIdMap; @@ -32,6 +33,7 @@ use super::{ use crate::editor_element::TuiEditorElement; use crate::inline_menu::TuiInlineMenu; use crate::input_mode_policy::TuiInputModePolicy; +use crate::model_menu::TuiModelMenuModel; use crate::slash_commands::{TuiSlashCommandModel, TuiSlashCommandRow}; use crate::test_fixtures::add_test_semantic_selection; @@ -102,6 +104,33 @@ fn build_view_with_inline_menu( (view, menu_model, ids) } +fn build_view_with_model_menu( + ctx: &mut AppContext, +) -> ( + ViewHandle, + ModelHandle, + LLMId, +) { + ctx.add_singleton_model(|_| Appearance::mock()); + add_test_semantic_selection(ctx); + let input_model = ctx.add_model(|ctx| CodeEditorModel::new_tui(W, ctx)); + let input_mode = BlocklistAIInputModel::mock(Rc::new(TuiInputModePolicy), ctx); + let id = LLMId::from("gpt-5"); + let id_for_model = id.clone(); + let menu_model = ctx.add_model(|_| { + TuiModelMenuModel::new_for_test(input_model.clone(), vec![(id_for_model, true)], 0) + }); + let inline_menu = TuiInlineMenu::new(menu_model.clone()); + let (_window_id, view) = ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + move |ctx| TuiInputView::new(input_model, input_mode, vec![inline_menu], ctx), + ); + (view, menu_model, id) +} + fn selected_slash_command_id( menu_model: &ModelHandle, ctx: &AppContext, @@ -159,6 +188,29 @@ fn inline_menu_accept_dismisses_before_emitting_unchanged_payload() { }); } +#[test] +fn model_menu_accept_emits_selected_id_and_stays_open_for_persistence() { + App::test((), |mut app| async move { + let (view, menu_model, id, accepted) = app.update(|ctx| { + let (view, menu_model, id) = build_view_with_model_menu(ctx); + let accepted = Rc::new(RefCell::new(Vec::new())); + let accepted_for_subscription = accepted.clone(); + ctx.subscribe_to_view(&view, move |_, event, _| { + if let TuiInputViewEvent::AcceptedModel(id) = event { + accepted_for_subscription.borrow_mut().push(id.clone()); + } + }); + (view, menu_model, id, accepted) + }); + + app.update(|ctx| dispatch(&view, ctx, &[TuiInputAction::Submit])); + app.read(|ctx| { + assert_eq!(accepted.borrow().as_slice(), &[id]); + assert!(menu_model.as_ref(ctx).is_open()); + }); + }); +} + #[test] fn escape_dismisses_menu_and_closed_menu_submit_falls_through() { App::test((), |mut app| async move { diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 5a03a82fa36..9bd892571c8 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -25,6 +25,7 @@ mod exit_confirmation; mod inline_menu; mod input_mode_policy; mod keybindings; +mod model_menu; mod resume; mod slash_commands; mod terminal_background; diff --git a/crates/warp_tui/src/model_menu.rs b/crates/warp_tui/src/model_menu.rs new file mode 100644 index 00000000000..a567b1362db --- /dev/null +++ b/crates/warp_tui/src/model_menu.rs @@ -0,0 +1,232 @@ +//! Searchable TUI model picker state. + +use warp::editor::{CodeEditorModel, CodeEditorModelEvent}; +use warp::tui_export::{query_model_picker_choices, LLMId, LLMPreferences, LLMPreferencesEvent}; +use warp_editor::model::CoreEditorModel; +use warpui_core::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity}; + +use crate::inline_menu::{ + result_row_capacity, TuiInlineMenuHeader, TuiInlineMenuListState, TuiInlineMenuRow, + TuiInlineMenuSnapshot, TuiInlineMenuStatus, MAX_INLINE_MENU_ROWS, +}; + +const MAX_VISIBLE_ROWS: usize = result_row_capacity(MAX_INLINE_MENU_ROWS, true, false); + +#[derive(Debug, Clone)] +struct TuiModelMenuRow { + id: LLMId, + title: String, + is_selectable: bool, +} + +#[derive(Debug, Clone, Default)] +enum TuiModelMenuState { + #[default] + Closed, + Open { + list: TuiInlineMenuListState, + }, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct TuiModelMenuEvent; + +pub(crate) struct TuiModelMenuModel { + input_editor: ModelHandle, + state: TuiModelMenuState, +} + +impl TuiModelMenuModel { + pub(crate) fn new( + input_editor: ModelHandle, + ctx: &mut ModelContext, + ) -> Self { + ctx.subscribe_to_model(&input_editor, |model, _, event, ctx| { + if model.is_open() && matches!(event, CodeEditorModelEvent::ContentChanged { .. }) { + model.refresh_rows(ctx); + } + }); + ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |model, _, event, ctx| { + if model.is_open() + && matches!( + event, + LLMPreferencesEvent::UpdatedAvailableLLMs + | LLMPreferencesEvent::UpdatedActiveAgentModeLLM + ) + { + model.refresh_rows(ctx); + } + }); + Self { + input_editor, + state: TuiModelMenuState::Closed, + } + } + + #[cfg(test)] + pub(crate) fn new_for_test( + input_editor: ModelHandle, + rows: Vec<(LLMId, bool)>, + selected_index: usize, + ) -> Self { + let mut list = TuiInlineMenuListState::default(); + list.replace_rows( + rows.into_iter() + .map(|(id, is_selectable)| TuiModelMenuRow { + title: id.to_string(), + id, + is_selectable, + }) + .collect(), + false, + Some(selected_index), + MAX_VISIBLE_ROWS, + |row| row.is_selectable, + ); + Self { + input_editor, + state: TuiModelMenuState::Open { list }, + } + } + + pub(crate) fn is_open(&self) -> bool { + matches!(self.state, TuiModelMenuState::Open { .. }) + } + + pub(crate) fn open(&mut self, ctx: &mut ModelContext) { + if self.is_open() { + return; + } + self.state = TuiModelMenuState::Open { + list: TuiInlineMenuListState::default(), + }; + self.refresh_rows(ctx); + } + + pub(crate) fn dismiss(&mut self, ctx: &mut ModelContext) { + if !self.is_open() { + return; + } + self.state = TuiModelMenuState::Closed; + self.input_editor + .update(ctx, |editor, ctx| editor.clear_buffer(ctx)); + ctx.emit(TuiModelMenuEvent); + } + + pub(crate) fn select_previous(&mut self, ctx: &mut ModelContext) { + let TuiModelMenuState::Open { list } = &mut self.state else { + return; + }; + list.select_previous(MAX_VISIBLE_ROWS, |row| row.is_selectable); + ctx.emit(TuiModelMenuEvent); + } + + pub(crate) fn select_next(&mut self, ctx: &mut ModelContext) { + let TuiModelMenuState::Open { list } = &mut self.state else { + return; + }; + list.select_next(MAX_VISIBLE_ROWS, |row| row.is_selectable); + ctx.emit(TuiModelMenuEvent); + } + + pub(crate) fn accept_selected(&self) -> Option { + let TuiModelMenuState::Open { list } = &self.state else { + return None; + }; + list.selected_row().map(|row| row.id.clone()) + } + + pub(crate) fn snapshot(&self) -> Option { + let TuiModelMenuState::Open { list } = &self.state else { + return None; + }; + Some(TuiInlineMenuSnapshot { + header: Some(TuiInlineMenuHeader { + title: Some("Models".to_owned()), + tabs: Vec::new(), + }), + rows: list + .rows() + .iter() + .map(|row| TuiInlineMenuRow { + title: row.title.clone(), + description: (!row.is_selectable).then(|| "disabled".to_owned()), + is_selectable: row.is_selectable, + }) + .collect(), + selected_index: list.selected_index(), + scroll_offset: list.scroll_offset(), + max_visible_rows: MAX_VISIBLE_ROWS, + status: list + .rows() + .is_empty() + .then(|| TuiInlineMenuStatus::Empty("No models found".to_owned())), + }) + } + + fn refresh_rows(&mut self, ctx: &mut ModelContext) { + if !self.is_open() { + return; + } + let query = input_text(&self.input_editor, ctx); + let preferences = LLMPreferences::as_ref(ctx); + let active_id = preferences.get_active_base_model(ctx, None).id.clone(); + let choices = query_model_picker_choices( + preferences, + preferences.get_base_llm_choices_for_agent_mode(ctx), + &query, + ctx, + ); + let rows = choices + .into_iter() + .map(|choice| { + let is_selectable = choice.is_selectable(); + TuiModelMenuRow { + id: choice.llm.id, + title: choice.llm.display_name, + is_selectable, + } + }) + .collect::>(); + let preferred_index = preferred_selection_index(&rows, &active_id, query.trim().is_empty()); + let TuiModelMenuState::Open { list } = &mut self.state else { + return; + }; + list.replace_rows(rows, false, preferred_index, MAX_VISIBLE_ROWS, |row| { + row.is_selectable + }); + ctx.emit(TuiModelMenuEvent); + } +} + +fn preferred_selection_index( + rows: &[TuiModelMenuRow], + active_id: &LLMId, + query_is_empty: bool, +) -> Option { + if query_is_empty { + rows.iter() + .position(|row| row.id == *active_id && row.is_selectable) + .or_else(|| rows.iter().rposition(|row| row.is_selectable)) + } else { + rows.iter().rposition(|row| row.is_selectable) + } +} + +fn input_text(editor: &ModelHandle, app: &AppContext) -> String { + let model = editor.as_ref(app); + let buffer = model.content().as_ref(app); + if buffer.is_empty() { + String::new() + } else { + buffer.text().into_string() + } +} + +impl Entity for TuiModelMenuModel { + type Event = TuiModelMenuEvent; +} + +#[cfg(test)] +#[path = "model_menu_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/model_menu_tests.rs b/crates/warp_tui/src/model_menu_tests.rs new file mode 100644 index 00000000000..aaf1b7d69e8 --- /dev/null +++ b/crates/warp_tui/src/model_menu_tests.rs @@ -0,0 +1,41 @@ +use super::*; + +fn row(id: &str, is_selectable: bool) -> TuiModelMenuRow { + TuiModelMenuRow { + id: id.into(), + title: id.to_owned(), + is_selectable, + } +} + +#[test] +fn empty_query_prefers_active_model_and_filtered_query_prefers_best_match() { + let rows = vec![row("auto", true), row("gpt-4", true), row("gpt-5", true)]; + + assert_eq!( + preferred_selection_index(&rows, &LLMId::from("gpt-4"), true), + Some(1) + ); + assert_eq!( + preferred_selection_index(&rows, &LLMId::from("gpt-4"), false), + Some(2) + ); +} + +#[test] +fn model_selection_skips_disabled_rows() { + let rows = vec![ + row("auto", true), + row("gpt-5", true), + row("disabled", false), + ]; + + assert_eq!( + preferred_selection_index(&rows, &LLMId::from("disabled"), true), + Some(1) + ); + assert_eq!( + preferred_selection_index(&rows, &LLMId::from("auto"), false), + Some(1) + ); +} diff --git a/crates/warp_tui/src/slash_commands.rs b/crates/warp_tui/src/slash_commands.rs index c6057689876..00663d8951a 100644 --- a/crates/warp_tui/src/slash_commands.rs +++ b/crates/warp_tui/src/slash_commands.rs @@ -14,13 +14,11 @@ use warp::tui_export::{ SlashCommandMixer, TuiSlashCommandDataSource, UpdatedActiveCommands, }; use warp_editor::model::CoreEditorModel; -use warp_search_core::inline_menu::{ - InlineMenuResultsUpdate, InlineMenuSelection, InputDrivenInlineMenuLifecycle, -}; +use warp_search_core::inline_menu::{InlineMenuResultsUpdate, InputDrivenInlineMenuLifecycle}; use warpui_core::{AppContext, Entity, ModelContext, ModelHandle}; use crate::inline_menu::{ - keep_selected_visible, result_row_capacity, TuiInlineMenuRow, TuiInlineMenuSnapshot, + result_row_capacity, TuiInlineMenuListState, TuiInlineMenuRow, TuiInlineMenuSnapshot, TuiInlineMenuStatus, MAX_INLINE_MENU_ROWS, }; @@ -41,10 +39,7 @@ pub(crate) enum TuiSlashCommandState { Closed, Open { query: String, - rows: Vec, - selection: InlineMenuSelection, - scroll_offset: usize, - is_loading: bool, + list: TuiInlineMenuListState, }, } @@ -103,18 +98,17 @@ impl TuiSlashCommandModel { rows: Vec, selected_index: usize, ) -> Self { - let mut selection = InlineMenuSelection::default(); - selection.select(selected_index, rows.len(), |_| true); + let mut list = TuiInlineMenuListState::default(); + list.replace_rows(rows, false, Some(selected_index), MAX_VISIBLE_ROWS, |_| { + true + }); Self { input_editor, slash_commands_source: None, mixer, state: TuiSlashCommandState::Open { query: String::new(), - rows, - selection, - scroll_offset: 0, - is_loading: false, + list, }, lifecycle: InputDrivenInlineMenuLifecycle::default(), } @@ -132,47 +126,25 @@ impl TuiSlashCommandModel { } pub(crate) fn selected_action(&self) -> Option { - let TuiSlashCommandState::Open { - rows, selection, .. - } = &self.state - else { + let TuiSlashCommandState::Open { list, .. } = &self.state else { return None; }; - selection - .selected_index() - .and_then(|index| rows.get(index)) - .map(|row| row.action.clone()) + list.selected_row().map(|row| row.action.clone()) } pub(crate) fn select_previous(&mut self, ctx: &mut ModelContext) { - let TuiSlashCommandState::Open { - rows, - selection, - scroll_offset, - .. - } = &mut self.state - else { + let TuiSlashCommandState::Open { list, .. } = &mut self.state else { return; }; - if let Some(selected_index) = selection.select_previous(rows.len(), |_| true) { - keep_selected_visible(rows.len(), selected_index, MAX_VISIBLE_ROWS, scroll_offset); - } + list.select_previous(MAX_VISIBLE_ROWS, |_| true); ctx.emit(TuiSlashCommandModelEvent); } pub(crate) fn select_next(&mut self, ctx: &mut ModelContext) { - let TuiSlashCommandState::Open { - rows, - selection, - scroll_offset, - .. - } = &mut self.state - else { + let TuiSlashCommandState::Open { list, .. } = &mut self.state else { return; }; - if let Some(selected_index) = selection.select_next(rows.len(), |_| true) { - keep_selected_visible(rows.len(), selected_index, MAX_VISIBLE_ROWS, scroll_offset); - } + list.select_next(MAX_VISIBLE_ROWS, |_| true); ctx.emit(TuiSlashCommandModelEvent); } @@ -195,18 +167,11 @@ impl TuiSlashCommandModel { } pub(crate) fn snapshot(&self) -> Option { - let TuiSlashCommandState::Open { - rows, - selection, - scroll_offset, - is_loading, - .. - } = &self.state - else { + let TuiSlashCommandState::Open { list, .. } = &self.state else { return None; }; - let status = if rows.is_empty() { - Some(if *is_loading { + let status = if list.rows().is_empty() { + Some(if list.is_loading() { TuiInlineMenuStatus::Loading("Loading slash commands…".to_owned()) } else { TuiInlineMenuStatus::Empty("No slash commands found".to_owned()) @@ -216,7 +181,8 @@ impl TuiSlashCommandModel { }; Some(TuiInlineMenuSnapshot { header: None, - rows: rows + rows: list + .rows() .iter() .map(|row| TuiInlineMenuRow { title: row.title.clone(), @@ -224,8 +190,8 @@ impl TuiSlashCommandModel { is_selectable: true, }) .collect(), - selected_index: selection.selected_index(), - scroll_offset: *scroll_offset, + selected_index: list.selected_index(), + scroll_offset: list.scroll_offset(), max_visible_rows: MAX_VISIBLE_ROWS, status, }) @@ -255,22 +221,23 @@ impl TuiSlashCommandModel { } fn run_query(&mut self, query: String, force: bool, ctx: &mut ModelContext) { - let (previous_rows, previous_selection, previous_scroll_offset) = match &self.state { - TuiSlashCommandState::Closed => (Vec::new(), InlineMenuSelection::default(), 0), + match &mut self.state { + TuiSlashCommandState::Closed => { + let mut list = TuiInlineMenuListState::default(); + list.set_loading(true); + self.state = TuiSlashCommandState::Open { + query: query.clone(), + list, + }; + } TuiSlashCommandState::Open { - rows, - selection, - scroll_offset, - .. - } => (rows.clone(), *selection, *scroll_offset), - }; - self.state = TuiSlashCommandState::Open { - query: query.clone(), - rows: previous_rows, - selection: previous_selection, - scroll_offset: previous_scroll_offset, - is_loading: true, - }; + query: current_query, + list, + } => { + *current_query = query.clone(); + list.set_loading(true); + } + } self.mixer.update(ctx, |mixer, ctx| { if !force && mixer.current_query().is_some_and(|q| q.text == query) { return; @@ -289,37 +256,18 @@ impl TuiSlashCommandModel { ) }; let results_update = { - let TuiSlashCommandState::Open { - selection, - is_loading, - .. - } = &mut self.state - else { + let TuiSlashCommandState::Open { list, .. } = &mut self.state else { return; }; - *is_loading = mixer_is_loading; - selection.reconcile_results(mixer_is_loading, new_rows.len(), |_| true) + list.reconcile_mixer_rows(new_rows, mixer_is_loading, MAX_VISIBLE_ROWS, |_| true) }; - let selected_index = match results_update { + match results_update { InlineMenuResultsUpdate::Loading => return, InlineMenuResultsUpdate::Empty => { self.close(ctx); return; } - InlineMenuResultsUpdate::Ready { selected_index } => selected_index, - }; - - let TuiSlashCommandState::Open { - scroll_offset, - rows, - .. - } = &mut self.state - else { - return; - }; - *rows = new_rows; - if let Some(selected_index) = selected_index { - keep_selected_visible(rows.len(), selected_index, MAX_VISIBLE_ROWS, scroll_offset); + InlineMenuResultsUpdate::Ready { .. } => {} } ctx.emit(TuiSlashCommandModelEvent); } diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index e8f6471d773..8b20c87d0e3 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -21,7 +21,7 @@ use warp::tui_export::{ CLISubagentEvent, CancellationReason, ChangelogModel, ChangelogModelEvent, ChangelogRequestType, CloudConversationData, CommandExecutionSource, ConversationSelection, ConversationSelectionHandle, ConversationUsageTotals, ExecuteCommandEvent, - GetRelevantFilesController, GitRepoModels, GitRepoStatusModel, GitStatusMetadata, + GetRelevantFilesController, GitRepoModels, GitRepoStatusModel, GitStatusMetadata, LLMId, LLMPreferences, LLMPreferencesEvent, ModelEvent, ParsedSlashCommandInput, PtyIntent, PtyIntentEvent, RepoDetectionSessionType, RepoDetectionSource, ServerConversationToken, ShellCommandExecutorEvent, SkillReference, SlashCommandDataSource as _, @@ -54,6 +54,7 @@ use crate::inline_menu::{TuiInlineMenu, MAX_INLINE_MENU_ROWS}; use crate::input::{TuiInputView, TuiInputViewEvent}; use crate::input_mode_policy::{self, TuiInputModePolicy}; use crate::keybindings::TUI_BINDING_GROUP; +use crate::model_menu::{TuiModelMenuEvent, TuiModelMenuModel}; use crate::resume::TuiExitSummaryHandle; use crate::slash_commands::TuiSlashCommandModel; use crate::transcript_view::{TuiTranscriptView, TuiTranscriptViewEvent}; @@ -104,6 +105,7 @@ const SWITCH_CONVERSATION_RUNNING_HINT: &str = const SWITCH_LOADING_HINT: &str = "Another conversation is already loading."; const SWITCH_UNAVAILABLE_HINT: &str = "That conversation is no longer available."; const LOADING_CONVERSATION_HINT: &str = "Loading conversation…"; +const MODEL_PERSISTENCE_FAILED_HINT: &str = "Could not save the selected model."; /// Footer hint shown while the input is in `!` shell mode. const SHELL_MODE_HINT: &str = "shell mode · esc to exit"; @@ -185,6 +187,7 @@ pub(crate) struct TuiTerminalSessionView { input_view: ViewHandle, inline_menus: Vec, conversation_menu: ModelHandle, + model_menu: ModelHandle, slash_commands_source: ModelHandle, conversation_selection: ConversationSelectionHandle, ai_action_model: ModelHandle, @@ -386,6 +389,11 @@ impl TuiTerminalSessionView { ); } }); + let model_menu = + ctx.add_model(|ctx| TuiModelMenuModel::new(input_editor_model.clone(), ctx)); + ctx.subscribe_to_model(&model_menu, |_, _, _: &TuiModelMenuEvent, ctx| { + ctx.notify(); + }); // Typing after a ctrl-c press disarms the pending exit confirmation. // The ctrl-c buffer clear leaves the buffer empty, so the window it // arms survives its own clear. @@ -427,6 +435,7 @@ impl TuiTerminalSessionView { let inline_menus = vec![ TuiInlineMenu::new(slash_commands.clone()), TuiInlineMenu::new(conversation_menu.clone()), + TuiInlineMenu::new(model_menu.clone()), ]; let inline_menus_for_input = inline_menus.clone(); let input_view = ctx.add_typed_action_tui_view(move |ctx| { @@ -457,6 +466,9 @@ impl TuiTerminalSessionView { TuiInputViewEvent::AcceptedConversation(entry_id) => { view.handle_accepted_conversation(*entry_id, ctx); } + TuiInputViewEvent::AcceptedModel(id) => { + view.handle_accepted_model(id, ctx); + } }); // The input box border color and the footer's shell-mode hint depend // on the input mode. @@ -624,6 +636,7 @@ impl TuiTerminalSessionView { input_view, inline_menus, conversation_menu, + model_menu, slash_commands_source, conversation_selection, ai_action_model: action_model, @@ -1432,6 +1445,18 @@ impl TuiTerminalSessionView { self.restore_conversation(target, TuiConversationRestoreOrigin::ConversationList, ctx); } + fn handle_accepted_model(&mut self, id: &LLMId, ctx: &mut ViewContext) { + let result = AISettings::handle(ctx).update(ctx, |settings, ctx| { + settings.agent_model.set_value(id.as_str().to_owned(), ctx) + }); + if let Err(error) = result { + report_error!(error.context("Failed to persist the TUI agent model")); + self.show_transient_hint(MODEL_PERSISTENCE_FAILED_HINT.to_owned(), ctx); + return; + } + self.model_menu.update(ctx, |menu, ctx| menu.dismiss(ctx)); + } + fn select_tui_slash_command(&mut self, command: &StaticCommand, ctx: &mut ViewContext) { match slash_command_selection_behavior(command) { SlashCommandSelectionBehavior::InsertCommandText(text) => { @@ -1481,6 +1506,10 @@ impl TuiTerminalSessionView { self.conversation_menu .update(ctx, |menu, ctx| menu.open(ctx)); record_static_slash_command_accepted(command.name, true, ctx); + } else if command.name == slash_commands::MODEL.name { + self.input_view.update(ctx, |input, ctx| input.clear(ctx)); + self.model_menu.update(ctx, |menu, ctx| menu.open(ctx)); + record_static_slash_command_accepted(command.name, true, ctx); } else if slash_command_is_submitted_as_prompt(command) { self.input_view.update(ctx, |input, ctx| input.clear(ctx)); let prompt = argument