diff --git a/app/src/completer/completion_source.rs b/app/src/completer/completion_source.rs new file mode 100644 index 00000000000..2152101da62 --- /dev/null +++ b/app/src/completer/completion_source.rs @@ -0,0 +1,134 @@ +use std::collections::HashMap; +use std::future::Future; + +use warp_completer::completer::{ + self, CompleterOptions, CompletionContext, CompletionsFallbackStrategy, MatchStrategy, + SuggestionResults, +}; +use warp_core::features::FeatureFlag; +use warp_core::user_preferences::GetUserPreferences; +use warpui::AppContext; + +use crate::terminal::model::completions::ShellCompletion; +use crate::terminal::model::session::Session; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CompletionSourcePolicy { + use_native_shell_completions: bool, + force_native_shell_completions: bool, +} + +impl CompletionSourcePolicy { + pub fn for_session(session: &Session, buffer_text: &str, ctx: &AppContext) -> Self { + let force_native_shell_completions = ctx + .private_user_preferences() + .read_value("ForceNativeShellCompletions") + .ok() + .flatten() + .and_then(|value| value.parse().ok()) + .unwrap_or(false); + Self::from_inputs( + FeatureFlag::NativeShellCompletions.is_enabled(), + force_native_shell_completions, + session.shell().supports_native_shell_completions(), + buffer_text.contains('\n'), + ) + } + + fn from_inputs( + native_shell_completions_enabled: bool, + force_native_shell_completions: bool, + shell_supports_native_completions: bool, + is_multiline: bool, + ) -> Self { + Self { + use_native_shell_completions: (native_shell_completions_enabled + || force_native_shell_completions) + && shell_supports_native_completions + && !is_multiline, + force_native_shell_completions, + } + } + + pub fn should_request_native_shell_completions(self) -> bool { + self.use_native_shell_completions + } + + pub fn fallback_strategy( + self, + fallback_when_native_is_unavailable: CompletionsFallbackStrategy, + ) -> CompletionsFallbackStrategy { + if self.use_native_shell_completions { + CompletionsFallbackStrategy::None + } else { + fallback_when_native_is_unavailable + } + } +} + +pub async fn completion_suggestions_with_native_fallback( + buffer_text: &str, + cursor_position: usize, + session_env_vars: Option<&HashMap>, + mut options: CompleterOptions, + policy: CompletionSourcePolicy, + native_results: F, + ctx: &T, +) -> Option +where + T: CompletionContext, + F: Future>>, +{ + let before_cursor_text = buffer_text.get(..cursor_position)?; + options.fallback_strategy = policy.fallback_strategy(options.fallback_strategy); + let warp_results = completer::suggestions( + before_cursor_text, + before_cursor_text.len(), + session_env_vars, + options, + ctx, + ) + .await; + + resolve_completion_results( + warp_results, + native_results, + before_cursor_text, + policy.force_native_shell_completions, + ) + .await +} + +async fn resolve_completion_results( + warp_results: Option, + native_results: F, + before_cursor_text: &str, + force_native_shell_completions: bool, +) -> Option +where + F: Future>>, +{ + if let Some(warp_results) = warp_results + && !warp_results.suggestions.is_empty() + && !force_native_shell_completions + { + return Some(warp_results); + } + + native_results.await.map(|results| { + let token_end = before_cursor_text.len(); + let token_start = before_cursor_text + .rfind(char::is_whitespace) + .map(|position| position + 1) + .unwrap_or_default(); + SuggestionResults { + replacement_span: (token_start, token_end).into(), + suggestions: results.into_iter().map(Into::into).collect(), + match_strategy: MatchStrategy::Fuzzy, + } + }) +} + +#[cfg(test)] +#[path = "completion_source_tests.rs"] +mod tests; diff --git a/app/src/completer/completion_source_tests.rs b/app/src/completer/completion_source_tests.rs new file mode 100644 index 00000000000..a0119276b10 --- /dev/null +++ b/app/src/completer/completion_source_tests.rs @@ -0,0 +1,93 @@ +use std::future; + +use warp_completer::completer::{ + CompletionsFallbackStrategy, Match, MatchStrategy, MatchedSuggestion, Priority, Suggestion, + SuggestionResults, SuggestionType, +}; +use warp_completer::meta::Span; +use warpui::r#async::block_on; + +use super::{CompletionSourcePolicy, resolve_completion_results}; +use crate::terminal::model::completions::ShellCompletion; + +fn suggestion_results(name: &str) -> SuggestionResults { + SuggestionResults { + replacement_span: Span::new(0, 1), + suggestions: vec![MatchedSuggestion::new( + Suggestion::with_same_display_and_replacement( + name, + None, + SuggestionType::Argument, + Priority::default(), + ), + Match::Prefix { + is_case_sensitive: true, + }, + )], + match_strategy: MatchStrategy::Fuzzy, + } +} + +#[test] +fn policy_requires_enablement_shell_support_and_single_line_input() { + let disabled = CompletionSourcePolicy::from_inputs(false, false, true, false); + assert!(!disabled.should_request_native_shell_completions()); + assert!(matches!( + disabled.fallback_strategy(CompletionsFallbackStrategy::FilePaths), + CompletionsFallbackStrategy::FilePaths + )); + + let forced = CompletionSourcePolicy::from_inputs(false, true, true, false); + assert!(forced.should_request_native_shell_completions()); + assert!(matches!( + forced.fallback_strategy(CompletionsFallbackStrategy::FilePaths), + CompletionsFallbackStrategy::None + )); + + let unsupported = CompletionSourcePolicy::from_inputs(true, false, false, false); + assert!(!unsupported.should_request_native_shell_completions()); + + let multiline = CompletionSourcePolicy::from_inputs(true, false, true, true); + assert!(!multiline.should_request_native_shell_completions()); +} + +#[test] +fn nonempty_warp_results_win_without_force() { + let results = block_on(resolve_completion_results( + Some(suggestion_results("warp")), + future::ready(Some(vec![ShellCompletion::new("native".to_owned())])), + "w", + false, + )) + .expect("Warp results should be retained"); + + assert_eq!(results.suggestions[0].replacement(), "warp"); +} + +#[test] +fn native_results_replace_empty_or_forced_warp_results() { + let empty_warp_results = SuggestionResults { + replacement_span: Span::new(0, 0), + suggestions: vec![], + match_strategy: MatchStrategy::Fuzzy, + }; + let native_after_empty = block_on(resolve_completion_results( + Some(empty_warp_results), + future::ready(Some(vec![ShellCompletion::new("native".to_owned())])), + "command na", + false, + )) + .expect("native results should replace empty Warp results"); + assert_eq!(native_after_empty.suggestions[0].replacement(), "native"); + assert_eq!(native_after_empty.replacement_span, Span::new(8, 10)); + + let native_after_force = block_on(resolve_completion_results( + Some(suggestion_results("warp")), + future::ready(Some(vec![ShellCompletion::new("native".to_owned())])), + "λ native", + true, + )) + .expect("forced native results should replace Warp results"); + assert_eq!(native_after_force.suggestions[0].replacement(), "native"); + assert_eq!(native_after_force.replacement_span, Span::new(3, 9)); +} diff --git a/app/src/completer/mod.rs b/app/src/completer/mod.rs index f9bd6d4318e..707348e159d 100644 --- a/app/src/completer/mod.rs +++ b/app/src/completer/mod.rs @@ -1,3 +1,4 @@ +mod completion_source; #[cfg(feature = "completions_v2")] mod js; @@ -8,6 +9,7 @@ use std::sync::Arc; use anyhow::Result; use async_trait::async_trait; +pub use completion_source::{CompletionSourcePolicy, completion_suggestions_with_native_fallback}; use lazy_static::lazy_static; use smol_str::SmolStr; use typed_path::{TypedPath, TypedPathBuf}; diff --git a/app/src/input_suggestions.rs b/app/src/input_suggestions.rs index 092a7260e4b..3d877d506e2 100644 --- a/app/src/input_suggestions.rs +++ b/app/src/input_suggestions.rs @@ -10,7 +10,7 @@ use itertools::Itertools; use pathfinder_geometry::vector::vec2f; use warp_command_signatures::IconType; use warp_completer::completer::{ - MatchType, PathSeparators, Suggestion, SuggestionResults, SuggestionType, + MatchType, PathSeparators, PreparedSuggestion, Suggestion, SuggestionResults, SuggestionType, }; use warp_core::features::FeatureFlag; use warp_core::ui::theme::AnsiColorIdentifier; @@ -273,9 +273,15 @@ fn filter_tab_suggestions( suggestions: &SuggestionResults, query: &str, path_separators: &[char], +) -> Vec { + items_from_prepared_suggestions(suggestions.prepare_for_query(query, path_separators)) +} + +fn items_from_prepared_suggestions( + suggestions: impl IntoIterator, ) -> Vec { suggestions - .filter_by_query(query, path_separators) + .into_iter() .map(|suggestion| Item { // TODO(vorporeal): Consider changing the type of `text` and `display` here to be `SmolStr`. text: suggestion.suggestion.replacement.to_string(), @@ -286,7 +292,7 @@ fn filter_tab_suggestions( .as_ref() .map(|desc| desc.clone().into()), matches: Some(suggestion.matching_indices), - icon_type: Some(icon_type(suggestion.suggestion)), + icon_type: Some(icon_type(&suggestion.suggestion)), match_type: suggestion.match_type, is_ai_query: false, is_history_item: false, @@ -339,6 +345,27 @@ impl InputSuggestions { ctx.notify(); } + pub fn set_prepared_tab_completions( + &mut self, + suggestions: Vec, + preselect_option: TabCompletionsPreselectOption, + ctx: &mut ViewContext, + ) { + self.set_items(items_from_prepared_suggestions(suggestions)); + if self.items.is_empty() { + return; + } + + match preselect_option { + TabCompletionsPreselectOption::First => self.select_first_item(ctx), + TabCompletionsPreselectOption::Unselected => self.selected_index = None, + TabCompletionsPreselectOption::Unchanged => {} + } + + self.cycle = true; + ctx.notify(); + } + pub fn position_id_at_index(index: usize) -> String { format!("input_suggestions:index_{index}") } diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index c86723e6cf1..c6df847e3fd 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -60,8 +60,9 @@ use vec1::Vec1; use vim::vim::{VimHandler, VimMode}; use warp_cli::agent::Harness; use warp_completer::completer::{ - self, CompleterOptions, CompletionContext, CompletionsFallbackStrategy, Description, Match, - MatchStrategy, MatchType, PathSeparators, SuggestionResults, + self, CompleterOptions, CompletionContext, CompletionsFallbackStrategy, Description, + ExplicitTabCompletion, MatchStrategy, MatchType, PathSeparators, PreparedSuggestion, + SuggestionResults, }; use warp_completer::meta::{HasSpan, Spanned}; use warp_completer::parsers::LiteCommand; @@ -72,7 +73,6 @@ use warp_core::r#async::debounce; use warp_core::context_flag::ContextFlag; use warp_core::ui::theme::AnsiColorIdentifier; use warp_core::ui::theme::color::internal_colors; -use warp_core::user_preferences::GetUserPreferences as _; use warp_editor::editor::NavigationKey; use warp_errors::{report_error, report_if_error}; use warp_util::path::ShellFamily; @@ -214,7 +214,9 @@ use crate::cloud_object::{CloudObject, CloudObjectLookup as _, Space}; #[cfg(feature = "local_fs")] use crate::code::editor_management::CodeSource; use crate::code_review::diff_state::DiffMode; -use crate::completer::SessionContext; +use crate::completer::{ + CompletionSourcePolicy, SessionContext, completion_suggestions_with_native_fallback, +}; use crate::context_chips::display::{PromptDisplay, PromptDisplayEvent}; use crate::context_chips::display_chip::{DisplayChipConfig, PromptChipShellCommand}; use crate::context_chips::prompt_type::PromptType; @@ -12245,30 +12247,11 @@ impl Input { ctx: &mut ViewContext<'_, Input>, ) { let buffer_text = self.buffer_text(ctx); - - // The 'ForceNativeShellCompletions' user pref can be used to unconditionally - // generate and show native shell completion results (i.e. regardless of whether or - // not we have completion results via completion specs). - let force_native_shell_completions = ctx - .private_user_preferences() - .read_value("ForceNativeShellCompletions") - .ok() - .flatten() - .and_then(|s| s.parse().ok()) - .unwrap_or(false); - - let use_native_shell_completions = (FeatureFlag::NativeShellCompletions.is_enabled() || force_native_shell_completions) - && completion_context - .session - .shell() - .supports_native_shell_completions() - // For now, don't use native shell completions for multi-line commands. - && !buffer_text.contains('\n'); + let completion_source_policy = + CompletionSourcePolicy::for_session(&completion_context.session, &buffer_text, ctx); let fallback_strategy = match completions_trigger { - CompletionsTrigger::Keybinding | CompletionsTrigger::SlashCommandAutoOpen - if !use_native_shell_completions => - { + CompletionsTrigger::Keybinding | CompletionsTrigger::SlashCommandAutoOpen => { CompletionsFallbackStrategy::FilePaths } _ => CompletionsFallbackStrategy::None, @@ -12299,28 +12282,29 @@ impl Input { }); let cursor_position = cursor_position.as_usize(); - let native_results_fut = if use_native_shell_completions { - // If we're using native shell completions, construct a future that - // will be resolved with any completions data provided by the shell. - let (results_tx, results_rx) = async_channel::unbounded(); - ctx.dispatch_typed_action(&TerminalAction::RunNativeShellCompletions { - buffer_text: buffer_text[0..cursor_position].to_owned(), - results_tx, - }); - async move { results_rx.recv().await.ok() }.boxed() - } else { - // If not, we can immediately say that there are no completion - // results from the shell. - futures::future::ready(None).boxed() - }; + let native_results_fut = + if completion_source_policy.should_request_native_shell_completions() { + // If we're using native shell completions, construct a future that + // will be resolved with any completions data provided by the shell. + let (results_tx, results_rx) = async_channel::unbounded(); + ctx.dispatch_typed_action(&TerminalAction::RunNativeShellCompletions { + buffer_text: buffer_text[0..cursor_position].to_owned(), + results_tx, + }); + async move { results_rx.recv().await.ok() }.boxed() + } else { + // If not, we can immediately say that there are no completion + // results from the shell. + futures::future::ready(None).boxed() + }; let completion_session = completion_context.session.clone(); let abort_handle = ctx .spawn_abortable( async move { - let suggestions = completer::suggestions( - before_cursor_text.as_str(), + let suggestions = completion_suggestions_with_native_fallback( + &buffer_text, cursor_position, session_env_vars.as_ref(), CompleterOptions { @@ -12329,36 +12313,12 @@ impl Input { suggest_file_path_completions_only: input_type.is_ai(), parse_quotes_as_literals: input_type.is_ai(), }, + completion_source_policy, + native_results_fut, &completion_context, ) .await; - let suggestions = match suggestions { - Some(s) if !s.suggestions.is_empty() && !force_native_shell_completions => { - Some(s) - } - _ => native_results_fut.await.map(|results| { - let suggestions = results.into_iter().map(Into::into).collect_vec(); - - let token_end = cursor_position; - // Within the section of the buffer from the start - // to the end of this token... - let token_start = buffer_text[0..token_end] - // Find the last whitespace char before the token end. - .rfind(char::is_whitespace) - // If we find one, the token start is the next char. - .map(|pos| pos + 1) - // Otherwise, the start is the beginning of the buffer. - .unwrap_or_default(); - - SuggestionResults { - replacement_span: (token_start, token_end).into(), - suggestions, - match_strategy: MatchStrategy::Fuzzy, - } - }), - }; - (suggestions, completions_trigger, editor_snapshot) }, |input, (suggestions, completions_trigger, editor_model), ctx| { @@ -12571,130 +12531,113 @@ impl Input { }); } Some(results) => { - match (results.single_prefix_suggestion(), completions_trigger) { - (Some(only_prefix_suggestion), CompletionsTrigger::Keybinding) => { - // If there is exactly one prefix suggestion, just insert into the buffer. + let query = results.replacement_span.slice(&buffer_text); + let buffer_text_original = buffer_text + [0..self.start_byte_index_of_last_selection(ctx).as_usize()] + .to_string(); + let decision = if completions_trigger == CompletionsTrigger::Keybinding { + results.explicit_tab_completion(query, self.path_separators(ctx).all) + } else { + ExplicitTabCompletion::Open { + suggestions: results + .prepare_for_query(query, self.path_separators(ctx).all), + replacement_span: results.replacement_span, + } + }; + let prepared_suggestions: Vec = match decision { + ExplicitTabCompletion::NoAction => { + self.suggestions_mode_model.update(ctx, |model, ctx| { + model.set_mode(InputSuggestionsMode::Closed, ctx); + }); + ctx.notify(); + return; + } + ExplicitTabCompletion::InsertSingle { + suggestion, + replacement_span, + } => { self.insert_completion_result_into_editor( - only_prefix_suggestion.replacement(), - results.replacement_span.start(), + &suggestion.suggestion.replacement, + replacement_span.start(), Executing::No, ctx, ); + ctx.notify(); + return; } - (_, completions_trigger) => { - let buffer_text_original = buffer_text - [0..self.start_byte_index_of_last_selection(ctx).as_usize()] - .to_string(); - - if completions_trigger == CompletionsTrigger::Keybinding - && let Some(common_prefix) = longest_common_prefix( - results - .suggestions - .iter() - .filter(|suggestion| { - // Ignore fuzzy matches and case-insensitive matches - // when calculating the longest common prefix, so we - // are able to insert a common prefix more often. - matches!( - suggestion.match_type, - Match::Prefix { - is_case_sensitive: true - } | Match::Exact { - is_case_sensitive: true - } - ) - }) - .map(|suggestion| suggestion.replacement()), - ) - { - // Insert the common prefix if it is longer than what the user has - // already typed. This check is necessary because the suggestions - // are case-insensitive, while the common prefix is necessarily - // case-sensitive. That can lead to the common prefix being shorter - // than the input, causing confusing behavior where the input is - // truncated. Also, only fill in the common prefix if the - // replacement itself is a prefix of the common prefix. If there - // are only fuzzy completions, then it's possible this is not the - // case, and we don't want to fill in the common prefix in that - // case. - let replacement_start = results.replacement_span.start(); - let current_word = &buffer_text_original[replacement_start - ..self.start_byte_index_of_last_selection(ctx).as_usize()]; - if common_prefix.len() > results.replacement_span.distance() - && common_prefix.starts_with(current_word) - { - self.insert_completion_prefix_into_editor( - ctx, - common_prefix, - results.replacement_span.start(), - ); - } - } + ExplicitTabCompletion::InsertCommonPrefixAndOpen { + common_prefix, + suggestions, + replacement_span, + } => { + self.insert_completion_prefix_into_editor( + ctx, + &common_prefix, + replacement_span.start(), + ); + suggestions + } + ExplicitTabCompletion::Open { suggestions, .. } => suggestions, + }; - // If not using completions as you type, then - // clear any autosuggestions when tab completions are open. - // The autosuggestion will be repopulated when the menu is closed. - // We don't do this for completions as you type because the user would - // otherwise hardly see autosuggestons. - if FeatureFlag::RemoveAutosuggestionDuringTabCompletions.is_enabled() - && !self.is_completions_while_typing_turned_on(ctx) - { - self.editor.update(ctx, |view, ctx| { - view.clear_autosuggestion(ctx); - }); - } + // If not using completions as you type, then + // clear any autosuggestions when tab completions are open. + // The autosuggestion will be repopulated when the menu is closed. + // We don't do this for completions as you type because the user would + // otherwise hardly see autosuggestons. + if FeatureFlag::RemoveAutosuggestionDuringTabCompletions.is_enabled() + && !self.is_completions_while_typing_turned_on(ctx) + { + self.editor.update(ctx, |view, ctx| { + view.clear_autosuggestion(ctx); + }); + } - // Decide where to render the tab completion menu. - // If we're rendering it at a specific position, let's make sure - // that position exists in the position cache. - let position = self.tab_completions_menu_position( - &results, - &buffer_text_original, + // Decide where to render the tab completion menu. + // If we're rendering it at a specific position, let's make sure + // that position exists in the position cache. + let position = + self.tab_completions_menu_position(&results, &buffer_text_original, ctx); + let menu_position = if let Some(position) = position { + self.editor.update(ctx, |editor, ctx| { + editor.cache_buffer_point( + position, + COMPLETIONS_START_OF_REPLACEMENT_SPAN_POSITION_ID, ctx, ); - let menu_position = if let Some(position) = position { - self.editor.update(ctx, |editor, ctx| { - editor.cache_buffer_point( - position, - COMPLETIONS_START_OF_REPLACEMENT_SPAN_POSITION_ID, - ctx, - ); - }); - TabCompletionsMenuPosition::AtStartOfReplacementSpan - } else { - TabCompletionsMenuPosition::AtLastCursor - }; + }); + TabCompletionsMenuPosition::AtStartOfReplacementSpan + } else { + TabCompletionsMenuPosition::AtLastCursor + }; - self.suggestions_mode_model.update(ctx, |m, ctx| { - m.set_mode( - InputSuggestionsMode::CompletionSuggestions { - replacement_start: results.replacement_span.start(), - buffer_text_original, - completion_results: results.clone(), - trigger: completions_trigger, - menu_position, - }, - ctx, - ); - }); + self.suggestions_mode_model.update(ctx, |model, ctx| { + model.set_mode( + InputSuggestionsMode::CompletionSuggestions { + replacement_start: results.replacement_span.start(), + buffer_text_original, + completion_results: results.clone(), + trigger: completions_trigger, + menu_position, + }, + ctx, + ); + }); - let preselect_option = if self.is_classic_completions_enabled(ctx) { - TabCompletionsPreselectOption::Unselected - } else { - TabCompletionsPreselectOption::First - }; + let preselect_option = if self.is_classic_completions_enabled(ctx) { + TabCompletionsPreselectOption::Unselected + } else { + TabCompletionsPreselectOption::First + }; - self.input_suggestions - .update(ctx, |input_suggestions, ctx| { - input_suggestions.prefix_search_for_tab_completion( - results.replacement_span.slice(&buffer_text), - &results, - preselect_option, - ctx, - ); - }); - } - } + self.input_suggestions + .update(ctx, |input_suggestions, ctx| { + input_suggestions.set_prepared_tab_completions( + prepared_suggestions, + preselect_option, + ctx, + ); + }); } } ctx.notify(); diff --git a/app/src/terminal/model/session.rs b/app/src/terminal/model/session.rs index 78037270c99..f7588cdfde8 100644 --- a/app/src/terminal/model/session.rs +++ b/app/src/terminal/model/session.rs @@ -1726,7 +1726,7 @@ pub fn get_local_hostname() -> Result { } } -#[cfg(test)] +#[cfg(any(test, feature = "test-util"))] pub mod testing { use super::command_executor::testing::TestCommandExecutor; use super::*; diff --git a/app/src/terminal/model/session/command_executor.rs b/app/src/terminal/model/session/command_executor.rs index a07f74a2397..99934c6b5a5 100644 --- a/app/src/terminal/model/session/command_executor.rs +++ b/app/src/terminal/model/session/command_executor.rs @@ -349,7 +349,7 @@ fn new_command_executor_for_local_tty_session( } } -#[cfg(test)] +#[cfg(any(test, feature = "test-util"))] pub mod testing { use anyhow::anyhow; use command::r#async::Command; diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index d4f2228dacd..1f4e4557494 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -169,7 +169,9 @@ pub use crate::code_review::git_repo_model::{ GitRepoModels, GitRepoStatusModel, GitStatusMetadata, }; pub use crate::code_review::github_repo_model::GitHubRepoModel; -pub use crate::completer::SessionContext; +pub use crate::completer::{ + CompletionSourcePolicy, SessionContext, completion_suggestions_with_native_fallback, +}; pub use crate::global_resource_handles::GlobalResourceHandlesProvider; pub use crate::persistence::PersistenceWriter; pub use crate::prefix::longest_common_prefix; @@ -227,11 +229,12 @@ pub use crate::terminal::model::blockgrid::BlockGrid; pub use crate::terminal::model::blocks::{ BlockHeight, BlockHeightItem, BlockHeightSummary, BlockList, RichContentItem, TotalIndex, }; +pub use crate::terminal::model::completions::ShellCompletion; pub use crate::terminal::model::escape_sequences::{KeystrokeWithDetails, ToEscapeSequence}; pub use crate::terminal::model::grid::grid_handler::{GridHandler, TermMode}; pub use crate::terminal::model::rich_content::RichContentType; -pub use crate::terminal::model::session::Sessions; pub use crate::terminal::model::session::active_session::{ActiveSession, ActiveSessionEvent}; +pub use crate::terminal::model::session::{Session, Sessions, SessionsEvent}; pub use crate::terminal::model::terminal_model::BlockIndex; pub use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher}; pub use crate::terminal::session_settings::SessionSettings; diff --git a/crates/warp_completer/src/completer/mod.rs b/crates/warp_completer/src/completer/mod.rs index 11c504c0007..c6ee40e8f9f 100644 --- a/crates/warp_completer/src/completer/mod.rs +++ b/crates/warp_completer/src/completer/mod.rs @@ -19,8 +19,9 @@ pub use describe::{Description, TopLevelCommandCaseSensitivity, describe, descri pub use engine::{EngineDirEntry, EngineFileType, LocationType}; pub use matchers::{Match, MatchStrategy, MatchType}; pub use suggest::{ - CompleterOptions, CompletionsFallbackStrategy, MatchedSuggestion, Priority, Suggestion, - SuggestionResults, SuggestionType, SuggestionTypeName, suggestions, + CompleterOptions, CompletionsFallbackStrategy, ExplicitTabCompletion, MatchedSuggestion, + PreparedSuggestion, Priority, Suggestion, SuggestionResults, SuggestionType, + SuggestionTypeName, suggestions, }; fn get_path_separators(ctx: &dyn CompletionContext) -> PathSeparators { diff --git a/crates/warp_completer/src/completer/suggest/mod.rs b/crates/warp_completer/src/completer/suggest/mod.rs index 9de80c15a21..be20bc599ba 100644 --- a/crates/warp_completer/src/completer/suggest/mod.rs +++ b/crates/warp_completer/src/completer/suggest/mod.rs @@ -280,6 +280,33 @@ pub struct FilteredSuggestion<'a> { pub matching_indices: Vec, } +#[derive(Clone, Debug)] +pub struct PreparedSuggestion { + pub suggestion: Suggestion, + pub match_type: MatchType, + /// The indices of the matching characters between suggestion.display and + /// the query that this PreparedSuggestion is derived from. + pub matching_indices: Vec, +} + +#[derive(Clone, Debug)] +pub enum ExplicitTabCompletion { + NoAction, + InsertSingle { + suggestion: PreparedSuggestion, + replacement_span: Span, + }, + InsertCommonPrefixAndOpen { + common_prefix: String, + suggestions: Vec, + replacement_span: Span, + }, + Open { + suggestions: Vec, + replacement_span: Span, + }, +} + impl SuggestionResults { /// Orders the suggestions in the following order: /// 1. A suggestion that matches the query exactly (if any) @@ -415,6 +442,74 @@ impl SuggestionResults { }) } + pub fn prepare_for_query( + &self, + query: &str, + path_separators: &[char], + ) -> Vec { + self.filter_by_query(query, path_separators) + .map(|suggestion| PreparedSuggestion { + suggestion: suggestion.suggestion.clone(), + match_type: suggestion.match_type, + matching_indices: suggestion.matching_indices, + }) + .collect() + } + + pub fn explicit_tab_completion( + &self, + query: &str, + path_separators: &[char], + ) -> ExplicitTabCompletion { + let suggestions = self.prepare_for_query(query, path_separators); + if suggestions.is_empty() { + return ExplicitTabCompletion::NoAction; + } + + if let Some(single_prefix_suggestion) = self.single_prefix_suggestion() + && let Some(suggestion) = suggestions + .iter() + .find(|suggestion| suggestion.suggestion == single_prefix_suggestion.suggestion) + { + return ExplicitTabCompletion::InsertSingle { + suggestion: suggestion.clone(), + replacement_span: self.replacement_span, + }; + } + + let common_prefix = longest_common_prefix( + self.suggestions + .iter() + .filter(|suggestion| { + matches!( + suggestion.match_type, + Match::Prefix { + is_case_sensitive: true + } | Match::Exact { + is_case_sensitive: true + } + ) + }) + .map(|suggestion| suggestion.replacement()), + ) + .map(str::to_owned); + + if let Some(common_prefix) = common_prefix + && common_prefix.len() > self.replacement_span.distance() + && common_prefix.starts_with(query) + { + return ExplicitTabCompletion::InsertCommonPrefixAndOpen { + common_prefix, + suggestions, + replacement_span: self.replacement_span, + }; + } + + ExplicitTabCompletion::Open { + suggestions, + replacement_span: self.replacement_span, + } + } /// Returns a `MatchedSuggestion` if there is a _single_ prefix suggestion, otherwise returns /// `None`. pub fn single_prefix_suggestion(&self) -> Option<&MatchedSuggestion> { @@ -471,6 +566,22 @@ impl SuggestionResults { } } +fn longest_common_prefix<'a>(mut strings: impl Iterator) -> Option<&'a str> { + let first = strings.next()?; + let common_prefix_len = strings.fold(first.len(), |common_prefix_len, string| { + first + .char_indices() + .zip(string.chars()) + .take_while(|((index, first_char), string_char)| { + *index < common_prefix_len && first_char == string_char + }) + .map(|((index, character), _)| index + character.len_utf8()) + .last() + .unwrap_or_default() + }); + Some(&first[..common_prefix_len]) +} + /// In the cases where we don't have completions to show, we can potentially /// fallback to one of these types. #[derive(Debug, Copy, Clone)] @@ -625,6 +736,10 @@ async fn suggestions_internal<'a>( }) } +#[cfg(test)] +#[path = "presentation_tests.rs"] +mod presentation_tests; + #[cfg(test)] #[path = "test.rs"] mod tests; diff --git a/crates/warp_completer/src/completer/suggest/presentation_tests.rs b/crates/warp_completer/src/completer/suggest/presentation_tests.rs new file mode 100644 index 00000000000..63b05ce497a --- /dev/null +++ b/crates/warp_completer/src/completer/suggest/presentation_tests.rs @@ -0,0 +1,126 @@ +use super::{ + ExplicitTabCompletion, MatchedSuggestion, Priority, Suggestion, SuggestionResults, + SuggestionType, +}; +use crate::completer::{MatchStrategy, MatchType, TopLevelCommandCaseSensitivity}; +use crate::meta::Span; + +const PATH_SEPARATORS: &[char] = &['/']; + +fn matched_suggestion(display: &str, query: &str) -> MatchedSuggestion { + let suggestion = Suggestion::with_same_display_and_replacement( + display, + None, + SuggestionType::Command(TopLevelCommandCaseSensitivity::CaseSensitive), + Priority::default(), + ); + let match_type = MatchStrategy::Fuzzy + .get_match_type(query, display) + .expect("test suggestion should match its query"); + MatchedSuggestion::new(suggestion, match_type) +} + +fn results(query: &str, displays: &[&str]) -> SuggestionResults { + SuggestionResults { + replacement_span: Span::new(0, query.len()), + suggestions: displays + .iter() + .map(|display| matched_suggestion(display, query)) + .collect(), + match_strategy: MatchStrategy::Fuzzy, + } +} + +#[test] +fn prepared_suggestions_follow_presentation_order() { + let results = results("git", &["graft-it", "git-status", "GIT", "git"]); + + let prepared = results.prepare_for_query("git", PATH_SEPARATORS); + let displays = prepared + .iter() + .map(|suggestion| suggestion.suggestion.display.as_str()) + .collect::>(); + + assert_eq!(displays, ["git", "GIT", "git-status", "graft-it"]); + assert!(matches!( + prepared[0].match_type, + MatchType::Exact { + is_case_sensitive: true + } + )); + assert_eq!(prepared[0].matching_indices, [0, 1, 2]); + assert!(matches!(prepared[3].match_type, MatchType::Fuzzy)); +} + +#[test] +fn explicit_tab_inserts_the_single_prefix_suggestion() { + let results = results("st", &["sitar", "status"]); + + let ExplicitTabCompletion::InsertSingle { + suggestion, + replacement_span, + } = results.explicit_tab_completion("st", PATH_SEPARATORS) + else { + panic!("one prefix suggestion should be inserted"); + }; + + assert_eq!(suggestion.suggestion.replacement, "status"); + assert_eq!(replacement_span, Span::new(0, 2)); +} + +#[test] +fn explicit_tab_opens_an_ordered_menu_with_a_common_prefix() { + let results = results("s", &["stork", "stash", "status"]); + + let ExplicitTabCompletion::InsertCommonPrefixAndOpen { + common_prefix, + suggestions, + replacement_span, + } = results.explicit_tab_completion("s", PATH_SEPARATORS) + else { + panic!("multiple prefix suggestions should open the menu"); + }; + assert_eq!(common_prefix, "st"); + assert_eq!(replacement_span, Span::new(0, 1)); + assert_eq!( + suggestions + .iter() + .map(|suggestion| suggestion.suggestion.display.as_str()) + .collect::>(), + ["stork", "stash", "status"] + ); +} + +#[test] +fn explicit_tab_has_no_action_when_the_query_filters_every_candidate() { + let results = results("a", &["alpha"]); + + assert!(matches!( + results.explicit_tab_completion("z", PATH_SEPARATORS), + ExplicitTabCompletion::NoAction + )); +} + +#[test] +fn explicit_tab_does_not_insert_a_case_insensitive_common_prefix() { + let results = results("ab", &["Abacus", "Abandon"]); + + let ExplicitTabCompletion::Open { suggestions, .. } = + results.explicit_tab_completion("ab", PATH_SEPARATORS) + else { + panic!("case-insensitive prefixes should only open the menu"); + }; + assert_eq!(suggestions.len(), 2); +} + +#[test] +fn explicit_tab_computes_common_prefixes_on_utf8_boundaries() { + let results = results("", &["éclair", "école"]); + + let ExplicitTabCompletion::InsertCommonPrefixAndOpen { common_prefix, .. } = + results.explicit_tab_completion("", PATH_SEPARATORS) + else { + panic!("the shared Unicode prefix should be inserted"); + }; + assert_eq!(common_prefix, "éc"); +} diff --git a/crates/warp_tui/src/completion_menu.rs b/crates/warp_tui/src/completion_menu.rs index 6cdb0acc187..d225fe07f08 100644 --- a/crates/warp_tui/src/completion_menu.rs +++ b/crates/warp_tui/src/completion_menu.rs @@ -3,7 +3,7 @@ use std::ops::Range; use string_offset::CharOffset; -use warp_completer::completer::{EngineFileType, MatchedSuggestion}; +use warp_completer::completer::{EngineFileType, PreparedSuggestion}; use warpui_core::{AppContext, Entity, ModelContext, ModelHandle}; use crate::inline_menu::{ @@ -87,7 +87,7 @@ impl TuiCompletionMenuModel { pub(crate) fn show( &mut self, - suggestions: Vec, + suggestions: Vec, replacement_range: Range, append_space_at_buffer_end: bool, ctx: &mut ModelContext, @@ -105,10 +105,10 @@ impl TuiCompletionMenuModel { let append_space = append_space_at_buffer_end && suggestion.suggestion.file_type != Some(EngineFileType::Directory); TuiCompletionRow { - display: suggestion.display().to_owned(), - description: suggestion.description(), + display: suggestion.suggestion.display.to_string(), + description: suggestion.suggestion.description.clone(), acceptance: TuiCompletionAcceptance { - replacement: suggestion.replacement().to_owned(), + replacement: suggestion.suggestion.replacement.to_string(), replacement_range: replacement_range.clone(), append_space, }, diff --git a/crates/warp_tui/src/completion_menu_tests.rs b/crates/warp_tui/src/completion_menu_tests.rs index f6d94d069d4..0992c2587d8 100644 --- a/crates/warp_tui/src/completion_menu_tests.rs +++ b/crates/warp_tui/src/completion_menu_tests.rs @@ -1,5 +1,5 @@ use warp_completer::completer::{ - EngineFileType, Match, MatchedSuggestion, Priority, Suggestion, SuggestionType, + EngineFileType, MatchType, PreparedSuggestion, Priority, Suggestion, SuggestionType, }; use warpui_core::App; @@ -9,7 +9,7 @@ fn matched_suggestion( display: &str, replacement: &str, file_type: Option, -) -> MatchedSuggestion { +) -> PreparedSuggestion { let mut suggestion = Suggestion::new( display, replacement, @@ -18,12 +18,13 @@ fn matched_suggestion( Priority::default(), ); suggestion.file_type = file_type; - MatchedSuggestion::new( + PreparedSuggestion { suggestion, - Match::Prefix { + match_type: MatchType::Prefix { is_case_sensitive: true, }, - ) + matching_indices: Vec::new(), + } } #[test] diff --git a/crates/warp_tui/src/session_registry.rs b/crates/warp_tui/src/session_registry.rs index 758ae09f496..68702748cff 100644 --- a/crates/warp_tui/src/session_registry.rs +++ b/crates/warp_tui/src/session_registry.rs @@ -344,6 +344,7 @@ impl TuiSessions { } TuiTerminalSessionEvent::ExecuteCommand(_) | TuiTerminalSessionEvent::InterruptPty + | TuiTerminalSessionEvent::RunNativeShellCompletions { .. } | TuiTerminalSessionEvent::WriteAgentInput { .. } | TuiTerminalSessionEvent::WriteUserInput(_) | TuiTerminalSessionEvent::Resize(_) => {} diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index a589932c604..9d3c43cb7ce 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -36,19 +36,19 @@ use warp::tui_export::{ ModelEvent, ParsedSlashCommandInput, PersistenceWriter, PillBarActionKind, PillBarInteractionEvent, PillBarPillKind, PillSwitchOutcome, PtyIntent, PtyIntentEvent, QueuedQueryEvent, QueuedQueryModel, RepoDetectionSessionType, RepoDetectionSource, - ServerConversationToken, SessionSettings, Sessions, ShellCommandExecutorEvent, SizeInfo, - SizeUpdate, SkillReference, SlashCommandDataSource as _, SlashCommandKind, - SlashCommandSelectionBehavior, StartAgentExecutorEvent, StartAgentRequest, StaticCommand, - TelemetryEvent, TerminalColorList, TerminalColors, TerminalModel, TerminalSurface, - TerminalSurfaceInit, TranscriptScope, TuiMcpAction, TuiMcpManager, TuiMcpServerId, - TuiMcpVariableValue, TuiOnboardingMarker, TuiOnboardingMarkers, TuiOnboardingMarkersEvent, - TuiSlashCommandDataSource, TuiSlashCommandDataSourceArgs, TuiUpArrowHistoryItemKind, - TuiUserInfoManager, TuiUserInfoManagerEvent, TuiZeroStateDataSource, UserTakeOverReason, - WAKEUP_THROTTLE_PERIOD, WarpConfig, WarpConfigUpdateEvent, block_context_from_terminal_model, - build_slash_command_mixer, detect_possible_git_repo, export_conversation_markdown, log_out_tui, - maybe_build_ai_query_upsert_event, prepare_conversation_block_restoration, - record_autodetection_toggle_from_slash_command, record_saved_prompt_accepted, - record_static_slash_command_accepted, saved_prompt_text_for_id, + ServerConversationToken, SessionSettings, Sessions, SessionsEvent, ShellCommandExecutorEvent, + ShellCompletion, SizeInfo, SizeUpdate, SkillReference, SlashCommandDataSource as _, + SlashCommandKind, SlashCommandSelectionBehavior, StartAgentExecutorEvent, StartAgentRequest, + StaticCommand, TelemetryEvent, TerminalColorList, TerminalColors, TerminalModel, + TerminalSurface, TerminalSurfaceInit, TranscriptScope, TuiMcpAction, TuiMcpManager, + TuiMcpServerId, TuiMcpVariableValue, TuiOnboardingMarker, TuiOnboardingMarkers, + TuiOnboardingMarkersEvent, TuiSlashCommandDataSource, TuiSlashCommandDataSourceArgs, + TuiUpArrowHistoryItemKind, TuiUserInfoManager, TuiUserInfoManagerEvent, TuiZeroStateDataSource, + UserTakeOverReason, WAKEUP_THROTTLE_PERIOD, WarpConfig, WarpConfigUpdateEvent, + block_context_from_terminal_model, build_slash_command_mixer, detect_possible_git_repo, + export_conversation_markdown, log_out_tui, maybe_build_ai_query_upsert_event, + prepare_conversation_block_restoration, record_autodetection_toggle_from_slash_command, + record_saved_prompt_accepted, record_static_slash_command_accepted, saved_prompt_text_for_id, slash_command_selection_behavior, throttle, }; use warp_core::channel::{Channel, ChannelState}; @@ -300,6 +300,10 @@ pub(crate) enum TuiTerminalSessionEvent { }, WriteUserInput(Cow<'static, [u8]>), Resize(SizeUpdate), + RunNativeShellCompletions { + buffer_text: String, + results_tx: Sender>, + }, StartAgentConversation { request: Box, working_directory: Option, @@ -320,6 +324,13 @@ impl PtyIntentEvent for TuiTerminalSessionEvent { }), Self::WriteUserInput(bytes) => Some(PtyIntent::WriteBytes(bytes.clone())), Self::Resize(size_update) => Some(PtyIntent::Resize(*size_update)), + Self::RunNativeShellCompletions { + buffer_text, + results_tx, + } => Some(PtyIntent::RunNativeShellCompletions { + buffer_text: buffer_text.clone(), + results_tx: results_tx.clone(), + }), Self::StartAgentConversation { .. } | Self::CleanupFailedChildLaunch { .. } => None, } } @@ -2061,6 +2072,7 @@ impl TuiTerminalSessionView { } ModelEvent::AfterBlockCompleted(completed) => { view.emit_block_completed_telemetry(completed, ctx); + view.ensure_external_commands_are_warming(ctx); } ModelEvent::AfterBlockStarted { .. } => { view.refresh_input_focus(ctx); @@ -2116,6 +2128,26 @@ impl TuiTerminalSessionView { ctx.notify(); } }); + ctx.subscribe_to_model(&sessions, |view, _, event, ctx| match event { + SessionsEvent::SessionBootstrapped(bootstrap_event) + if view.active_session.as_ref(ctx).session_id(ctx) + == Some(bootstrap_event.session_id) => + { + let Some(session) = view.sessions.as_ref(ctx).get(bootstrap_event.session_id) + else { + report_error!( + "Could not find active TUI session after its bootstrap event", + extra: { "session_id" => ?bootstrap_event.session_id } + ); + return; + }; + view.abort_shell_completion(ctx); + view.warm_shell_completion_sources(session, ctx); + } + SessionsEvent::SessionBootstrapped(_) + | SessionsEvent::SessionInitialized { .. } + | SessionsEvent::EnvironmentVariablesUpdated { .. } => {} + }); ctx.subscribe_to_model(&active_session, |view, _, event, ctx| match event { ActiveSessionEvent::UpdatedPwd => { view.abort_shell_completion(ctx); @@ -2158,7 +2190,7 @@ impl TuiTerminalSessionView { }); ctx.notify(); } - ActiveSessionEvent::Bootstrapped => view.abort_shell_completion(ctx), + ActiveSessionEvent::Bootstrapped => {} }); // The footer's usage entry shows the selected conversation's token/cost // totals: re-render when that conversation's usage metadata updates. @@ -2271,6 +2303,9 @@ impl TuiTerminalSessionView { if let Some(error) = initial_settings_file_error { view.show_settings_file_error(&error, ctx); } + if let Some(session) = view.active_session.as_ref(ctx).session(ctx) { + view.warm_shell_completion_sources(session, ctx); + } view } diff --git a/crates/warp_tui/src/terminal_session_view/completions.rs b/crates/warp_tui/src/terminal_session_view/completions.rs index a4f842cfd10..177bd2c1e92 100644 --- a/crates/warp_tui/src/terminal_session_view/completions.rs +++ b/crates/warp_tui/src/terminal_session_view/completions.rs @@ -1,14 +1,20 @@ //! Asynchronous shell-command completion coordination for the TUI composer. -use warp::tui_export::{longest_common_prefix, tui_completion_session_context}; +use std::sync::Arc; + +use futures::FutureExt as _; +use warp::tui_export::{ + CompletionSourcePolicy, Session, completion_suggestions_with_native_fallback, + tui_completion_session_context, +}; use warp_completer::completer::{ - CompleterOptions, EngineFileType, Match, SuggestionResults, suggestions, + CompleterOptions, EngineFileType, ExplicitTabCompletion, SuggestionResults, }; use warp_core::SessionId; use warpui_core::r#async::SpawnedFutureHandle; use warpui_core::{AppContext, ViewContext}; -use super::TuiTerminalSessionView; +use super::{TuiTerminalSessionEvent, TuiTerminalSessionView}; use crate::completion_menu::TuiCompletionAcceptance; use crate::inline_menu::active_inline_menu; use crate::input::view::TuiCompletionInputSnapshot; @@ -29,6 +35,40 @@ struct CompletionRequestSnapshot { } impl TuiTerminalSessionView { + pub(super) fn warm_shell_completion_sources( + &self, + session: Arc, + ctx: &mut ViewContext, + ) { + let function_names_session = session.clone(); + let builtins_session = session.clone(); + + ctx.spawn( + async move { session.load_external_commands().await }, + |_, _, _| {}, + ); + ctx.background_executor() + .spawn(async move { function_names_session.load_all_function_names().await }) + .detach(); + ctx.background_executor() + .spawn(async move { builtins_session.load_all_builtins().await }) + .detach(); + } + + pub(super) fn ensure_external_commands_are_warming(&self, ctx: &mut ViewContext) { + let Some(session) = self.active_session.as_ref(ctx).session(ctx) else { + return; + }; + if session.has_attempted_to_load_external_commands() { + return; + } + + ctx.spawn( + async move { session.load_external_commands().await }, + |_, _, _| {}, + ); + } + pub(super) fn request_shell_completion(&mut self, ctx: &mut ViewContext) { if active_inline_menu( &self.inline_menus, @@ -49,6 +89,8 @@ impl TuiTerminalSessionView { return; }; let session_id = session.id(); + let completion_source_policy = + CompletionSourcePolicy::for_session(&session, &input.buffer_text, ctx); let Some(completion_context) = tui_completion_session_context( self.active_session.as_ref(ctx), current_working_directory.clone(), @@ -71,16 +113,27 @@ impl TuiTerminalSessionView { current_working_directory, generation, }; - let line = request.input.buffer_text[..request.input.cursor_byte_offset].to_owned(); let cursor_byte_offset = request.input.cursor_byte_offset; + let native_results = if completion_source_policy.should_request_native_shell_completions() { + let (results_tx, results_rx) = async_channel::unbounded(); + ctx.emit(TuiTerminalSessionEvent::RunNativeShellCompletions { + buffer_text: request.input.buffer_text[..cursor_byte_offset].to_owned(), + results_tx, + }); + async move { results_rx.recv().await.ok() }.boxed() + } else { + futures::future::ready(None).boxed() + }; let completion_session = completion_context.session.clone(); self.completion_request.future = Some(ctx.spawn_abortable( async move { - let results = suggestions( - &line, + let results = completion_suggestions_with_native_fallback( + &request.input.buffer_text, cursor_byte_offset, session_env_vars.as_ref(), CompleterOptions::default(), + completion_source_policy, + native_results, &completion_context, ) .await; @@ -127,69 +180,67 @@ impl TuiTerminalSessionView { let Some(results) = results.filter(|results| !results.suggestions.is_empty()) else { return; }; - let replacement_range = results.replacement_span.start()..results.replacement_span.end(); - let append_space_at_buffer_end = - request.input.cursor_byte_offset == request.input.buffer_text.len(); - - if let Some(suggestion) = results.single_prefix_suggestion() { - let acceptance = TuiCompletionAcceptance { - replacement: suggestion.replacement().to_owned(), - replacement_range, - append_space: append_space_at_buffer_end - && suggestion.suggestion.file_type != Some(EngineFileType::Directory), - }; - self.input_view.update(ctx, |input, ctx| { - input.apply_shell_completion(acceptance, ctx) - }); + let Some(query) = request + .input + .buffer_text + .get(results.replacement_span.start()..results.replacement_span.end()) + else { return; - } - - let common_prefix = longest_common_prefix( - results - .suggestions - .iter() - .filter(|suggestion| { - matches!( - suggestion.match_type, - Match::Prefix { - is_case_sensitive: true - } | Match::Exact { - is_case_sensitive: true - } - ) - }) - .map(|suggestion| suggestion.replacement()), - ) - .map(str::to_owned); - let menu_input = common_prefix - .filter(|prefix| { - should_insert_common_prefix( - prefix, - &request.input, - results.replacement_span.start(), - results.replacement_span.distance(), - ) - }) - .and_then(|prefix| { + }; + let Some(session) = self.active_session.as_ref(ctx).session(ctx) else { + return; + }; + let path_separators = session.path_separators(); + let decision = results.explicit_tab_completion(query, path_separators.all); + let (suggestions, replacement_span, menu_input) = match decision { + ExplicitTabCompletion::NoAction => return, + ExplicitTabCompletion::InsertSingle { + suggestion, + replacement_span, + } => { + let acceptance = TuiCompletionAcceptance { + replacement: suggestion.suggestion.replacement.to_string(), + replacement_range: replacement_span.start()..replacement_span.end(), + append_space: request.input.cursor_byte_offset + == request.input.buffer_text.len() + && suggestion.suggestion.file_type != Some(EngineFileType::Directory), + }; + self.input_view.update(ctx, |input, ctx| { + input.apply_shell_completion(acceptance, ctx) + }); + return; + } + ExplicitTabCompletion::InsertCommonPrefixAndOpen { + common_prefix, + suggestions, + replacement_span, + } => { let acceptance = TuiCompletionAcceptance { - replacement: prefix, - replacement_range: replacement_range.clone(), + replacement: common_prefix, + replacement_range: replacement_span.start()..replacement_span.end(), append_space: false, }; let did_apply = self.input_view.update(ctx, |input, ctx| { input.apply_shell_completion(acceptance, ctx) }); - did_apply.then(|| self.input_view.as_ref(ctx).completion_snapshot(ctx))? - }) - .unwrap_or_else(|| request.input.clone()); - let menu_replacement_range = - results.replacement_span.start()..menu_input.cursor_byte_offset; + let menu_input = did_apply + .then(|| self.input_view.as_ref(ctx).completion_snapshot(ctx)) + .flatten() + .unwrap_or_else(|| request.input.clone()); + (suggestions, replacement_span, menu_input) + } + ExplicitTabCompletion::Open { + suggestions, + replacement_span, + } => (suggestions, replacement_span, request.input.clone()), + }; + let menu_replacement_range = replacement_span.start()..menu_input.cursor_byte_offset; let append_space_at_buffer_end = menu_input.cursor_byte_offset == menu_input.buffer_text.len(); self.completion_request.menu_snapshot = Some(menu_input); self.completion_menu.update(ctx, |menu, ctx| { menu.show( - results.suggestions, + suggestions, menu_replacement_range, append_space_at_buffer_end, ctx, @@ -241,21 +292,6 @@ fn completion_request_is_current( && !has_active_inline_menu } -fn should_insert_common_prefix( - common_prefix: &str, - input: &TuiCompletionInputSnapshot, - replacement_start: usize, - replacement_distance: usize, -) -> bool { - let Some(current_word) = input - .buffer_text - .get(replacement_start..input.cursor_byte_offset) - else { - return false; - }; - common_prefix.len() > replacement_distance && common_prefix.starts_with(current_word) -} - #[cfg(test)] #[path = "completions_tests.rs"] mod tests; diff --git a/crates/warp_tui/src/terminal_session_view/completions_tests.rs b/crates/warp_tui/src/terminal_session_view/completions_tests.rs index acf358c8d5b..f795fb45581 100644 --- a/crates/warp_tui/src/terminal_session_view/completions_tests.rs +++ b/crates/warp_tui/src/terminal_session_view/completions_tests.rs @@ -7,28 +7,6 @@ fn snapshot(buffer_text: &str, cursor_byte_offset: usize) -> TuiCompletionInputS } } -#[test] -fn common_prefix_extends_only_the_current_backend_span() { - assert!(should_insert_common_prefix( - "checkout", - &snapshot("git che", 7), - 4, - 3, - )); - assert!(!should_insert_common_prefix( - "branch", - &snapshot("git che", 7), - 4, - 3, - )); - assert!(!should_insert_common_prefix( - "che", - &snapshot("git che", 7), - 4, - 3, - )); -} - #[test] fn completion_requests_reject_every_stale_snapshot_dimension() { let input = snapshot("git che", 7); @@ -93,19 +71,3 @@ fn completion_requests_reject_every_stale_snapshot_dimension() { assert!(!is_current); } } - -#[test] -fn common_prefix_rejects_invalid_utf8_or_out_of_bounds_spans() { - assert!(!should_insert_common_prefix( - "éclair", - &snapshot("é", "é".len()), - 1, - 1, - )); - assert!(!should_insert_common_prefix( - "echo", - &snapshot("ec", 2), - 3, - 1, - )); -} diff --git a/crates/warp_tui/src/terminal_session_view_tests.rs b/crates/warp_tui/src/terminal_session_view_tests.rs index 5ea8b109573..0e7315cc16e 100644 --- a/crates/warp_tui/src/terminal_session_view_tests.rs +++ b/crates/warp_tui/src/terminal_session_view_tests.rs @@ -21,7 +21,7 @@ use warp::tui_export::{ AIConversationAutoexecuteMode, AIConversationId, AgentViewEntryOrigin, BlockPadding, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, ConversationStatus, ConversationUsageTotals, Harness, InputTypeAutoDetectionSource, LLMPreferences, LinkedWorkflowData, - LongRunningCommandControlState, PtyIntent, PtyIntentEvent, SizeInfo, SizeUpdate, + LongRunningCommandControlState, PtyIntent, PtyIntentEvent, Session, SizeInfo, SizeUpdate, SlashCommandDataSource as _, SlashCommandKind, TaskId, TranscriptScope, TuiMcpAction, TuiMcpServerId, TuiOnboardingMarker, TuiOnboardingMarkers, TuiUpArrowHistoryItemKind, UserTakeOverReason, WarpConfig, WarpConfigUpdateEvent, export_conversation_markdown, @@ -863,6 +863,27 @@ fn shell_mode_reserves_tab_even_when_attachments_render() { assert!(!attachment_focus_available(true, true)); assert!(!attachment_focus_available(false, false)); } +#[test] +fn shell_completion_source_warmup_loads_path_executables() { + App::test((), |mut app| async move { + let fixture = focus_test_fixture(&mut app); + let (view, _) = add_focus_test_session(&mut app, &fixture, true); + let session = Arc::new(Session::test()); + + view.update(&mut app, |view, ctx| { + view.warm_shell_completion_sources(session.clone(), ctx); + }); + + let deadline = Instant::now() + Duration::from_secs(5); + while !session.has_loaded_external_commands() && Instant::now() < deadline { + Timer::after(Duration::from_millis(10)).await; + } + + assert!(session.has_attempted_to_load_external_commands()); + assert!(session.has_loaded_external_commands()); + assert!(session.executable_names().any(|command| command == "git")); + }); +} #[test] fn nld_reset_only_unlocks_after_agent_control_and_not_on_user_edit() { @@ -4863,6 +4884,33 @@ fn user_input_event_projects_to_raw_user_bytes() { }; assert_eq!(&*bytes, b"hello\r"); } + +#[test] +fn native_completion_event_projects_to_pty_request() { + let (results_tx, results_rx) = async_channel::unbounded(); + let event = TuiTerminalSessionEvent::RunNativeShellCompletions { + buffer_text: "git che".to_owned(), + results_tx, + }; + let Some(PtyIntent::RunNativeShellCompletions { + buffer_text, + results_tx, + }) = event.pty_intent() + else { + panic!("native completion event should map to a PTY request"); + }; + + assert_eq!(buffer_text, "git che"); + results_tx + .try_send(Vec::new()) + .expect("projected sender should remain connected"); + assert!( + results_rx + .try_recv() + .expect("original receiver should receive projected results") + .is_empty() + ); +} #[test] fn running_command_attachment_bindings_are_context_scoped() { App::test((), |mut app| async move {