From 3743622f2120ffa83ba844e87698883e03ced87f Mon Sep 17 00:00:00 2001 From: Will Fuqua Date: Sun, 16 Aug 2026 00:51:36 +0700 Subject: [PATCH] Rerequest completion items when text is deleted/retyped --- CHANGELOG.md | 4 +++ src/PrettyPrompt/Panes/CompletionPane.cs | 23 +++++++++---- tests/PrettyPrompt.Tests/CompletionTests.cs | 36 +++++++++++++++++++++ 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fb93a4..1d7769a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# Release 6.0.5 (UNRELEASED) + +- Fix the completion list offering items for a word that is no longer typed. The completion callback is now asked again whenever the typed word stops being an extension of the one it was asked about. + # Release 6.0.4 - Support "complex" completion items that rewrite a larger region of the document than the typed word (and reposition the caret) ([304](https://github.com/waf/PrettyPrompt/pull/304)). diff --git a/src/PrettyPrompt/Panes/CompletionPane.cs b/src/PrettyPrompt/Panes/CompletionPane.cs index e7aceb2..54b1dd2 100644 --- a/src/PrettyPrompt/Panes/CompletionPane.cs +++ b/src/PrettyPrompt/Panes/CompletionPane.cs @@ -37,6 +37,13 @@ internal class CompletionPane : IKeyPressHandler /// private IReadOnlyList allCompletions = Array.Empty(); + /// + /// The text of the span was requested for. Callbacks are free to + /// return only the items matching that text, so the list stays usable exactly as long as the + /// user keeps extending it. + /// + private string allCompletionsFilterText = string.Empty; + /// /// An "ordered view" over that shows the list filtered by what the user has typed. /// @@ -71,6 +78,7 @@ private async Task Open(CancellationToken cancellationToken) bool wasOpen = IsOpen; this.IsOpen = true; this.allCompletions = Array.Empty(); + this.allCompletionsFilterText = string.Empty; if (!wasOpen) { await promptCallbacks.CompletionWindowOpenedAsync(codePane.Document.GetText(), codePane.Document.Caret, cancellationToken).ConfigureAwait(false); @@ -224,17 +232,20 @@ await promptCallbacks.ShouldOpenCompletionWindowAsync(codePane.Document.GetText( } } - if (allCompletions.Count == 0) + // We ask the callback once and then only re-rank what it gave us, which is correct for + // as long as the user is refining the text we asked about. Deleting back past that text + // and typing something else leaves us ranking items for a word that is no longer there + // (ask on "G", delete it, type "H", and the G-items get sorted against "H"), so ask again. + if (allCompletions.Count == 0 || + !documentText.AsSpan(spanToReplace).StartsWith(allCompletionsFilterText, StringComparison.Ordinal)) { var completions = await promptCallbacks.GetCompletionItemsAsync(documentText, documentCaret, spanToReplace, cancellationToken).ConfigureAwait(false); if (completions.Any()) { allCompletions = completions; - if (completions.Any()) - { - int height = Math.Min(codePane.CodeAreaHeight - VerticalPaddingHeight - overloadPane.GetCurrentHeight(), configuration.MaxCompletionItemsCount); - await FilteredView.UpdateItems(completions, documentText, documentCaret, spanToReplace, height, cancellationToken).ConfigureAwait(false); - } + allCompletionsFilterText = documentText.AsSpan(spanToReplace).ToString(); + int height = Math.Min(codePane.CodeAreaHeight - VerticalPaddingHeight - overloadPane.GetCurrentHeight(), configuration.MaxCompletionItemsCount); + await FilteredView.UpdateItems(completions, documentText, documentCaret, spanToReplace, height, cancellationToken).ConfigureAwait(false); } else { diff --git a/tests/PrettyPrompt.Tests/CompletionTests.cs b/tests/PrettyPrompt.Tests/CompletionTests.cs index d57837f..06c910d 100644 --- a/tests/PrettyPrompt.Tests/CompletionTests.cs +++ b/tests/PrettyPrompt.Tests/CompletionTests.cs @@ -800,6 +800,42 @@ public async Task ReadLine_RenderingBug() Assert.Contains("bbb │ a vivid description of bbb", output[4]); //after press of 'b' (here was the bug) } + /// + /// A callback that returns only the items matching the typed word -- what a real completion + /// provider does, and what deliberately does not do. + /// + [Fact] + public async Task ReadLine_FilteringCallback_IsAskedAgainAfterTheTypedWordIsDeleted() + { + var console = ConsoleStub.NewConsole(); + var prompt = new Prompt( + callbacks: new TestPromptCallbacks + { + CompletionCallback = (text, caret, spanToBeReplaced) => + { + var typedWord = text.AsSpan(spanToBeReplaced).ToString(); + return Task.FromResult>( + new[] { "green", "grey", "hazel" } + .Where(c => c.StartsWith(typedWord, StringComparison.OrdinalIgnoreCase)) + .Select(c => new CompletionItem(c)) + .ToArray()); + } + }, + console: console); + + console.StubInput( + $"gr", // the list is fetched for "gr", so it holds green and grey + $"{Backspace}{Backspace}", // and is stale from here on: neither is a completion of "" + $"h", + $"{Enter}", // commits the selected item + $"{Enter}"); + + var result = await prompt.ReadLineAsync(); + + Assert.True(result.IsSuccess); + Assert.Equal("hazel", result.Text); + } + public static Prompt ConfigurePrompt(IConsole console, PromptConfiguration? configuration = null, string[]? completions = null) => new( callbacks: new TestPromptCallbacks