diff --git a/PSReadLine/BasicEditing.cs b/PSReadLine/BasicEditing.cs index bbfbd39ec..c6b0823be 100644 --- a/PSReadLine/BasicEditing.cs +++ b/PSReadLine/BasicEditing.cs @@ -85,6 +85,8 @@ public static void CancelLine(ConsoleKeyInfo? key = null, object arg = null) { _singleton.ClearStatusMessage(false); _singleton._current = _singleton._buffer.Length; + + using var _ = _singleton.PredictionOff(); _singleton.ForceRender(); _singleton._console.Write("\x1b[91m^C\x1b[0m"); @@ -214,6 +216,8 @@ public static void DeleteCharOrExit(ConsoleKeyInfo? key = null, object arg = nul private bool AcceptLineImpl(bool validate) { + using var _ = PredictionOff(); + ParseInput(); if (_parseErrors.Any(e => e.IncompleteInput)) { @@ -274,6 +278,12 @@ private bool AcceptLineImpl(bool validate) // Let public API set cursor to end of line incase end of line is end of buffer SetCursorPosition(_current); + if (_suggestionText != null) + { + ResetSuggestion(); + _console.BlankRestOfLine(); + } + _console.Write("\n"); _inputAccepted = true; return true; diff --git a/PSReadLine/Changes.txt b/PSReadLine/Changes.txt index dbef3b6b6..31c5c2f48 100644 --- a/PSReadLine/Changes.txt +++ b/PSReadLine/Changes.txt @@ -1,3 +1,9 @@ +### Version 2.1.0-beta1 + +Pre-release notes: + +* Experimental support for fish-like suggestions in PSReadLine. + ### Version 2.0.1 Bug fixes: diff --git a/PSReadLine/Cmdlets.cs b/PSReadLine/Cmdlets.cs index c3d569b65..4e46526a3 100644 --- a/PSReadLine/Cmdlets.cs +++ b/PSReadLine/Cmdlets.cs @@ -63,6 +63,12 @@ public enum AddToHistoryOption MemoryAndFile } + public enum PredictionSource + { + None, + History, + } + public class PSConsoleReadLineOptions { public const ConsoleColor DefaultCommentColor = ConsoleColor.DarkGreen; @@ -78,6 +84,10 @@ public class PSConsoleReadLineOptions public const ConsoleColor DefaultEmphasisColor = ConsoleColor.Cyan; public const ConsoleColor DefaultErrorColor = ConsoleColor.Red; + // Use dark black by default for the suggestion text. + // Find the most suitable color using https://stackoverflow.com/a/33206814 + public const string DefaultPredictionColor = "\x1b[38;5;238m"; + public static EditMode DefaultEditMode = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? EditMode.Windows : EditMode.Emacs; @@ -128,6 +138,11 @@ public class PSConsoleReadLineOptions public const HistorySaveStyle DefaultHistorySaveStyle = HistorySaveStyle.SaveIncrementally; + /// + /// The predictive suggestion feature is disabled by default. + /// + public const PredictionSource DefaultPredictionSource = PredictionSource.None; + /// /// How long in milliseconds should we wait before concluding /// the input is not an escape sequence? @@ -155,6 +170,7 @@ public PSConsoleReadLineOptions(string hostName) HistorySearchCaseSensitive = DefaultHistorySearchCaseSensitive; HistorySaveStyle = DefaultHistorySaveStyle; AnsiEscapeTimeout = DefaultAnsiEscapeTimeout; + PredictionSource = DefaultPredictionSource; var historyFileName = hostName + "_history.txt"; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -309,6 +325,11 @@ public object ContinuationPromptColor public string HistorySavePath { get; set; } public HistorySaveStyle HistorySaveStyle { get; set; } + /// + /// Sets the source to get predictive suggestions. + /// + public PredictionSource PredictionSource { get; set; } + /// /// How long in milliseconds should we wait before concluding /// the input is not an escape sequence? @@ -407,6 +428,12 @@ public object SelectionColor set => _selectionColor = VTColorUtils.AsEscapeSequence(value); } + public object PredictionColor + { + get => _predictionColor; + set => _predictionColor = VTColorUtils.AsEscapeSequence(value); + } + internal string _defaultTokenColor; internal string _commentColor; internal string _keywordColor; @@ -421,6 +448,7 @@ public object SelectionColor internal string _emphasisColor; internal string _errorColor; internal string _selectionColor; + internal string _predictionColor; internal void ResetColors() { @@ -438,6 +466,7 @@ internal void ResetColors() MemberColor = DefaultNumberColor; EmphasisColor = DefaultEmphasisColor; ErrorColor = DefaultErrorColor; + PredictionColor = DefaultPredictionColor; var bg = Console.BackgroundColor; if (fg == VTColorUtils.UnknownColor || bg == VTColorUtils.UnknownColor) @@ -473,7 +502,8 @@ internal void SetColor(string property, object value) {"Type", (o, v) => o.TypeColor = v}, {"Number", (o, v) => o.NumberColor = v}, {"Member", (o, v) => o.MemberColor = v}, - {"Selection", (o, v) => o.SelectionColor = v }, + {"Selection", (o, v) => o.SelectionColor = v}, + {"Prediction", (o, v) => o.PredictionColor = v}, }; Interlocked.CompareExchange(ref ColorSetters, setters, null); @@ -680,6 +710,14 @@ public ViModeStyle ViModeIndicator [Parameter] public ScriptBlock ViModeChangeHandler { get; set; } + [Parameter] + public PredictionSource PredictionSource + { + get => _predictionSource.GetValueOrDefault(); + set => _predictionSource = value; + } + internal PredictionSource? _predictionSource; + [Parameter] public Hashtable Colors { get; set; } diff --git a/PSReadLine/Completion.cs b/PSReadLine/Completion.cs index 22f884e05..908e34506 100644 --- a/PSReadLine/Completion.cs +++ b/PSReadLine/Completion.cs @@ -217,6 +217,9 @@ private void CompleteImpl(bool menuSelect) ViInsertWithAppend(); } + // Do not show suggestion text during tab completion. + using var _ = PredictionOff(); + var completions = GetCompletions(); if (completions == null || completions.CompletionMatches.Count == 0) return; diff --git a/PSReadLine/History.cs b/PSReadLine/History.cs index 6e0bd614c..308ec549b 100644 --- a/PSReadLine/History.cs +++ b/PSReadLine/History.cs @@ -502,6 +502,8 @@ private void UpdateFromHistory(HistoryMoveCursor moveCursor) } break; } + + using var _ = PredictionOff(); Render(); } @@ -677,6 +679,30 @@ private void HistorySearch(int direction) } } + /// + /// Currently we only select single-line history that is prefixed with the user input, + /// but it can be improved to not strictly use the user input as a prefix, but a hint + /// to extract a partial pipeline or statement from a single-line or multiple-line + /// history entry. + /// + private string GetHistorySuggestion(string text) + { + for (int index = _history.Count - 1; index >= 0; index --) + { + var line = _history[index].CommandLine.TrimEnd(); + if (line.Length > text.Length) + { + bool isMultiLine = line.Contains('\n'); + if (!isMultiLine && line.StartsWith(text, Options.HistoryStringComparison)) + { + return line; + } + } + } + + return null; + } + /// /// Move to the first item in the history. /// @@ -889,6 +915,7 @@ private void InteractiveHistorySearchLoop(int direction) private void InteractiveHistorySearch(int direction) { + using var _ = PredictionOff(); SaveCurrentLine(); // Add a status line that will contain the search prompt and string diff --git a/PSReadLine/KeyBindings.cs b/PSReadLine/KeyBindings.cs index 01491540a..b60b018db 100644 --- a/PSReadLine/KeyBindings.cs +++ b/PSReadLine/KeyBindings.cs @@ -549,6 +549,8 @@ public static KeyHandlerGroup GetDisplayGrouping(string function) case nameof(ViExit): case nameof(ViInsertMode): case nameof(WhatIsKey): + case nameof(AcceptSuggestion): + case nameof(AcceptNextSuggestionWord): return KeyHandlerGroup.Miscellaneous; case nameof(CharacterSearch): diff --git a/PSReadLine/Movement.cs b/PSReadLine/Movement.cs index 138191969..d7369c2b1 100644 --- a/PSReadLine/Movement.cs +++ b/PSReadLine/Movement.cs @@ -61,7 +61,14 @@ public static void ForwardChar(ConsoleKeyInfo? key = null, object arg = null) { if (TryGetArgAsInt(arg, out var numericArg, 1)) { - SetCursorPosition(_singleton._current + numericArg); + if (_singleton._current == _singleton._buffer.Length && numericArg > 0) + { + AcceptSuggestion(key, arg); + } + else + { + SetCursorPosition(_singleton._current + numericArg); + } } } @@ -311,6 +318,12 @@ public static void ForwardWord(ConsoleKeyInfo? key = null, object arg = null) return; } + if (_singleton._current == _singleton._buffer.Length && numericArg > 0) + { + AcceptNextSuggestionWord(numericArg); + return; + } + if (numericArg < 0) { BackwardWord(key, -numericArg); diff --git a/PSReadLine/Options.cs b/PSReadLine/Options.cs index fe45cf8f2..da878cbd6 100644 --- a/PSReadLine/Options.cs +++ b/PSReadLine/Options.cs @@ -135,6 +135,14 @@ private void SetOptionsInternal(SetPSReadLineOption options) { Options.PromptText = options.PromptText; } + if (options._predictionSource.HasValue) + { + if (_console is PlatformWindows.LegacyWin32Console && options.PredictionSource != PredictionSource.None) + { + throw new ArgumentException(PSReadLineResources.PredictiveSuggestionNotSupported); + } + Options.PredictionSource = options.PredictionSource; + } if (options.Colors != null) { IDictionaryEnumerator e = options.Colors.GetEnumerator(); diff --git a/PSReadLine/PSReadLine.csproj b/PSReadLine/PSReadLine.csproj index f55085310..971631cf8 100644 --- a/PSReadLine/PSReadLine.csproj +++ b/PSReadLine/PSReadLine.csproj @@ -4,11 +4,12 @@ Library Microsoft.PowerShell.PSReadLine Microsoft.PowerShell.PSReadLine2 - 2.0.1.0 - 2.0.1 - 2.0.1 + 2.1.0.0 + 2.1.0 + 2.1.0-beta1 true net461;netcoreapp2.1 + 8.0 diff --git a/PSReadLine/PSReadLine.format.ps1xml b/PSReadLine/PSReadLine.format.ps1xml index 0f437fa74..9b239328e 100644 --- a/PSReadLine/PSReadLine.format.ps1xml +++ b/PSReadLine/PSReadLine.format.ps1xml @@ -158,6 +158,9 @@ $d = [Microsoft.PowerShell.KeyHandler]::GetGroupingDescription($_.Group) AnsiEscapeTimeout + + PredictionSource + [Microsoft.PowerShell.VTColorUtils]::FormatColor($_.CommandColor) @@ -202,6 +205,10 @@ $d = [Microsoft.PowerShell.KeyHandler]::GetGroupingDescription($_.Group) [Microsoft.PowerShell.VTColorUtils]::FormatColor($_.ParameterColor) + + + [Microsoft.PowerShell.VTColorUtils]::FormatColor($_.PredictionColor) + [Microsoft.PowerShell.VTColorUtils]::FormatColor($_.SelectionColor) diff --git a/PSReadLine/PSReadLineResources.Designer.cs b/PSReadLine/PSReadLineResources.Designer.cs index 3ec3e6b6e..03943aeef 100644 --- a/PSReadLine/PSReadLineResources.Designer.cs +++ b/PSReadLine/PSReadLineResources.Designer.cs @@ -638,7 +638,34 @@ internal static string InsertLineBelowDescription { return ResourceManager.GetString("InsertLineBelowDescription", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Accept the current inline or selected suggestion. + /// + internal static string AcceptSuggestionDescription { + get { + return ResourceManager.GetString("AcceptSuggestionDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Accept the next word of the inline or selected suggestion. + /// + internal static string AcceptNextSuggestionWordDescription { + get { + return ResourceManager.GetString("AcceptNextSuggestionWordDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The predictive suggestion feature cannot be enabled because the console output doesn't support virtual terminal processing or it's redirected. + /// + internal static string PredictiveSuggestionNotSupported { + get { + return ResourceManager.GetString("PredictiveSuggestionNotSupported", resourceCulture); + } + } + /// /// Looks up a localized string similar to '{0}' is not a valid color property. /// diff --git a/PSReadLine/PSReadLineResources.resx b/PSReadLine/PSReadLineResources.resx index fd9def428..e6f2d7192 100644 --- a/PSReadLine/PSReadLineResources.resx +++ b/PSReadLine/PSReadLineResources.resx @@ -741,6 +741,12 @@ If there are other parse errors, unresolved commands, or incorrect parameters, s Inserts a new empty line below the current line without attempting to execute the input + + Accept the current inline or selected suggestion + + + Accept the next word of the inline or selected suggestion + Error reading or writing history file '{0}': {1} @@ -783,4 +789,7 @@ Or not saving history with: '{0}' is not a valid color property + + The predictive suggestion feature cannot be enabled because the console output doesn't support virtual terminal processing or it's redirected. + diff --git a/PSReadLine/ReadLine.vi.cs b/PSReadLine/ReadLine.vi.cs index ef7fe7e0d..ad48e19f4 100644 --- a/PSReadLine/ReadLine.vi.cs +++ b/PSReadLine/ReadLine.vi.cs @@ -522,6 +522,9 @@ internal static IDisposable UseViInsertModeTables() private void ViIndicateCommandMode() { + // Show suggestion in 'InsertMode' but not 'CommandMode'. + _showSuggestion = false; + if (_options.ViModeIndicator == ViModeStyle.Cursor) { _console.CursorSize = _normalCursorSize < 50 ? 100 : 25; @@ -541,6 +544,9 @@ private void ViIndicateCommandMode() private void ViIndicateInsertMode() { + // Show suggestion in 'InsertMode' but not 'CommandMode'. + _showSuggestion = true; + if (_options.ViModeIndicator == ViModeStyle.Cursor) { _console.CursorSize = _normalCursorSize; diff --git a/PSReadLine/Render.cs b/PSReadLine/Render.cs index 4a876b2dc..1eea51cc9 100644 --- a/PSReadLine/Render.cs +++ b/PSReadLine/Render.cs @@ -156,6 +156,7 @@ private void ForceRender() private int GenerateRender(string defaultColor) { var text = ParseInput(); + var suggestion = GetSuggestion(text); string color = defaultColor; string activeColor = string.Empty; @@ -172,13 +173,52 @@ void UpdateColorsIfNecessary(string newColor) } } - void MaybeEmphasize(int i, string currColor) + void RenderOneChar(char charToRender, bool toEmphasize) { - if (i >= _emphasisStart && i < (_emphasisStart + _emphasisLength)) + if (charToRender == '\n') { - currColor = _options._emphasisColor; + if (inSelectedRegion) + { + // Turn off inverse before end of line, turn on after continuation prompt + _consoleBufferLines[currentLogicalLine].Append("\x1b[0m"); + } + + currentLogicalLine += 1; + if (currentLogicalLine == _consoleBufferLines.Count) + { + _consoleBufferLines.Add(new StringBuilder(COMMON_WIDEST_CONSOLE_WIDTH)); + } + + // Reset the color for continuation prompt so the color sequence will always be explicitly + // specified for continuation prompt in the generated render strings. + // This is necessary because we will likely not rewrite all texts during rendering, and thus + // we cannot assume the continuation prompt can continue to use the active color setting from + // the previous rendering string. + activeColor = string.Empty; + + UpdateColorsIfNecessary(Options._continuationPromptColor); + _consoleBufferLines[currentLogicalLine].Append(Options.ContinuationPrompt); + + if (inSelectedRegion) + { + // Turn off inverse before end of line, turn on after continuation prompt + _consoleBufferLines[currentLogicalLine].Append(Options.SelectionColor); + } + + return; + } + + UpdateColorsIfNecessary(toEmphasize ? _options._emphasisColor : color); + + if (char.IsControl(charToRender)) + { + _consoleBufferLines[currentLogicalLine].Append('^'); + _consoleBufferLines[currentLogicalLine].Append((char)('@' + charToRender)); + } + else + { + _consoleBufferLines[currentLogicalLine].Append(charToRender); } - UpdateColorsIfNecessary(currColor); } foreach (var buf in _consoleBufferLines) @@ -288,58 +328,24 @@ void MaybeEmphasize(int i, string currColor) } var charToRender = text[i]; - if (charToRender == '\n') - { - if (inSelectedRegion) - { - // Turn off inverse before end of line, turn on after continuation prompt - _consoleBufferLines[currentLogicalLine].Append("\x1b[0m"); - } - - currentLogicalLine += 1; - if (currentLogicalLine > _consoleBufferLines.Count - 1) - { - _consoleBufferLines.Add(new StringBuilder(COMMON_WIDEST_CONSOLE_WIDTH)); - } - - // Reset the color for continuation prompt so the color sequence will always be explicitly - // specified for continuation prompt in the generated render strings. - // This is necessary because we will likely not rewrite all texts during rendering, and thus - // we cannot assume the continuation prompt can continue to use the active color setting from - // the previous rendering string. - activeColor = string.Empty; - - UpdateColorsIfNecessary(Options._continuationPromptColor); - foreach (char c in Options.ContinuationPrompt) - { - _consoleBufferLines[currentLogicalLine].Append(c); - } + var toEmphasize = i >= _emphasisStart && i < (_emphasisStart + _emphasisLength); - if (inSelectedRegion) - { - // Turn off inverse before end of line, turn on after continuation prompt - _consoleBufferLines[currentLogicalLine].Append(Options.SelectionColor); - } - } - else - { - if (char.IsControl(charToRender)) - { - MaybeEmphasize(i, color); - _consoleBufferLines[currentLogicalLine].Append('^'); - _consoleBufferLines[currentLogicalLine].Append((char)('@' + charToRender)); - } - else - { - MaybeEmphasize(i, color); - _consoleBufferLines[currentLogicalLine].Append(charToRender); - } - } + RenderOneChar(charToRender, toEmphasize); } if (inSelectedRegion) { _consoleBufferLines[currentLogicalLine].Append("\x1b[0m"); + inSelectedRegion = false; + } + + if (suggestion != null) + { + color = _options._predictionColor; + foreach (char charToRender in suggestion) + { + RenderOneChar(charToRender, toEmphasize: false); + } } if (_statusLinePrompt != null) diff --git a/PSReadLine/Suggestion.cs b/PSReadLine/Suggestion.cs new file mode 100644 index 000000000..107ecb07e --- /dev/null +++ b/PSReadLine/Suggestion.cs @@ -0,0 +1,106 @@ +using System; + +namespace Microsoft.PowerShell +{ + public partial class PSConsoleReadLine + { + private string _suggestionText; + private string _lastUserInput; + private bool _showSuggestion = true; + + private bool IsPredictionOn => _options.PredictionSource != PredictionSource.None && _showSuggestion; + private bool IsPredictionOff => _options.PredictionSource == PredictionSource.None || !_showSuggestion; + + /// + /// Turn off the prediction feature for the scope of the calling method. + /// Note: the calling method need to use this method with the 'using' statement/variable, + /// so that the suggestion feature can be properly restored. + /// + private IDisposable PredictionOff() + { + var oldSuggestionMode = _singleton._showSuggestion; + _singleton._showSuggestion = false; + + return new Disposable(() => _singleton._showSuggestion = oldSuggestionMode); + } + + private void ResetSuggestion() + { + _suggestionText = null; + _lastUserInput = null; + } + + private string GetSuggestion(string text) + { + if (IsPredictionOff || LineIsMultiLine() || string.IsNullOrWhiteSpace(text)) + { + ResetSuggestion(); + return null; + } + + try + { + if (_suggestionText == null || _suggestionText.Length <= text.Length || + text.Length < _lastUserInput.Length || !_suggestionText.StartsWith(text, _options.HistoryStringComparison)) + { + _lastUserInput = text; + _suggestionText = GetHistorySuggestion(text); + } + } + catch + { + _suggestionText = null; + } + + return _suggestionText?.Substring(text.Length); + } + + /// + /// Accept the suggestion text if there is one. + /// + public static void AcceptSuggestion(ConsoleKeyInfo? key = null, object arg = null) + { + if (_singleton._suggestionText != null) + { + // Ignore the visual selection. + _singleton._visualSelectionCommandCount = 0; + + using var _ = _singleton.PredictionOff(); + Replace(0, _singleton._buffer.Length, _singleton._suggestionText); + } + } + + /// + /// Accept the current or the next suggestion text word. + /// + public static void AcceptNextSuggestionWord(ConsoleKeyInfo? key = null, object arg = null) + { + if (!TryGetArgAsInt(arg, out var numericArg, 1)) + { + return; + } + + if (numericArg > 0) + { + AcceptNextSuggestionWord(numericArg); + } + } + + private static void AcceptNextSuggestionWord(int numericArg) + { + if (_singleton._suggestionText != null) + { + // Ignore the visual selection. + _singleton._visualSelectionCommandCount = 0; + + int index = _singleton._buffer.Length; + while (numericArg-- > 0 && index < _singleton._suggestionText.Length) + { + index = _singleton.FindForwardSuggestionWordPoint(index, _singleton.Options.WordDelimiters); + } + + Replace(0, _singleton._buffer.Length, _singleton._suggestionText.Substring(0, index)); + } + } + } +} diff --git a/PSReadLine/Words.cs b/PSReadLine/Words.cs index a9ea20289..40be76214 100644 --- a/PSReadLine/Words.cs +++ b/PSReadLine/Words.cs @@ -91,9 +91,59 @@ private Token FindToken(int current, FindTokenMode mode) private bool InWord(int index, string wordDelimiters) { char c = _buffer[index]; + return InWord(c, wordDelimiters); + } + + private bool InWord(char c, string wordDelimiters) + { return !char.IsWhiteSpace(c) && wordDelimiters.IndexOf(c) < 0; } + private int FindForwardSuggestionWordPoint(int currentIndex, string wordDelimiters) + { + System.Diagnostics.Debug.Assert( + _suggestionText != null && _suggestionText.Length > _buffer.Length, + "Caller needs to make sure the suggestion text exist."); + + if (currentIndex >= _suggestionText.Length) + { + return _suggestionText.Length; + } + + int i = currentIndex; + if (!InWord(_suggestionText[i], wordDelimiters)) + { + // Scan to end of current non-word region + while (++i < _suggestionText.Length) + { + if (InWord(_suggestionText[i], wordDelimiters)) + { + break; + } + } + } + + if (i < _suggestionText.Length) + { + while (++i < _suggestionText.Length) + { + if (!InWord(_suggestionText[i], wordDelimiters)) + { + if (_suggestionText[i] == ' ') + { + // If the end of this suggestion word is a space, then we consider the word + // is complete and include the space in the accepted suggestion word. + i++; + } + + break; + } + } + } + + return i; + } + /// /// Find the end of the current/next word as defined by wordDelimiters and whitespace. /// diff --git a/docs/Set-PSReadLineOption.md b/docs/Set-PSReadLineOption.md index 09c7f2ede..db0ea0aeb 100644 --- a/docs/Set-PSReadLineOption.md +++ b/docs/Set-PSReadLineOption.md @@ -36,6 +36,7 @@ Set-PSReadLineOption [-AnsiEscapeTimeout ] [-ViModeIndicator ] [-ViModeChangeHandler ] + [-PredictionSource ] ``` ## DESCRIPTION @@ -389,6 +390,8 @@ The valid keys include: -- Member: The member name token color. +-- Prediction: The color for the suggestion text that comes from the prediction API. + ```yaml Type: Hashtable Parameter Sets: (All) @@ -612,6 +615,28 @@ Accept pipeline input: false Accept wildcard characters: false ``` +### -PredictionSource + +Specifies the source for PSReadLine to get predictive suggestions. + +Valid values are: + +-- None: disable the predictive suggestion feature. + +-- History: get predictive suggestions from history only. + +```yaml +Type: PredictionSource +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: false +Accept wildcard characters: false +``` + ## INPUTS ### None diff --git a/test/SuggestionTest.cs b/test/SuggestionTest.cs new file mode 100644 index 000000000..c62736b5a --- /dev/null +++ b/test/SuggestionTest.cs @@ -0,0 +1,293 @@ +using Microsoft.PowerShell; +using System; +using System.Collections; +using Xunit; + +namespace Test +{ + public partial class ReadLine + { + [SkippableFact] + public void RenderSuggestion() + { + TestSetup(KeyMode.Cmd, + new KeyHandler("Ctrl+f", PSConsoleReadLine.ForwardWord)); + + // No matching history entry + SetHistory("echo -bar", "eca -zoo"); + Test("a", Keys( + 'a', CheckThat(() => AssertScreenIs(1, TokenClassification.Command, 'a')), + _.Ctrl_f, CheckThat(() => AssertScreenIs(1, TokenClassification.Command, 'a')), + _.RightArrow, CheckThat(() => AssertScreenIs(1, TokenClassification.Command, 'a')), + CheckThat(() => AssertCursorLeftIs(1)) + )); + + // Different matches as more input coming + SetHistory("echo -bar", "eca -zoo"); + Test("ech", Keys( + 'e', CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, 'e', + TokenClassification.Prediction, "ca -zoo")), + 'c', CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "ec", + TokenClassification.Prediction, "a -zoo")), + 'h', CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "ech", + TokenClassification.Prediction, "o -bar")), + // Once accepted, the suggestion text should be blanked out. + _.Enter, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "ech")) + )); + + // Accept all or partial suggestion text with 'ForwardChar' and 'ForwardWord' + SetHistory("echo -bar", "eca -zoo"); + Test("eca ", Keys( + 'e', CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, 'e', + TokenClassification.Prediction, "ca -zoo")), + _.RightArrow, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "eca", + TokenClassification.None, ' ', + TokenClassification.Parameter, "-zoo")), + _.Ctrl_z, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, 'e', + TokenClassification.Prediction, "ca -zoo")), + _.Ctrl_f, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "eca", + TokenClassification.None, ' ', + TokenClassification.Prediction, "-zoo")), + CheckThat(() => AssertCursorLeftIs(4)), + _.Ctrl_f, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "eca", + TokenClassification.None, ' ', + TokenClassification.Parameter, "-zoo")), + _.Ctrl_z, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "eca", + TokenClassification.None, ' ', + TokenClassification.Prediction, "-zoo")) + )); + } + + [SkippableFact] + public void CustomKeyBindingsToAcceptSuggestion() + { + TestSetup(KeyMode.Cmd, + new KeyHandler("Alt+g", PSConsoleReadLine.AcceptSuggestion), + new KeyHandler("Alt+f", PSConsoleReadLine.AcceptNextSuggestionWord)); + + SetHistory("echo -bar", "eca -zoo"); + Test("eca ", Keys( + "ec", CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "ec", + TokenClassification.Prediction, "a -zoo")), + CheckThat(() => AssertCursorLeftIs(2)), + _.Alt_g, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "eca", + TokenClassification.None, ' ', + TokenClassification.Parameter, "-zoo")), + CheckThat(() => AssertCursorLeftIs(8)), + _.Ctrl_z, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "ec", + TokenClassification.Prediction, "a -zoo")), + CheckThat(() => AssertCursorLeftIs(2)), + _.Alt_f, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "eca", + TokenClassification.None, ' ', + TokenClassification.Prediction, "-zoo")), + CheckThat(() => AssertCursorLeftIs(4)), + _.Alt_f, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "eca", + TokenClassification.None, ' ', + TokenClassification.Parameter, "-zoo")), + CheckThat(() => AssertCursorLeftIs(8)), + _.Ctrl_z, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "eca", + TokenClassification.None, ' ', + TokenClassification.Prediction, "-zoo")), + CheckThat(() => AssertCursorLeftIs(4)), + + // Revert back to 'ec'. + _.Ctrl_z, CheckThat(() => AssertCursorLeftIs(2)), + // Move cursor to column 0. + _.LeftArrow, _.LeftArrow, + CheckThat(() => AssertCursorLeftIs(0)), + _.Alt_g, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "eca", + TokenClassification.None, ' ', + TokenClassification.Parameter, "-zoo")), + CheckThat(() => AssertCursorLeftIs(8)), + _.Ctrl_z, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "ec", + TokenClassification.Prediction, "a -zoo")), + CheckThat(() => AssertCursorLeftIs(2)), + // Move cursor to column 0 again. + _.LeftArrow, _.LeftArrow, + CheckThat(() => AssertCursorLeftIs(0)), + _.Alt_f, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "eca", + TokenClassification.None, ' ', + TokenClassification.Prediction, "-zoo")), + CheckThat(() => AssertCursorLeftIs(4)) + )); + } + + [SkippableFact] + public void AcceptNextSuggestionWordCanAcceptMoreThanOneWords() + { + TestSetup(KeyMode.Cmd, + new KeyHandler("Ctrl+f", PSConsoleReadLine.ForwardWord), + new KeyHandler("Alt+f", PSConsoleReadLine.AcceptNextSuggestionWord)); + + SetHistory("abc def ghi jkl"); + Test("abc def ghi jkl", Keys( + 'a', CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, 'a', + TokenClassification.Prediction, "bc def ghi jkl")), + _.Alt_3, _.Ctrl_f, + CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "abc", + TokenClassification.None, " def ghi ", + TokenClassification.Prediction, "jkl")), + _.Ctrl_z, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, 'a', + TokenClassification.Prediction, "bc def ghi jkl")), + _.Alt_3, _.Alt_f, + CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "abc", + TokenClassification.None, " def ghi ", + TokenClassification.Prediction, "jkl")), + _.Ctrl_z, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, 'a', + TokenClassification.Prediction, "bc def ghi jkl")), + _.Alt_8, _.Alt_f, + CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "abc", + TokenClassification.None, " def ghi jkl")) + )); + } + + [SkippableFact] + public void AcceptSuggestionWithSelection() + { + TestSetup(KeyMode.Cmd, + new KeyHandler("Ctrl+f", PSConsoleReadLine.ForwardWord)); + + SetHistory("git diff --cached", "git diff"); + Test("git diff", Keys( + "git", CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "git", + TokenClassification.Prediction, " diff")), + _.RightArrow, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "git", + TokenClassification.None, " diff")), + _.Ctrl_z, CheckThat(() => AssertCursorLeftIs(3)), + _.Ctrl_f, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "git", + TokenClassification.None, " diff", + TokenClassification.Prediction, " --cached")), + + // Perform visual selection and then accept suggestion. + _.Ctrl_z, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "git", + TokenClassification.Prediction, " diff")), + _.LeftArrow, _.Shift_RightArrow, + CheckThat(() => + { + PSConsoleReadLine.GetSelectionState(out var start, out var length); + Assert.Equal(2, start); + Assert.Equal(1, length); + }), + _.RightArrow, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "git", + TokenClassification.None, " diff")), + + // Perform visual selection and then accept next suggestion word. + _.Ctrl_z, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "git", + TokenClassification.Prediction, " diff")), + _.LeftArrow, _.Shift_RightArrow, + CheckThat(() => + { + PSConsoleReadLine.GetSelectionState(out var start, out var length); + Assert.Equal(2, start); + Assert.Equal(1, length); + }), + _.Ctrl_f, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "git", + TokenClassification.None, " diff", + TokenClassification.Prediction, " --cached")) + )); + } + + [SkippableFact] + public void DisablePrediction() + { + TestSetup(KeyMode.Cmd); + SetHistory("echo -bar", "eca -zoo"); + PSConsoleReadLine.SetOptions(new SetPSReadLineOption {PredictionSource = PredictionSource.None}); + + Test("ech", Keys( + 'e', CheckThat(() => AssertScreenIs(1, TokenClassification.Command, 'e')), + 'c', CheckThat(() => AssertScreenIs(1, TokenClassification.Command, "ec")), + 'h', CheckThat(() => AssertScreenIs(1, TokenClassification.Command, "ech")), + _.Enter, CheckThat(() => AssertScreenIs(1, TokenClassification.Command, "ech")) + )); + } + + [SkippableFact] + public void SetPredictionColor() + { + TestSetup(KeyMode.Cmd); + var predictionColor = MakeCombinedColor(ConsoleColor.DarkYellow, ConsoleColor.Yellow); + var predictionColorToCheck = Tuple.Create(ConsoleColor.DarkYellow, ConsoleColor.Yellow); + PSConsoleReadLine.SetOptions(new SetPSReadLineOption {Colors = new Hashtable(){{"Prediction", predictionColor}}}); + + SetHistory("echo -bar", "eca -zoo"); + Test("ech", Keys( + 'e', CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, 'e', + predictionColorToCheck, "ca -zoo")), + 'c', CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "ec", + predictionColorToCheck, "a -zoo")), + 'h', CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "ech", + predictionColorToCheck, "o -bar")), + // Once accepted, the suggestion text should be blanked out. + _.Enter, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "ech")) + )); + } + + [SkippableFact] + public void HistoryEditsCanUndoProperly() + { + TestSetup(KeyMode.Cmd, + new KeyHandler("Ctrl+f", PSConsoleReadLine.ForwardWord)); + SetHistory("git checkout -b branch origin/bbbb"); + + // Accept partial suggestion. + Test("git checkout ", Keys( + "git ch", CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "git", + TokenClassification.None, " ch", + TokenClassification.Prediction, "eckout -b branch origin/bbbb")), + _.Ctrl_f, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "git", + TokenClassification.None, " checkout ", + TokenClassification.Prediction, "-b branch origin/bbbb")), + _.Enter, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "git", + TokenClassification.None, " checkout ")) + )); + + // Get the last command line from history, and revert the line. + // 'RevertLine' will undo all edits of the history command. + Test("", Keys( + _.UpArrow, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "git", + TokenClassification.None, " checkout ")), + _.Escape)); + } + } +} diff --git a/test/UnitTestReadLine.cs b/test/UnitTestReadLine.cs index b17e3345a..8abbef291 100644 --- a/test/UnitTestReadLine.cs +++ b/test/UnitTestReadLine.cs @@ -50,6 +50,7 @@ public enum TokenClassification Number, Member, Selection, + Prediction, } public abstract partial class ReadLine @@ -101,6 +102,7 @@ private enum KeyMode /*Number*/ ConsoleColor.DarkBlue, /*Member*/ ConsoleColor.DarkMagenta, /*Selection*/ ConsoleColor.Black, + /*Prediction*/ConsoleColor.DarkGreen, }; internal static readonly ConsoleColor[] BackgroundColors = new[] @@ -116,7 +118,8 @@ private enum KeyMode /*Type*/ ConsoleColor.Black, /*Number*/ ConsoleColor.Gray, /*Member*/ ConsoleColor.Yellow, - /*Selection*/ ConsoleColor.Gray + /*Selection*/ ConsoleColor.Gray, + /*Prediction*/ConsoleColor.Cyan, }; class KeyHandler @@ -475,6 +478,7 @@ private void TestSetup(KeyMode keyMode, params KeyHandler[] keyHandlers) ShowToolTips = PSConsoleReadLineOptions.DefaultShowToolTips, WordDelimiters = PSConsoleReadLineOptions.DefaultWordDelimiters, PromptText = new [] {""}, + PredictionSource = PredictionSource.History, Colors = new Hashtable { { "ContinuationPrompt", MakeCombinedColor(_console.ForegroundColor, _console.BackgroundColor) }, { "Emphasis", MakeCombinedColor(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor) }, @@ -505,7 +509,7 @@ private void TestSetup(KeyMode keyMode, params KeyHandler[] keyHandlers) var tokenTypes = new[] { "Default", "Comment", "Keyword", "String", "Operator", "Variable", - "Command", "Parameter", "Type", "Number", "Member", "Selection" + "Command", "Parameter", "Type", "Number", "Member", "Selection", "Prediction" }; var colors = new Hashtable(); for (var i = 0; i < tokenTypes.Length; i++)