Make word completions less intrusive (#36745)

Kirill Bulatov created

Introduce `min_words_query_len` threshold for automatic word completion
display, and set it to 3 by default.

Re-enable word completions in Markdown and Plaintext.

Release Notes:

- Introduced `min_words_query_len` threshold for automatic word
completion display, and set it to 3 by default to make them less
intrusive

Change summary

assets/settings/default.json                      | 11 +-
crates/copilot/src/copilot_completion_provider.rs |  2 
crates/editor/src/editor.rs                       | 35 ++++++---
crates/editor/src/editor_tests.rs                 | 57 +++++++++++++++++
crates/language/src/language_settings.rs          | 13 ++
docs/src/configuring-zed.md                       | 12 +++
6 files changed, 109 insertions(+), 21 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -1503,6 +1503,11 @@
     //
     // Default: fallback
     "words": "fallback",
+    // Minimum number of characters required to automatically trigger word-based completions.
+    // Before that value, it's still possible to trigger the words-based completion manually with the corresponding editor command.
+    //
+    // Default: 3
+    "words_min_length": 3,
     // Whether to fetch LSP completions or not.
     //
     // Default: true
@@ -1642,9 +1647,6 @@
       "use_on_type_format": false,
       "allow_rewrap": "anywhere",
       "soft_wrap": "editor_width",
-      "completions": {
-        "words": "disabled"
-      },
       "prettier": {
         "allowed": true
       }
@@ -1658,9 +1660,6 @@
       }
     },
     "Plain Text": {
-      "completions": {
-        "words": "disabled"
-      },
       "allow_rewrap": "anywhere"
     },
     "Python": {

crates/copilot/src/copilot_completion_provider.rs 🔗

@@ -301,6 +301,7 @@ mod tests {
         init_test(cx, |settings| {
             settings.defaults.completions = Some(CompletionSettings {
                 words: WordsCompletionMode::Disabled,
+                words_min_length: 0,
                 lsp: true,
                 lsp_fetch_timeout_ms: 0,
                 lsp_insert_mode: LspInsertMode::Insert,
@@ -533,6 +534,7 @@ mod tests {
         init_test(cx, |settings| {
             settings.defaults.completions = Some(CompletionSettings {
                 words: WordsCompletionMode::Disabled,
+                words_min_length: 0,
                 lsp: true,
                 lsp_fetch_timeout_ms: 0,
                 lsp_insert_mode: LspInsertMode::Insert,

crates/editor/src/editor.rs 🔗

@@ -5576,6 +5576,11 @@ impl Editor {
             .as_ref()
             .is_none_or(|query| !query.chars().any(|c| c.is_digit(10)));
 
+        let omit_word_completions = match &query {
+            Some(query) => query.chars().count() < completion_settings.words_min_length,
+            None => completion_settings.words_min_length != 0,
+        };
+
         let (mut words, provider_responses) = match &provider {
             Some(provider) => {
                 let provider_responses = provider.completions(
@@ -5587,9 +5592,11 @@ impl Editor {
                     cx,
                 );
 
-                let words = match completion_settings.words {
-                    WordsCompletionMode::Disabled => Task::ready(BTreeMap::default()),
-                    WordsCompletionMode::Enabled | WordsCompletionMode::Fallback => cx
+                let words = match (omit_word_completions, completion_settings.words) {
+                    (true, _) | (_, WordsCompletionMode::Disabled) => {
+                        Task::ready(BTreeMap::default())
+                    }
+                    (false, WordsCompletionMode::Enabled | WordsCompletionMode::Fallback) => cx
                         .background_spawn(async move {
                             buffer_snapshot.words_in_range(WordsQuery {
                                 fuzzy_contents: None,
@@ -5601,16 +5608,20 @@ impl Editor {
 
                 (words, provider_responses)
             }
-            None => (
-                cx.background_spawn(async move {
-                    buffer_snapshot.words_in_range(WordsQuery {
-                        fuzzy_contents: None,
-                        range: word_search_range,
-                        skip_digits,
+            None => {
+                let words = if omit_word_completions {
+                    Task::ready(BTreeMap::default())
+                } else {
+                    cx.background_spawn(async move {
+                        buffer_snapshot.words_in_range(WordsQuery {
+                            fuzzy_contents: None,
+                            range: word_search_range,
+                            skip_digits,
+                        })
                     })
-                }),
-                Task::ready(Ok(Vec::new())),
-            ),
+                };
+                (words, Task::ready(Ok(Vec::new())))
+            }
         };
 
         let snippet_sort_order = EditorSettings::get_global(cx).snippet_sort_order;

crates/editor/src/editor_tests.rs 🔗

@@ -12237,6 +12237,7 @@ async fn test_completion_mode(cx: &mut TestAppContext) {
                 settings.defaults.completions = Some(CompletionSettings {
                     lsp_insert_mode,
                     words: WordsCompletionMode::Disabled,
+                    words_min_length: 0,
                     lsp: true,
                     lsp_fetch_timeout_ms: 0,
                 });
@@ -12295,6 +12296,7 @@ async fn test_completion_with_mode_specified_by_action(cx: &mut TestAppContext)
     update_test_language_settings(&mut cx, |settings| {
         settings.defaults.completions = Some(CompletionSettings {
             words: WordsCompletionMode::Disabled,
+            words_min_length: 0,
             // set the opposite here to ensure that the action is overriding the default behavior
             lsp_insert_mode: LspInsertMode::Insert,
             lsp: true,
@@ -12331,6 +12333,7 @@ async fn test_completion_with_mode_specified_by_action(cx: &mut TestAppContext)
     update_test_language_settings(&mut cx, |settings| {
         settings.defaults.completions = Some(CompletionSettings {
             words: WordsCompletionMode::Disabled,
+            words_min_length: 0,
             // set the opposite here to ensure that the action is overriding the default behavior
             lsp_insert_mode: LspInsertMode::Replace,
             lsp: true,
@@ -13072,6 +13075,7 @@ async fn test_word_completion(cx: &mut TestAppContext) {
     init_test(cx, |language_settings| {
         language_settings.defaults.completions = Some(CompletionSettings {
             words: WordsCompletionMode::Fallback,
+            words_min_length: 0,
             lsp: true,
             lsp_fetch_timeout_ms: 10,
             lsp_insert_mode: LspInsertMode::Insert,
@@ -13168,6 +13172,7 @@ async fn test_word_completions_do_not_duplicate_lsp_ones(cx: &mut TestAppContext
     init_test(cx, |language_settings| {
         language_settings.defaults.completions = Some(CompletionSettings {
             words: WordsCompletionMode::Enabled,
+            words_min_length: 0,
             lsp: true,
             lsp_fetch_timeout_ms: 0,
             lsp_insert_mode: LspInsertMode::Insert,
@@ -13231,6 +13236,7 @@ async fn test_word_completions_continue_on_typing(cx: &mut TestAppContext) {
     init_test(cx, |language_settings| {
         language_settings.defaults.completions = Some(CompletionSettings {
             words: WordsCompletionMode::Disabled,
+            words_min_length: 0,
             lsp: true,
             lsp_fetch_timeout_ms: 0,
             lsp_insert_mode: LspInsertMode::Insert,
@@ -13304,6 +13310,7 @@ async fn test_word_completions_usually_skip_digits(cx: &mut TestAppContext) {
     init_test(cx, |language_settings| {
         language_settings.defaults.completions = Some(CompletionSettings {
             words: WordsCompletionMode::Fallback,
+            words_min_length: 0,
             lsp: false,
             lsp_fetch_timeout_ms: 0,
             lsp_insert_mode: LspInsertMode::Insert,
@@ -13361,6 +13368,56 @@ async fn test_word_completions_usually_skip_digits(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_word_completions_do_not_show_before_threshold(cx: &mut TestAppContext) {
+    init_test(cx, |language_settings| {
+        language_settings.defaults.completions = Some(CompletionSettings {
+            words: WordsCompletionMode::Enabled,
+            words_min_length: 3,
+            lsp: true,
+            lsp_fetch_timeout_ms: 0,
+            lsp_insert_mode: LspInsertMode::Insert,
+        });
+    });
+
+    let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await;
+    cx.set_state(indoc! {"ˇ
+        wow
+        wowen
+        wowser
+    "});
+    cx.simulate_keystroke("w");
+    cx.executor().run_until_parked();
+    cx.update_editor(|editor, _, _| {
+        if editor.context_menu.borrow_mut().is_some() {
+            panic!(
+                "expected completion menu to be hidden, as words completion threshold is not met"
+            );
+        }
+    });
+
+    cx.simulate_keystroke("o");
+    cx.executor().run_until_parked();
+    cx.update_editor(|editor, _, _| {
+        if editor.context_menu.borrow_mut().is_some() {
+            panic!(
+                "expected completion menu to be hidden, as words completion threshold is not met still"
+            );
+        }
+    });
+
+    cx.simulate_keystroke("w");
+    cx.executor().run_until_parked();
+    cx.update_editor(|editor, _, _| {
+        if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref()
+        {
+            assert_eq!(completion_menu_entries(menu), &["wowen", "wowser"], "After word completion threshold is met, matching words should be shown, excluding the already typed word");
+        } else {
+            panic!("expected completion menu to be open after the word completions threshold is met");
+        }
+    });
+}
+
 fn gen_text_edit(params: &CompletionParams, text: &str) -> Option<lsp::CompletionTextEdit> {
     let position = || lsp::Position {
         line: params.text_document_position.position.line,

crates/language/src/language_settings.rs 🔗

@@ -350,6 +350,12 @@ pub struct CompletionSettings {
     /// Default: `fallback`
     #[serde(default = "default_words_completion_mode")]
     pub words: WordsCompletionMode,
+    /// How many characters has to be in the completions query to automatically show the words-based completions.
+    /// Before that value, it's still possible to trigger the words-based completion manually with the corresponding editor command.
+    ///
+    /// Default: 3
+    #[serde(default = "default_3")]
+    pub words_min_length: usize,
     /// Whether to fetch LSP completions or not.
     ///
     /// Default: true
@@ -359,7 +365,7 @@ pub struct CompletionSettings {
     /// When set to 0, waits indefinitely.
     ///
     /// Default: 0
-    #[serde(default = "default_lsp_fetch_timeout_ms")]
+    #[serde(default)]
     pub lsp_fetch_timeout_ms: u64,
     /// Controls how LSP completions are inserted.
     ///
@@ -405,8 +411,8 @@ fn default_lsp_insert_mode() -> LspInsertMode {
     LspInsertMode::ReplaceSuffix
 }
 
-fn default_lsp_fetch_timeout_ms() -> u64 {
-    0
+fn default_3() -> usize {
+    3
 }
 
 /// The settings for a particular language.
@@ -1468,6 +1474,7 @@ impl settings::Settings for AllLanguageSettings {
             } else {
                 d.completions = Some(CompletionSettings {
                     words: mode,
+                    words_min_length: 3,
                     lsp: true,
                     lsp_fetch_timeout_ms: 0,
                     lsp_insert_mode: LspInsertMode::ReplaceSuffix,

docs/src/configuring-zed.md 🔗

@@ -2425,6 +2425,7 @@ Examples:
 {
   "completions": {
     "words": "fallback",
+    "words_min_length": 3,
     "lsp": true,
     "lsp_fetch_timeout_ms": 0,
     "lsp_insert_mode": "replace_suffix"
@@ -2444,6 +2445,17 @@ Examples:
 2. `fallback` - Only if LSP response errors or times out, use document's words to show completions
 3. `disabled` - Never fetch or complete document's words for completions (word-based completions can still be queried via a separate action)
 
+### Min Words Query Length
+
+- Description: Minimum number of characters required to automatically trigger word-based completions.
+  Before that value, it's still possible to trigger the words-based completion manually with the corresponding editor command.
+- Setting: `words_min_length`
+- Default: `3`
+
+**Options**
+
+Positive integer values
+
 ### LSP
 
 - Description: Whether to fetch LSP completions or not.