From 1138305f9c77e0c4ac3dee1767654f4fd6aae722 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:03:31 +0000 Subject: [PATCH 1/4] Command search: add setting to disable fuzzy matching (APP-5818) Adds a Terminal Input toggle that falls back to a literal, case-insensitive substring search (bash/zsh's reverse-history-search semantics) instead of Skim fuzzy matching, independent of HistorySearchRankingV2. --- .../history/history_data_source.rs | 52 ++- app/src/search/command_search/history/rank.rs | 42 +++ .../command_search/history/rank_tests.rs | 57 ++++ .../search/command_search/searcher_tests.rs | 310 +++++++++++++++++- app/src/settings/input.rs | 10 + app/src/settings_view/features_page.rs | 106 +++++- app/src/settings_view/mod.rs | 1 + app/src/workspace/view.rs | 6 + 8 files changed, 571 insertions(+), 13 deletions(-) diff --git a/app/src/search/command_search/history/history_data_source.rs b/app/src/search/command_search/history/history_data_source.rs index 7295222a592..33895ea60b5 100644 --- a/app/src/search/command_search/history/history_data_source.rs +++ b/app/src/search/command_search/history/history_data_source.rs @@ -12,7 +12,7 @@ use crate::search::async_snapshot_data_source::AsyncSnapshotDataSource; use crate::search::command_search::searcher::CommandSearchItemAction; use crate::search::data_source::{Query, QueryResult}; use crate::search::mixer::{BoxFuture, DataSourceRunErrorWrapper}; -use crate::settings::AISettings; +use crate::settings::{AISettings, InputSettings}; use crate::terminal; use crate::terminal::HistoryEntry; use crate::terminal::model::session::SessionId; @@ -23,12 +23,23 @@ pub(crate) struct HistorySnapshot { commands: Arc<[Arc]>, query_text: String, current_session_id: SessionId, + fuzzy_matching_enabled: bool, } /// Creates an async data source for shell history commands. #[cfg(test)] pub fn history_data_source( commands: Vec, +) -> AsyncSnapshotDataSource { + history_data_source_with_fuzzy_matching(commands, true) +} + +/// Test-only variant of [`history_data_source`] with an explicit fuzzy-matching setting, for +/// exercising the literal-substring path in [`fuzzy_match_history_literal`]. +#[cfg(test)] +pub fn history_data_source_with_fuzzy_matching( + commands: Vec, + fuzzy_matching_enabled: bool, ) -> AsyncSnapshotDataSource { let commands: Arc<[Arc]> = commands.into_iter().map(Arc::new).collect(); AsyncSnapshotDataSource::new( @@ -36,6 +47,7 @@ pub fn history_data_source( commands: commands.clone(), query_text: query.text.clone(), current_session_id: SessionId::from(0), + fuzzy_matching_enabled, }, fuzzy_match_history, ) @@ -57,6 +69,8 @@ pub(crate) fn history_data_source_for_session( commands, query_text: query.text.clone(), current_session_id: session_id, + fuzzy_matching_enabled: *InputSettings::as_ref(app) + .command_search_fuzzy_matching_enabled, } }, fuzzy_match_history, @@ -67,6 +81,10 @@ pub(crate) fn fuzzy_match_history( snapshot: HistorySnapshot, ) -> BoxFuture<'static, Result>, DataSourceRunErrorWrapper>> { + if !snapshot.fuzzy_matching_enabled { + return fuzzy_match_history_literal(snapshot); + } + if !FeatureFlag::HistorySearchRankingV2.is_enabled() { return fuzzy_match_history_legacy(snapshot); } @@ -143,3 +161,35 @@ fn fuzzy_match_history_legacy( Ok(results) }) } + +fn fuzzy_match_history_literal( + snapshot: HistorySnapshot, +) -> BoxFuture<'static, Result>, DataSourceRunErrorWrapper>> +{ + Box::pin(async move { + let mut results = Vec::new(); + let query = snapshot.query_text.trim(); + let score = rank::literal_match_score(query); + + for chunk in snapshot.commands.chunks(CHUNK_SIZE) { + for entry in chunk { + let Some(match_result) = rank::match_literal_substring(&entry.command, query) + else { + continue; + }; + + results.push( + HistorySearchItem { + entry: entry.clone(), + match_result, + score, + } + .into(), + ); + } + yield_now().await; + } + + Ok(results) + }) +} diff --git a/app/src/search/command_search/history/rank.rs b/app/src/search/command_search/history/rank.rs index 98bb0f94944..4a18a87185e 100644 --- a/app/src/search/command_search/history/rank.rs +++ b/app/src/search/command_search/history/rank.rs @@ -225,6 +225,48 @@ fn age_days(start_ts: DateTime, now: DateTime) -> f64 { ((now - start_ts).num_seconds() as f64 / seconds_per_day).max(0.0) } +/// Matches `query` against `command` as a literal, contiguous, ASCII-case-insensitive substring +/// rather than as a fuzzy subsequence -- the semantics of bash/zsh's reverse history search. An +/// empty `query` matches everything at the start, the same as `str::contains("")`. +pub(crate) fn match_literal_substring(command: &str, query: &str) -> Option { + let command_chars: Vec = command.chars().collect(); + let query_chars: Vec = query.chars().collect(); + if query_chars.is_empty() { + return Some(FuzzyMatchResult { + score: 0, + matched_indices: vec![], + }); + } + if query_chars.len() > command_chars.len() { + return None; + } + + let start = (0..=command_chars.len() - query_chars.len()).find(|&start| { + command_chars[start..start + query_chars.len()] + .iter() + .zip(&query_chars) + .all(|(command_char, query_char)| command_char.eq_ignore_ascii_case(query_char)) + })?; + + Some(FuzzyMatchResult { + score: 0, + matched_indices: (start..start + query_chars.len()).collect(), + }) +} + +/// Score shared by every literal-substring match for a given `query`: the raw Skim score of +/// matching `query` against itself, i.e. the maximum a query of that length could ever score. +/// Every candidate that passes [`match_literal_substring`] gets this identical, genuinely +/// raw-Skim-scale score, so ties break via the mixer's stable sort, which preserves +/// `History::commands_shared`'s already-chronological candidate order -- ordering results +/// most-recent-first, the way bash/zsh's non-ranking reverse search does, while staying +/// comparable to every other Command Search source's own Skim-based score. +pub(crate) fn literal_match_score(query: &str) -> OrderedFloat { + let score = + fuzzy_match::match_indices_case_insensitive(query, query).map_or(0, |result| result.score); + OrderedFloat(score as f64) +} + #[cfg(test)] #[path = "rank_tests.rs"] mod tests; diff --git a/app/src/search/command_search/history/rank_tests.rs b/app/src/search/command_search/history/rank_tests.rs index 792094b32a2..c28ed84da7e 100644 --- a/app/src/search/command_search/history/rank_tests.rs +++ b/app/src/search/command_search/history/rank_tests.rs @@ -240,3 +240,60 @@ fn blank_query_ignores_priors_and_yields_a_result() { chronological order, not just bypass the score floor" ); } + +#[test] +fn literal_substring_matches_regardless_of_case() { + assert!(match_literal_substring("git STATUS", "status").is_some()); + assert!(match_literal_substring("GIT status", "GIT").is_some()); +} + +#[test] +fn literal_substring_does_not_match_a_subsequence() { + assert!( + match_literal_substring("list docker containers", "ldc").is_none(), + "a fuzzy subsequence match must not count as a literal substring match" + ); +} + +#[test] +fn literal_substring_does_not_tokenize_the_query() { + assert!( + match_literal_substring("cd ~/projects/history_orm", "cd hi orm").is_none(), + "unlike the fuzzy path's whitespace-AND tokenization, the literal query must occur \ + verbatim, not as separately-matched terms" + ); + assert!(match_literal_substring("cd hi orm now", "cd hi orm").is_some()); +} + +#[test] +fn literal_substring_match_indices_span_the_match() { + let result = match_literal_substring("git checkout master", "checkout").expect("should match"); + assert_eq!(result.matched_indices, vec![4, 5, 6, 7, 8, 9, 10, 11]); +} + +#[test] +fn literal_substring_blank_query_matches_with_no_highlight() { + let result = match_literal_substring("anything", "").expect("blank query matches everything"); + assert!(result.matched_indices.is_empty()); +} + +#[test] +fn literal_match_score_is_the_query_matched_against_itself() { + let expected = fuzzy_match::match_indices_case_insensitive("git status", "git status") + .expect("a string should fuzzy-match itself") + .score; + assert_eq!( + literal_match_score("git status"), + OrderedFloat(expected as f64) + ); +} + +#[test] +fn literal_match_score_is_identical_for_every_query_length() { + assert_eq!(literal_match_score("foo"), literal_match_score("foo")); + assert_ne!( + literal_match_score("foo"), + literal_match_score("foo bar baz"), + "a longer query should score higher, since it's matched against itself" + ); +} diff --git a/app/src/search/command_search/searcher_tests.rs b/app/src/search/command_search/searcher_tests.rs index 99e8696d7d8..d0da3a8dce5 100644 --- a/app/src/search/command_search/searcher_tests.rs +++ b/app/src/search/command_search/searcher_tests.rs @@ -19,7 +19,7 @@ use crate::auth::auth_manager::AuthManager; use crate::search::ai_queries::fuzzy_match::FuzzyMatchAIQueryResults; use crate::search::command_search::ai_queries::AIQuerySearchResultItem; use crate::search::command_search::history::{ - history_data_source, history_data_source_for_session, + history_data_source, history_data_source_for_session, history_data_source_with_fuzzy_matching, }; use crate::search::command_search::searcher::CommandSearchMixer; use crate::search::command_search::workflows::{WorkflowIdentity, WorkflowSearchItem}; @@ -496,6 +496,314 @@ fn test_history_score_stays_comparable_to_other_sources_raw_skim_scale() { }); } +#[test] +fn disabled_fuzzy_matching_does_not_tokenize_the_query() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let mixer = app.add_model(|_| CommandSearchMixer::new()); + mixer.update(&mut app, |mixer, ctx| { + mixer.add_async_source( + history_data_source_with_fuzzy_matching( + vec![HistoryEntry::command_only( + "cd ~/projects/history_orm".to_owned(), + )], + false, + ), + HashSet::from([QueryFilter::History]), + AddAsyncSourceOptions { + debounce_interval: None, + run_in_zero_state: false, + run_when_unfiltered: true, + }, + ctx, + ); + mixer.run_query("cd hi orm".into(), ctx); + }); + + assert_eventually!( + app.read(|app| !mixer.as_ref(app).is_loading()), + "the query should finish loading" + ); + + app.read(|app| { + assert!( + mixer.as_ref(app).results().is_empty(), + "disabled fuzzy matching shouldn't AND-tokenize a multi-word query into \ + separately-matched terms, unlike the enabled path" + ); + }); + }); +} + +#[test] +fn disabled_fuzzy_matching_matches_literally_when_v2_ranking_is_enabled() { + let _flag = FeatureFlag::HistorySearchRankingV2.override_enabled(true); + App::test((), |mut app| async move { + initialize_app(&mut app); + let mixer = app.add_model(|_| CommandSearchMixer::new()); + mixer.update(&mut app, |mixer, ctx| { + mixer.add_async_source( + history_data_source_with_fuzzy_matching( + vec![HistoryEntry::command_only("git status".to_owned())], + false, + ), + HashSet::from([QueryFilter::History]), + AddAsyncSourceOptions { + debounce_interval: None, + run_in_zero_state: false, + run_when_unfiltered: true, + }, + ctx, + ); + mixer.run_query("git status".into(), ctx); + }); + + assert_eventually!( + app.read(|app| !mixer.as_ref(app).is_loading()), + "the query should finish loading" + ); + + app.read(|app| { + assert_eq!( + mixer.as_ref(app).results().len(), + 1, + "the disabled setting must short-circuit the V2 ranking path" + ); + }); + }); +} + +#[test] +fn disabled_fuzzy_matching_matches_literally_when_v2_ranking_is_disabled() { + let _flag = FeatureFlag::HistorySearchRankingV2.override_enabled(false); + App::test((), |mut app| async move { + initialize_app(&mut app); + let mixer = app.add_model(|_| CommandSearchMixer::new()); + mixer.update(&mut app, |mixer, ctx| { + mixer.add_async_source( + history_data_source_with_fuzzy_matching( + vec![HistoryEntry::command_only("git status".to_owned())], + false, + ), + HashSet::from([QueryFilter::History]), + AddAsyncSourceOptions { + debounce_interval: None, + run_in_zero_state: false, + run_when_unfiltered: true, + }, + ctx, + ); + mixer.run_query("git status".into(), ctx); + }); + + assert_eventually!( + app.read(|app| !mixer.as_ref(app).is_loading()), + "the query should finish loading" + ); + + app.read(|app| { + assert_eq!( + mixer.as_ref(app).results().len(), + 1, + "the disabled setting must short-circuit the legacy raw-Skim path too, not just V2" + ); + }); + }); +} + +#[test] +fn disabled_fuzzy_matching_orders_results_most_recent_first() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let mixer = app.add_model(|_| CommandSearchMixer::new()); + mixer.update(&mut app, |mixer, ctx| { + mixer.add_async_source( + // "git status" is a whole-line exact match while "sudo git status" merely + // contains it; the fuzzy-enabled path would score the exact match higher via + // EXACT_WHOLE_LINE_BONUS. The literal path assigns both the same score, so this + // only orders correctly if it's actually falling back to insertion order. + history_data_source_with_fuzzy_matching( + vec![ + HistoryEntry::command_only("git status".to_owned()), + HistoryEntry::command_only("sudo git status".to_owned()), + ], + false, + ), + HashSet::from([QueryFilter::History]), + AddAsyncSourceOptions { + debounce_interval: None, + run_in_zero_state: false, + run_when_unfiltered: true, + }, + ctx, + ); + mixer.run_query("git status".into(), ctx); + }); + + assert_eventually!( + app.read(|app| !mixer.as_ref(app).is_loading()), + "the query should finish loading" + ); + + app.read(|app| { + let results = mixer.as_ref(app).results(); + assert_eq!(results.len(), 2); + assert!( + matches!( + results.last().map(|result| result.accept_result()), + Some(CommandSearchItemAction::AcceptHistory(AcceptedHistoryItem { + command, + .. + })) if command == "sudo git status" + ), + "the more recently-run command should rank highest despite scoring no higher, \ + the way bash/zsh's reverse search orders purely by recency" + ); + }); + }); +} + +#[test] +fn disabled_fuzzy_matching_score_stays_comparable_to_other_sources_raw_skim_scale() { + App::test((), |mut app| async move { + initialize_app(&mut app); + + let history_command = "npm test -- widgets".to_owned(); + let weak_match_text = "archive old logs then send email summary tonight"; + + let literal_score = fuzzy_match::match_indices_case_insensitive("test", "test") + .expect("a query should fuzzy-match itself") + .score; + let weak_raw_score = fuzzy_match::match_indices_case_insensitive(weak_match_text, "test") + .expect("the weak match text should fuzzy-match \"test\"") + .score; + assert!( + weak_raw_score < literal_score, + "fixture premise: the competitor's raw Skim score must be lower than a query's \ + self-match score (weak={weak_raw_score}, literal={literal_score})" + ); + + let weak_workflow = Workflow::Command { + name: "Unrelated maintenance task".to_owned(), + command: weak_match_text.to_owned(), + tags: vec![], + description: None, + arguments: vec![], + source_url: None, + author: None, + author_url: None, + shells: vec![], + environment_variables: None, + }; + let fuzzy_matched_workflow = + FuzzyMatchWorkflowResult::try_match("test", &weak_workflow, "") + .expect("the workflow's command should fuzzy-match \"test\""); + let workflow_item = WorkflowSearchItem { + identity: WorkflowIdentity::Local(Box::new(WorkflowType::Local(weak_workflow))), + source: WorkflowSource::Local, + fuzzy_matched_workflow, + }; + + let mixer = app.add_model(|_| CommandSearchMixer::new()); + mixer.update(&mut app, |mixer, ctx| { + mixer.add_sync_source( + FixedResults(vec![workflow_item]), + HashSet::from([QueryFilter::Workflows]), + ); + mixer.add_async_source( + history_data_source_with_fuzzy_matching( + vec![HistoryEntry::command_only(history_command.clone())], + false, + ), + HashSet::from([QueryFilter::History]), + AddAsyncSourceOptions { + debounce_interval: None, + run_in_zero_state: false, + run_when_unfiltered: true, + }, + ctx, + ); + mixer.run_query("test".into(), ctx); + }); + + assert_eventually!( + app.read(|app| !mixer.as_ref(app).is_loading()), + "the query should finish loading" + ); + + app.read(|app| { + let results = mixer.as_ref(app).results(); + assert_eq!(results.len(), 2); + assert!( + matches!( + results.last().map(|result| result.accept_result()), + Some(CommandSearchItemAction::AcceptHistory(AcceptedHistoryItem { + command, + .. + })) if command == history_command + ), + "a literal-substring history match should still outrank a weaker fuzzy match \ + from another source, proving its score stays on the same raw-Skim scale rather \ + than e.g. being pinned to a fixed low value" + ); + }); + }); +} + +#[test] +fn disabled_fuzzy_matching_still_populates_the_zero_state() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let same_ts = Local::now(); + let mut older = HistoryEntry::command_only("git status".to_owned()); + older.start_ts = Some(same_ts); + let mut newer = HistoryEntry::command_only("git log".to_owned()); + newer.start_ts = Some(same_ts); + + let mixer = app.add_model(|_| CommandSearchMixer::new()); + mixer.update(&mut app, |mixer, ctx| { + mixer.add_async_source( + history_data_source_with_fuzzy_matching(vec![older, newer], false), + HashSet::from([QueryFilter::History]), + AddAsyncSourceOptions { + debounce_interval: None, + run_in_zero_state: true, + run_when_unfiltered: true, + }, + ctx, + ); + mixer.run_query( + Query { + text: "".to_owned(), + filters: HashSet::new(), + }, + ctx, + ); + }); + + assert_eventually!( + app.read(|app| !mixer.as_ref(app).is_loading()), + "the history query should finish loading" + ); + + app.read(|app| { + let results = mixer.as_ref(app).results(); + assert_eq!( + results.len(), + 2, + "history should still populate the zero state when fuzzy matching is disabled" + ); + assert!(matches!( + results.last().map(|result| result.accept_result()), + Some(CommandSearchItemAction::AcceptHistory(AcceptedHistoryItem { + command, + .. + })) if command == "git log" + )); + }); + }); +} + #[test] fn test_no_query_filter_runs_all_data_sources() { let _flag = FeatureFlag::HistorySearchRankingV2.override_enabled(true); diff --git a/app/src/settings/input.rs b/app/src/settings/input.rs index 7fc5a27b4d6..3e64c1a6aca 100644 --- a/app/src/settings/input.rs +++ b/app/src/settings/input.rs @@ -229,6 +229,16 @@ define_settings_group!(InputSettings, surface: settings::SettingSurfaces::GUI, private: true, }, + command_search_fuzzy_matching_enabled: CommandSearchFuzzyMatchingEnabled { + type: bool, + default: true, + supported_platforms: SupportedPlatforms::ALL, + sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes), + surface: settings::SettingSurfaces::GUI, + private: false, + toml_path: "terminal.input.command_search_fuzzy_matching_enabled", + description: "Whether Command Search (Ctrl+R) uses fuzzy matching, or falls back to a literal, case-insensitive substring search like bash/zsh's reverse history search.", + }, ] ); diff --git a/app/src/settings_view/features_page.rs b/app/src/settings_view/features_page.rs index 1ca19482b78..9a480c6435c 100644 --- a/app/src/settings_view/features_page.rs +++ b/app/src/settings_view/features_page.rs @@ -67,17 +67,17 @@ use crate::settings::{ AISettingsChangedEvent, AliasExpansionEnabled, AliasExpansionSettings, AppEditorSettings, AtContextMenuInTerminalMode, AutocompleteSymbols, AutosuggestionKeybindingHint, ChangelogSettings, CloudPreferencesSettings, CodeSettings, CommandCorrections, - CompletionsOpenWhileTyping, CopyOnSelect, CtrlTabBehavior, DEFAULT_QUAKE_MODE_SIZE_PERCENTAGES, - DefaultSessionMode, EnableSlashCommandsInTerminal, ErrorUnderliningEnabled, ExtraMetaKeys, - GPUSettings, GlobalHotkeyMode, InputSettings, InputSettingsChangedEvent, - LinuxSelectionClipboard, MiddleClickPasteEnabled, MouseScrollMultiplier, - NativeShellCompletionsEnabled, OutlineCodebaseSymbolsForAtContextMenu, PreferLowPowerGPU, - PreferredGraphicsBackend, QUAKE_WINDOW_AUTOHIDE_SUPPORTED, QuakeModeSettings, - RightClickBehavior, RightClickBehaviorSetting, ScrollSettings, ScrollSettingsChangedEvent, - SelectionSettings, SelectionSettingsChangedEvent, ShowAutosuggestionIgnoreButton, - ShowChangelogAfterUpdate, ShowTerminalInputMessageBar, SshSettings, SyntaxHighlighting, - TabBehavior, UserNativeRedirectPreference, VimModeEnabled, VimStatusBar, - VimUnnamedSystemClipboard, WarpCompletionsEnabled, + CommandSearchFuzzyMatchingEnabled, CompletionsOpenWhileTyping, CopyOnSelect, CtrlTabBehavior, + DEFAULT_QUAKE_MODE_SIZE_PERCENTAGES, DefaultSessionMode, EnableSlashCommandsInTerminal, + ErrorUnderliningEnabled, ExtraMetaKeys, GPUSettings, GlobalHotkeyMode, InputSettings, + InputSettingsChangedEvent, LinuxSelectionClipboard, MiddleClickPasteEnabled, + MouseScrollMultiplier, NativeShellCompletionsEnabled, OutlineCodebaseSymbolsForAtContextMenu, + PreferLowPowerGPU, PreferredGraphicsBackend, QUAKE_WINDOW_AUTOHIDE_SUPPORTED, + QuakeModeSettings, RightClickBehavior, RightClickBehaviorSetting, ScrollSettings, + ScrollSettingsChangedEvent, SelectionSettings, SelectionSettingsChangedEvent, + ShowAutosuggestionIgnoreButton, ShowChangelogAfterUpdate, ShowTerminalInputMessageBar, + SshSettings, SyntaxHighlighting, TabBehavior, UserNativeRedirectPreference, VimModeEnabled, + VimStatusBar, VimUnnamedSystemClipboard, WarpCompletionsEnabled, }; use crate::terminal::alt_screen_reporting::{ AltScreenReporting, FocusReportingEnabled, MouseReportingEnabled, ScrollReportingEnabled, @@ -257,6 +257,19 @@ pub fn init_actions_from_parent_view( .command_corrections .is_supported_on_current_platform(), ), + ToggleSettingActionPair::new( + "fuzzy matching in command search", + builder(SettingsAction::FeaturesPageToggle( + FeaturesPageAction::ToggleCommandSearchFuzzyMatching, + )), + context, + flags::COMMAND_SEARCH_FUZZY_MATCHING_FLAG, + ) + .is_supported_on_current_platform( + InputSettings::as_ref(app) + .command_search_fuzzy_matching_enabled + .is_supported_on_current_platform(), + ), ToggleSettingActionPair::new( "error underlining", builder(SettingsAction::FeaturesPageToggle( @@ -760,6 +773,7 @@ pub enum FeaturesPageAction { ToggleWarpCompletions, ToggleNativeShellCompletions, ToggleCommandCorrections, + ToggleCommandSearchFuzzyMatching, ToggleErrorUnderlining, ToggleSyntaxHighlighting, ToggleAliasExpansion, @@ -976,6 +990,10 @@ impl FeaturesPageAction { action: "ToggleCommandCorrections".to_string(), value: to_string(*input_settings.command_corrections.value()), }, + Self::ToggleCommandSearchFuzzyMatching => TelemetryEvent::FeaturesPageAction { + action: "ToggleCommandSearchFuzzyMatching".to_string(), + value: to_string(*input_settings.command_search_fuzzy_matching_enabled.value()), + }, Self::ToggleErrorUnderlining => TelemetryEvent::FeaturesPageAction { action: "ToggleErrorUnderlining".to_string(), value: to_string(*input_settings.error_underlining.value()), @@ -1895,6 +1913,15 @@ impl TypedActionView for FeaturesPageView { ); }); } + ToggleCommandSearchFuzzyMatching => { + InputSettings::handle(ctx).update(ctx, |input_settings, ctx| { + report_if_error!( + input_settings + .command_search_fuzzy_matching_enabled + .toggle_and_save_value(ctx) + ); + }); + } ToggleErrorUnderlining => { InputSettings::handle(ctx).update(ctx, |input_settings, ctx| { report_if_error!(input_settings.error_underlining.toggle_and_save_value(ctx)); @@ -2949,6 +2976,13 @@ impl FeaturesPageView { editor_widgets.push(Box::new(CommandCorrectionsWidget::default())); } + if input_settings + .command_search_fuzzy_matching_enabled + .is_supported_on_current_platform() + { + editor_widgets.push(Box::new(CommandSearchFuzzyMatchingWidget::default())); + } + let alias_expansion_settings = AliasExpansionSettings::as_ref(ctx); if alias_expansion_settings .alias_expansion_enabled @@ -6141,6 +6175,56 @@ impl SettingsWidget for CommandCorrectionsWidget { } } +#[derive(Default)] +struct CommandSearchFuzzyMatchingWidget { + switch_state: SwitchStateHandle, +} + +impl SettingsWidget for CommandSearchFuzzyMatchingWidget { + type View = FeaturesPageView; + + fn search_terms(&self) -> &str { + "command search fuzzy matching ctrl+r bash zsh literal substring history" + } + + fn render( + &self, + view: &Self::View, + appearance: &Appearance, + app: &AppContext, + ) -> Box { + let ui_builder = appearance.ui_builder(); + render_body_item::( + "Fuzzy match in Command Search".into(), + None, + LocalOnlyIconState::for_setting( + CommandSearchFuzzyMatchingEnabled::storage_key(), + CommandSearchFuzzyMatchingEnabled::sync_to_cloud(), + &mut view + .button_mouse_states + .local_only_icon_tooltip_states + .borrow_mut(), + app, + ), + ToggleState::Enabled, + appearance, + ui_builder + .switch(self.switch_state.clone()) + .check(*InputSettings::as_ref(app).command_search_fuzzy_matching_enabled) + .build() + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(FeaturesPageAction::ToggleCommandSearchFuzzyMatching); + }) + .finish(), + Some( + "When disabled, Command Search falls back to a literal substring search, like \ + bash and zsh's history search." + .to_owned(), + ), + ) + } +} + #[derive(Default)] struct AliasExpansionWidget { switch_state: SwitchStateHandle, diff --git a/app/src/settings_view/mod.rs b/app/src/settings_view/mod.rs index d58ba4168da..33a8904e932 100644 --- a/app/src/settings_view/mod.rs +++ b/app/src/settings_view/mod.rs @@ -525,6 +525,7 @@ pub mod flags { pub const WARP_COMPLETIONS_CONTEXT_FLAG: &str = "Warp_Completions"; pub const NATIVE_SHELL_COMPLETIONS_CONTEXT_FLAG: &str = "Native_Shell_Completions"; pub const COMMAND_CORRECTIONS_CONTEXT_FLAG: &str = "Command_Corrections"; + pub const COMMAND_SEARCH_FUZZY_MATCHING_FLAG: &str = "Command_Search_Fuzzy_Matching"; pub const ERROR_UNDERLINING_FLAG: &str = "error_underlining"; pub const SYNTAX_HIGHLIGHTING_FLAG: &str = "syntax_highlighting"; pub const SAME_LINE_PROMPT: &str = "Same_Line_Prompt_Enabled"; diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 2876acf6f1a..3824bb61fa1 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -23058,6 +23058,12 @@ impl Workspace { context.set.insert(flags::COMMAND_CORRECTIONS_CONTEXT_FLAG); } + if *input_settings.command_search_fuzzy_matching_enabled.value() { + context + .set + .insert(flags::COMMAND_SEARCH_FUZZY_MATCHING_FLAG); + } + if *input_settings.error_underlining.value() { context.set.insert(flags::ERROR_UNDERLINING_FLAG); } From 0c8d3697e1ae927bedda6a2b88c8769b2d5c02ca Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:20:33 +0000 Subject: [PATCH 2/4] Halve the disabled-fuzzy shared score so history doesn't dominate strong competitors --- app/src/search/command_search/history/rank.rs | 18 ++++-- .../command_search/history/rank_tests.rs | 6 +- .../search/command_search/searcher_tests.rs | 63 +++++++++++++++++++ 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/app/src/search/command_search/history/rank.rs b/app/src/search/command_search/history/rank.rs index 4a18a87185e..d7e09ecbd57 100644 --- a/app/src/search/command_search/history/rank.rs +++ b/app/src/search/command_search/history/rank.rs @@ -41,6 +41,14 @@ const CONSECUTIVE_BONUS_PER_CHAR: f64 = 4.0; /// prefix of a longer one. const EXACT_WHOLE_LINE_BONUS: f64 = 12.0; +/// Fraction of a query's self-match score (see `literal_match_score`) used as every +/// literal-substring match's shared score. The full self-match score is the ceiling for a query +/// of that length -- using it outright would let a merely-containing history match tie the best +/// possible match from every other Command Search source. `0.5` instead matches the highest +/// per-field weight `FuzzyMatchWorkflowResult` already gives its own strongest field, so a +/// literal match competes with, rather than always beating, an equally strong match elsewhere. +const LITERAL_MATCH_SCORE_FRACTION: f64 = 0.5; + /// Recency assigned to entries with no timestamp (history-file rows with no matching sqlite /// record), i.e. exactly between "as fresh as possible" (1.0) and "as stale as possible" (0.0): /// there's no data to justify treating an untracked entry as either. @@ -254,17 +262,17 @@ pub(crate) fn match_literal_substring(command: &str, query: &str) -> Option OrderedFloat { - let score = + let self_match_score = fuzzy_match::match_indices_case_insensitive(query, query).map_or(0, |result| result.score); - OrderedFloat(score as f64) + OrderedFloat(self_match_score as f64 * LITERAL_MATCH_SCORE_FRACTION) } #[cfg(test)] diff --git a/app/src/search/command_search/history/rank_tests.rs b/app/src/search/command_search/history/rank_tests.rs index c28ed84da7e..1f3e6ff0a64 100644 --- a/app/src/search/command_search/history/rank_tests.rs +++ b/app/src/search/command_search/history/rank_tests.rs @@ -278,13 +278,13 @@ fn literal_substring_blank_query_matches_with_no_highlight() { } #[test] -fn literal_match_score_is_the_query_matched_against_itself() { - let expected = fuzzy_match::match_indices_case_insensitive("git status", "git status") +fn literal_match_score_is_half_the_query_matched_against_itself() { + let self_match_score = fuzzy_match::match_indices_case_insensitive("git status", "git status") .expect("a string should fuzzy-match itself") .score; assert_eq!( literal_match_score("git status"), - OrderedFloat(expected as f64) + OrderedFloat(self_match_score as f64 * 0.5) ); } diff --git a/app/src/search/command_search/searcher_tests.rs b/app/src/search/command_search/searcher_tests.rs index d0da3a8dce5..34433a131a7 100644 --- a/app/src/search/command_search/searcher_tests.rs +++ b/app/src/search/command_search/searcher_tests.rs @@ -750,6 +750,69 @@ fn disabled_fuzzy_matching_score_stays_comparable_to_other_sources_raw_skim_scal }); } +#[test] +fn disabled_fuzzy_matching_does_not_unconditionally_outrank_a_strong_competitor() { + App::test((), |mut app| async move { + initialize_app(&mut app); + + // The history command only contains "test" in passing; the AI query text is an exact + // match. If disabled-mode history scored at its own self-match ceiling instead of a + // fraction of it, this merely-containing match would tie the strongest possible + // competitor from every other source, regardless of how well that competitor matches. + let history_command = "run integration tests for module".to_owned(); + let strong_ai_query = "test".to_owned(); + + let ai_prompt_item = AIQuerySearchResultItem { + query_text: strong_ai_query.clone(), + start_time: Local::now(), + output_status: AIQueryHistoryOutputStatus::Completed, + working_directory: None, + fuzzy_match_results: FuzzyMatchAIQueryResults::try_match("test", &strong_ai_query) + .expect("an exact match should fuzzy-match itself"), + }; + + let mixer = app.add_model(|_| CommandSearchMixer::new()); + mixer.update(&mut app, |mixer, ctx| { + mixer.add_sync_source( + FixedResults(vec![ai_prompt_item]), + HashSet::from([QueryFilter::PromptHistory]), + ); + mixer.add_async_source( + history_data_source_with_fuzzy_matching( + vec![HistoryEntry::command_only(history_command)], + false, + ), + HashSet::from([QueryFilter::History]), + AddAsyncSourceOptions { + debounce_interval: None, + run_in_zero_state: false, + run_when_unfiltered: true, + }, + ctx, + ); + mixer.run_query("test".into(), ctx); + }); + + assert_eventually!( + app.read(|app| !mixer.as_ref(app).is_loading()), + "the query should finish loading" + ); + + app.read(|app| { + let results = mixer.as_ref(app).results(); + assert_eq!(results.len(), 2); + assert!( + !matches!( + results.last().map(|result| result.accept_result()), + Some(CommandSearchItemAction::AcceptHistory(_)) + ), + "an exact match from another source should still outrank a merely-containing \ + literal history match" + ); + }); + }); +} + #[test] fn disabled_fuzzy_matching_still_populates_the_zero_state() { App::test((), |mut app| async move { From a33dafadad2186775f61a9799c5105aaaffc73b6 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:24:46 +0000 Subject: [PATCH 3/4] Scope fuzzy-matching toggle copy to command history explicitly --- app/src/settings/input.rs | 2 +- app/src/settings_view/features_page.rs | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/src/settings/input.rs b/app/src/settings/input.rs index 3e64c1a6aca..b9cbc4343e4 100644 --- a/app/src/settings/input.rs +++ b/app/src/settings/input.rs @@ -237,7 +237,7 @@ define_settings_group!(InputSettings, surface: settings::SettingSurfaces::GUI, private: false, toml_path: "terminal.input.command_search_fuzzy_matching_enabled", - description: "Whether Command Search (Ctrl+R) uses fuzzy matching, or falls back to a literal, case-insensitive substring search like bash/zsh's reverse history search.", + description: "Whether command history results in Command Search (Ctrl+R) use fuzzy matching, or fall back to a literal, case-insensitive substring search like bash/zsh's reverse history search. Other Command Search results are unaffected.", }, ] ); diff --git a/app/src/settings_view/features_page.rs b/app/src/settings_view/features_page.rs index 9a480c6435c..d47dbba74c2 100644 --- a/app/src/settings_view/features_page.rs +++ b/app/src/settings_view/features_page.rs @@ -258,7 +258,7 @@ pub fn init_actions_from_parent_view( .is_supported_on_current_platform(), ), ToggleSettingActionPair::new( - "fuzzy matching in command search", + "fuzzy matching of command history in command search", builder(SettingsAction::FeaturesPageToggle( FeaturesPageAction::ToggleCommandSearchFuzzyMatching, )), @@ -6184,7 +6184,7 @@ impl SettingsWidget for CommandSearchFuzzyMatchingWidget { type View = FeaturesPageView; fn search_terms(&self) -> &str { - "command search fuzzy matching ctrl+r bash zsh literal substring history" + "command search history fuzzy matching ctrl+r bash zsh literal substring" } fn render( @@ -6195,7 +6195,7 @@ impl SettingsWidget for CommandSearchFuzzyMatchingWidget { ) -> Box { let ui_builder = appearance.ui_builder(); render_body_item::( - "Fuzzy match in Command Search".into(), + "Fuzzy match command history in Command Search".into(), None, LocalOnlyIconState::for_setting( CommandSearchFuzzyMatchingEnabled::storage_key(), @@ -6217,8 +6217,9 @@ impl SettingsWidget for CommandSearchFuzzyMatchingWidget { }) .finish(), Some( - "When disabled, Command Search falls back to a literal substring search, like \ - bash and zsh's history search." + "When disabled, command history results in Command Search fall back to a \ + literal substring search, like bash and zsh's history search. Other Command \ + Search results are unaffected." .to_owned(), ), ) From 039ad63382cb2dce7d750feaa002d717ca450d26 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:52:36 +0000 Subject: [PATCH 4/4] Fix vacuous disabled-fuzzy-matching tests with discriminating fixtures Verified each fix by deliberately reintroducing the regression it names (V2 path ignoring the setting, then legacy path ignoring it) and confirming only the matching test goes red, before reverting. --- .../search/command_search/searcher_tests.rs | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/app/src/search/command_search/searcher_tests.rs b/app/src/search/command_search/searcher_tests.rs index 34433a131a7..7d3be31d0d9 100644 --- a/app/src/search/command_search/searcher_tests.rs +++ b/app/src/search/command_search/searcher_tests.rs @@ -498,6 +498,9 @@ fn test_history_score_stays_comparable_to_other_sources_raw_skim_scale() { #[test] fn disabled_fuzzy_matching_does_not_tokenize_the_query() { + // V2 is the path with AND-tokenization; without this override the test would run against + // the legacy default and prove nothing about V2 being short-circuited. + let _flag = FeatureFlag::HistorySearchRankingV2.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let mixer = app.add_model(|_| CommandSearchMixer::new()); @@ -529,14 +532,14 @@ fn disabled_fuzzy_matching_does_not_tokenize_the_query() { assert!( mixer.as_ref(app).results().is_empty(), "disabled fuzzy matching shouldn't AND-tokenize a multi-word query into \ - separately-matched terms, unlike the enabled path" + separately-matched terms, the way V2's enabled tokenization would" ); }); }); } #[test] -fn disabled_fuzzy_matching_matches_literally_when_v2_ranking_is_enabled() { +fn disabled_fuzzy_matching_short_circuits_the_v2_ranking_path() { let _flag = FeatureFlag::HistorySearchRankingV2.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); @@ -555,7 +558,9 @@ fn disabled_fuzzy_matching_matches_literally_when_v2_ranking_is_enabled() { }, ctx, ); - mixer.run_query("git status".into(), ctx); + // "gts" is a fuzzy subsequence of "git status" (g...t...s), which V2's Skim matching + // would find; a literal-substring match would not, since it isn't contiguous. + mixer.run_query("gts".into(), ctx); }); assert_eventually!( @@ -564,17 +569,17 @@ fn disabled_fuzzy_matching_matches_literally_when_v2_ranking_is_enabled() { ); app.read(|app| { - assert_eq!( - mixer.as_ref(app).results().len(), - 1, - "the disabled setting must short-circuit the V2 ranking path" + assert!( + mixer.as_ref(app).results().is_empty(), + "the disabled setting must short-circuit the V2 ranking path, not just leave a \ + fuzzy subsequence match in place" ); }); }); } #[test] -fn disabled_fuzzy_matching_matches_literally_when_v2_ranking_is_disabled() { +fn disabled_fuzzy_matching_short_circuits_the_legacy_ranking_path() { let _flag = FeatureFlag::HistorySearchRankingV2.override_enabled(false); App::test((), |mut app| async move { initialize_app(&mut app); @@ -593,7 +598,9 @@ fn disabled_fuzzy_matching_matches_literally_when_v2_ranking_is_disabled() { }, ctx, ); - mixer.run_query("git status".into(), ctx); + // "gts" is a fuzzy subsequence of "git status" (g...t...s), which the legacy raw-Skim + // path would find; a literal-substring match would not, since it isn't contiguous. + mixer.run_query("gts".into(), ctx); }); assert_eventually!( @@ -602,10 +609,10 @@ fn disabled_fuzzy_matching_matches_literally_when_v2_ranking_is_disabled() { ); app.read(|app| { - assert_eq!( - mixer.as_ref(app).results().len(), - 1, - "the disabled setting must short-circuit the legacy raw-Skim path too, not just V2" + assert!( + mixer.as_ref(app).results().is_empty(), + "the disabled setting must short-circuit the legacy raw-Skim path too, not just \ + leave a fuzzy subsequence match in place" ); }); });