diff --git a/src/Microsoft.PowerShell.ConsoleHost/host/msh/ConsoleHost.cs b/src/Microsoft.PowerShell.ConsoleHost/host/msh/ConsoleHost.cs index e2b7bd7b8ba..577abc5c087 100644 --- a/src/Microsoft.PowerShell.ConsoleHost/host/msh/ConsoleHost.cs +++ b/src/Microsoft.PowerShell.ConsoleHost/host/msh/ConsoleHost.cs @@ -2836,61 +2836,13 @@ private void EvaluateFeedbacks(ConsoleHostUserInterface ui) // Output any training suggestions try { - List feedbacks = FeedbackHub.GetFeedback(_parent.Runspace); - if (feedbacks is null || feedbacks.Count == 0) + List feedbacks = FeedbackHub.GetFeedback(_parent.Runspace); + if (feedbacks is null || feedbacks.Count is 0) { return; } - // Feedback section starts with a new line. - ui.WriteLine(); - - const string Indentation = " "; - string nameStyle = PSStyle.Instance.Formatting.FeedbackProvider; - string textStyle = PSStyle.Instance.Formatting.FeedbackText; - string ansiReset = PSStyle.Instance.Reset; - - if (!ui.SupportsVirtualTerminal) - { - nameStyle = string.Empty; - textStyle = string.Empty; - ansiReset = string.Empty; - } - - int count = 0; - var output = new StringBuilder(); - - foreach (FeedbackEntry entry in feedbacks) - { - if (count > 0) - { - output.AppendLine(); - } - - output.Append("Suggestion [") - .Append(nameStyle) - .Append(entry.Name) - .Append(ansiReset) - .AppendLine("]:") - .Append(textStyle); - - string[] lines = entry.Text.Split('\n', StringSplitOptions.RemoveEmptyEntries); - foreach (string line in lines) - { - output.Append(Indentation) - .Append(line.AsSpan().TrimEnd()) - .AppendLine(); - } - - output.Append(ansiReset); - ui.Write(output.ToString()); - - count++; - output.Clear(); - } - - // Feedback section ends with a new line. - ui.WriteLine(); + HostUtilities.RenderFeedback(feedbacks, ui); } catch (Exception e) { diff --git a/src/System.Management.Automation/FormatAndOutput/DefaultFormatters/PowerShellCore_format_ps1xml.cs b/src/System.Management.Automation/FormatAndOutput/DefaultFormatters/PowerShellCore_format_ps1xml.cs index 6ab8b8f2150..3d6257f36a8 100644 --- a/src/System.Management.Automation/FormatAndOutput/DefaultFormatters/PowerShellCore_format_ps1xml.cs +++ b/src/System.Management.Automation/FormatAndOutput/DefaultFormatters/PowerShellCore_format_ps1xml.cs @@ -2063,8 +2063,9 @@ private static IEnumerable ViewsOf_System_Management_Autom .AddItemScriptBlock(@"""$($_.Formatting.Debug)$($_.Formatting.Debug.Replace(""""`e"""",'`e'))$($PSStyle.Reset)""", label: "Formatting.Debug") .AddItemScriptBlock(@"""$($_.Formatting.TableHeader)$($_.Formatting.TableHeader.Replace(""""`e"""",'`e'))$($PSStyle.Reset)""", label: "Formatting.TableHeader") .AddItemScriptBlock(@"""$($_.Formatting.CustomTableHeaderLabel)$($_.Formatting.CustomTableHeaderLabel.Replace(""""`e"""",'`e'))$($PSStyle.Reset)""", label: "Formatting.CustomTableHeaderLabel") - .AddItemScriptBlock(@"""$($_.Formatting.FeedbackProvider)$($_.Formatting.FeedbackProvider.Replace(""""`e"""",'`e'))$($PSStyle.Reset)""", label: "Formatting.FeedbackProvider") + .AddItemScriptBlock(@"""$($_.Formatting.FeedbackName)$($_.Formatting.FeedbackName.Replace(""""`e"""",'`e'))$($PSStyle.Reset)""", label: "Formatting.FeedbackName") .AddItemScriptBlock(@"""$($_.Formatting.FeedbackText)$($_.Formatting.FeedbackText.Replace(""""`e"""",'`e'))$($PSStyle.Reset)""", label: "Formatting.FeedbackText") + .AddItemScriptBlock(@"""$($_.Formatting.FeedbackAction)$($_.Formatting.FeedbackAction.Replace(""""`e"""",'`e'))$($PSStyle.Reset)""", label: "Formatting.FeedbackAction") .AddItemScriptBlock(@"""$($_.Progress.Style)$($_.Progress.Style.Replace(""""`e"""",'`e'))$($PSStyle.Reset)""", label: "Progress.Style") .AddItemScriptBlock(@"""$($_.Progress.MaxWidth)""", label: "Progress.MaxWidth") .AddItemScriptBlock(@"""$($_.Progress.View)""", label: "Progress.View") @@ -2122,8 +2123,9 @@ private static IEnumerable ViewsOf_System_Management_Autom .AddItemScriptBlock(@"""$($_.Debug)$($_.Debug.Replace(""""`e"""",'`e'))$($PSStyle.Reset)""", label: "Debug") .AddItemScriptBlock(@"""$($_.TableHeader)$($_.TableHeader.Replace(""""`e"""",'`e'))$($PSStyle.Reset)""", label: "TableHeader") .AddItemScriptBlock(@"""$($_.CustomTableHeaderLabel)$($_.CustomTableHeaderLabel.Replace(""""`e"""",'`e'))$($PSStyle.Reset)""", label: "CustomTableHeaderLabel") - .AddItemScriptBlock(@"""$($_.FeedbackProvider)$($_.FeedbackProvider.Replace(""""`e"""",'`e'))$($PSStyle.Reset)""", label: "FeedbackProvider") + .AddItemScriptBlock(@"""$($_.FeedbackName)$($_.FeedbackName.Replace(""""`e"""",'`e'))$($PSStyle.Reset)""", label: "FeedbackName") .AddItemScriptBlock(@"""$($_.FeedbackText)$($_.FeedbackText.Replace(""""`e"""",'`e'))$($PSStyle.Reset)""", label: "FeedbackText") + .AddItemScriptBlock(@"""$($_.FeedbackAction)$($_.FeedbackAction.Replace(""""`e"""",'`e'))$($PSStyle.Reset)""", label: "FeedbackAction") .EndEntry() .EndList()); } diff --git a/src/System.Management.Automation/FormatAndOutput/common/PSStyle.cs b/src/System.Management.Automation/FormatAndOutput/common/PSStyle.cs index 930e86d4acc..3f927afd7d5 100644 --- a/src/System.Management.Automation/FormatAndOutput/common/PSStyle.cs +++ b/src/System.Management.Automation/FormatAndOutput/common/PSStyle.cs @@ -435,16 +435,17 @@ public string Debug /// /// Gets or sets the style for rendering feedback provider names. /// - public string FeedbackProvider + public string FeedbackName { - get => _feedbackProvider; - set => _feedbackProvider = ValidateNoContent(value); + get => _feedbackName; + set => _feedbackName = ValidateNoContent(value); } - private string _feedbackProvider = "\x1b[33m"; + // Yellow by default. + private string _feedbackName = "\x1b[33m"; /// - /// Gets or sets the style for rendering feedback text. + /// Gets or sets the style for rendering feedback message. /// public string FeedbackText { @@ -452,7 +453,20 @@ public string FeedbackText set => _feedbackText = ValidateNoContent(value); } + // BrightCyan by default. private string _feedbackText = "\x1b[96m"; + + /// + /// Gets or sets the style for rendering feedback actions. + /// + public string FeedbackAction + { + get => _feedbackAction; + set => _feedbackAction = ValidateNoContent(value); + } + + // BrightWhite by default. + private string _feedbackAction = "\x1b[97m"; } /// diff --git a/src/System.Management.Automation/engine/Subsystem/FeedbackSubsystem/FeedbackHub.cs b/src/System.Management.Automation/engine/Subsystem/FeedbackSubsystem/FeedbackHub.cs index 5755945dcd0..6818d25bcdb 100644 --- a/src/System.Management.Automation/engine/Subsystem/FeedbackSubsystem/FeedbackHub.cs +++ b/src/System.Management.Automation/engine/Subsystem/FeedbackSubsystem/FeedbackHub.cs @@ -17,7 +17,7 @@ namespace System.Management.Automation.Subsystem.Feedback /// /// The class represents a result from a feedback provider. /// - public class FeedbackEntry + public class FeedbackResult { /// /// Gets the Id of the feedback provider. @@ -30,15 +30,15 @@ public class FeedbackEntry public string Name { get; } /// - /// Gets the text of the feedback. + /// Gets the feedback item. /// - public string Text { get; } + public FeedbackItem Item { get; } - internal FeedbackEntry(Guid id, string name, string text) + internal FeedbackResult(Guid id, string name, FeedbackItem item) { Id = id; Name = name; - Text = text; + Item = item; } } @@ -50,7 +50,7 @@ public static class FeedbackHub /// /// Collect the feedback from registered feedback providers using the default timeout. /// - public static List? GetFeedback(Runspace runspace) + public static List? GetFeedback(Runspace runspace) { return GetFeedback(runspace, millisecondsTimeout: 300); } @@ -58,7 +58,7 @@ public static class FeedbackHub /// /// Collect the feedback from registered feedback providers using the specified timeout. /// - public static List? GetFeedback(Runspace runspace, int millisecondsTimeout) + public static List? GetFeedback(Runspace runspace, int millisecondsTimeout) { Requires.Condition(millisecondsTimeout > 0, nameof(millisecondsTimeout)); @@ -110,9 +110,9 @@ public static class FeedbackHub } IFeedbackProvider? generalFeedback = null; - List>? tasks = null; + List>? tasks = null; CancellationTokenSource? cancellationSource = null; - Func? callBack = null; + Func? callBack = null; for (int i = 0; i < providers.Count; i++) { @@ -126,7 +126,7 @@ public static class FeedbackHub if (tasks is null) { - tasks = new List>(capacity: count); + tasks = new List>(capacity: count); cancellationSource = new CancellationTokenSource(); callBack = GetCallBack(lastHistory.CommandLine, lastError, cancellationSource); } @@ -148,7 +148,7 @@ public static class FeedbackHub Task.Delay(millisecondsTimeout, cancellationSource!.Token)); } - List? resultList = null; + List? resultList = null; if (generalFeedback is not null) { bool changedDefault = false; @@ -162,11 +162,11 @@ public static class FeedbackHub Runspace.DefaultRunspace = localRunspace; } - string? text = generalFeedback.GetFeedback(lastHistory.CommandLine, lastError, CancellationToken.None); - if (text is not null) + FeedbackItem? item = generalFeedback.GetFeedback(lastHistory.CommandLine, lastError, CancellationToken.None); + if (item is not null) { - resultList ??= new List(count); - resultList.Add(new FeedbackEntry(generalFeedback.Id, generalFeedback.Name, text)); + resultList ??= new List(count); + resultList.Add(new FeedbackResult(generalFeedback.Id, generalFeedback.Name, item)); } } finally @@ -188,14 +188,14 @@ public static class FeedbackHub waitTask.Wait(); cancellationSource!.Cancel(); - foreach (Task task in tasks!) + foreach (Task task in tasks!) { if (task.IsCompletedSuccessfully) { - FeedbackEntry? result = task.Result; + FeedbackResult? result = task.Result; if (result is not null) { - resultList ??= new List(count); + resultList ??= new List(count); resultList.Add(result); } } @@ -212,7 +212,7 @@ public static class FeedbackHub // A local helper function to avoid creating an instance of the generated delegate helper class // when no feedback provider is registered. - private static Func GetCallBack( + private static Func GetCallBack( string commandLine, ErrorRecord lastError, CancellationTokenSource cancellationSource) @@ -220,8 +220,8 @@ public static class FeedbackHub return state => { var provider = (IFeedbackProvider)state!; - var text = provider.GetFeedback(commandLine, lastError, cancellationSource.Token); - return text is null ? null : new FeedbackEntry(provider.Id, provider.Name, text); + var item = provider.GetFeedback(commandLine, lastError, cancellationSource.Token); + return item is null ? null : new FeedbackResult(provider.Id, provider.Name, item); }; } } diff --git a/src/System.Management.Automation/engine/Subsystem/FeedbackSubsystem/IFeedbackProvider.cs b/src/System.Management.Automation/engine/Subsystem/FeedbackSubsystem/IFeedbackProvider.cs index ba6d4f8433b..52c79a46fd5 100644 --- a/src/System.Management.Automation/engine/Subsystem/FeedbackSubsystem/IFeedbackProvider.cs +++ b/src/System.Management.Automation/engine/Subsystem/FeedbackSubsystem/IFeedbackProvider.cs @@ -3,7 +3,6 @@ #nullable enable -using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -14,6 +13,91 @@ namespace System.Management.Automation.Subsystem.Feedback { + /// + /// Layout for displaying the recommended actions. + /// + public enum FeedbackDisplayLayout + { + /// + /// Display one recommended action per row. + /// + Portrait, + + /// + /// Display all recommended actions in the same row. + /// + Landscape, + } + + /// + /// The class represents a feedback item generated by the feedback provider. + /// + public sealed class FeedbackItem + { + /// + /// Gets the description message about this feedback. + /// + public string Header { get; } + + /// + /// Gets the footer message about this feedback. + /// + public string? Footer { get; } + + /// + /// Gets the recommended actions -- command lines or even code snippets to run. + /// + public List? RecommendedActions { get; } + + /// + /// Gets the layout to use for displaying the recommended actions. + /// + public FeedbackDisplayLayout Layout { get; } + + /// + /// Gets or sets the next feedback item, if there is one. + /// + public FeedbackItem? Next { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The description message (must be not null or empty). + /// The recommended actions to take (optional). + public FeedbackItem(string header, List? actions) + : this(header, actions, footer: null, FeedbackDisplayLayout.Portrait) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The description message (must be not null or empty). + /// The recommended actions to take (optional). + /// The layout for displaying the actions. + public FeedbackItem(string header, List? actions, FeedbackDisplayLayout layout) + : this(header, actions, footer: null, layout) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The description message (must be not null or empty). + /// The recommended actions to take (optional). + /// The footer message (optional). + /// The layout for displaying the actions. + public FeedbackItem(string header, List? actions, string? footer, FeedbackDisplayLayout layout) + { + Requires.NotNullOrEmpty(header, nameof(header)); + + Header = header; + RecommendedActions = actions; + Footer = footer; + Layout = layout; + } + } + /// /// Interface for implementing a feedback provider on command failures. /// @@ -27,29 +111,29 @@ public interface IFeedbackProvider : ISubsystem /// /// Gets feedback based on the given commandline and error record. /// - /// - string? GetFeedback(string commandLine, ErrorRecord lastError, CancellationToken token); + /// The command line that was just executed. + /// The error that was triggerd by the command line. + /// The cancellation token to cancel the operation. + /// The feedback item. + FeedbackItem? GetFeedback(string commandLine, ErrorRecord lastError, CancellationToken token); } internal sealed class GeneralCommandErrorFeedback : IFeedbackProvider { private readonly Guid _guid; - private readonly object[] _args; - private ScriptBlock? _fuzzySb; internal GeneralCommandErrorFeedback() { _guid = new Guid("A3C6B07E-4A89-40C9-8BE6-2A9AAD2786A4"); - _args = new object[1]; } public Guid Id => _guid; - public string Name => "General"; + public string Name => "general"; public string Description => "The built-in general feedback source for command errors."; - public string? GetFeedback(string commandLine, ErrorRecord lastError, CancellationToken token) + public FeedbackItem? GetFeedback(string commandLine, ErrorRecord lastError, CancellationToken token) { var rsToUse = Runspace.DefaultRunspace; if (rsToUse is null) @@ -72,31 +156,31 @@ internal GeneralCommandErrorFeedback() if (command is not null) { - return StringUtil.Format( - SuggestionStrings.Suggestion_CommandExistsInCurrentDirectory, - target, - localTarget); + return new FeedbackItem( + StringUtil.Format(SuggestionStrings.Suggestion_CommandExistsInCurrentDirectory, target), + new List { localTarget }); } // Check fuzzy matching command names. if (ExperimentalFeature.IsEnabled("PSCommandNotFoundSuggestion")) { - _fuzzySb ??= ScriptBlock.CreateDelayParsedScriptBlock(@$" - param([string] $target) - $cmdNames = Get-Command $target -UseFuzzyMatching -FuzzyMinimumDistance 1 | Select-Object -First 5 -Unique -ExpandProperty Name - if ($cmdNames) {{ - [string]::Join(', ', $cmdNames) - }} - ", isProductCode: true); - - _args[0] = target; - var result = _fuzzySb.InvokeReturnAsIs(_args); - - if (result is not null && result != AutomationNull.Value) + var pwsh = PowerShell.Create(RunspaceMode.CurrentRunspace); + var results = pwsh.AddCommand("Get-Command") + .AddParameter("UseFuzzyMatching") + .AddParameter("FuzzyMinimumDistance", 1) + .AddParameter("Name", target) + .AddCommand("Select-Object") + .AddParameter("First", 5) + .AddParameter("Unique") + .AddParameter("ExpandProperty", "Name") + .Invoke(); + + if (results.Count > 0) { - return StringUtil.Format( + return new FeedbackItem( SuggestionStrings.Suggestion_CommandNotFound, - result.ToString()); + new List(results), + FeedbackDisplayLayout.Landscape); } } } @@ -108,7 +192,6 @@ internal GeneralCommandErrorFeedback() internal sealed class UnixCommandNotFound : IFeedbackProvider, ICommandPredictor { private readonly Guid _guid; - private string? _notFoundFeedback; private List? _candidates; internal UnixCommandNotFound() @@ -146,10 +229,7 @@ static bool IsFileExecutable(string path) } } - /// - /// Gets feedback based on the given commandline and error record. - /// - public string? GetFeedback(string commandLine, ErrorRecord lastError, CancellationToken token) + public FeedbackItem? GetFeedback(string commandLine, ErrorRecord lastError, CancellationToken token) { if (Platform.IsWindows || lastError.FullyQualifiedErrorId != "CommandNotFoundException") { @@ -176,15 +256,44 @@ static bool IsFileExecutable(string path) startInfo.RedirectStandardOutput = true; using var process = Process.Start(startInfo); - var stderr = process?.StandardError.ReadToEnd().Trim(); - - // The feedback contains recommended actions only if the output has multiple lines of text. - if (stderr?.IndexOf('\n') > 0) + if (process is not null) { - _notFoundFeedback = stderr; + string? header = null; + List? actions = null; - var stdout = process?.StandardOutput.ReadToEnd().Trim(); - return string.IsNullOrEmpty(stdout) ? stderr : $"{stderr}\n{stdout}"; + while (true) + { + string? line = process.StandardError.ReadLine(); + if (line is null) + { + break; + } + + if (line == string.Empty) + { + continue; + } + + if (line.StartsWith("sudo ", StringComparison.Ordinal)) + { + actions ??= new List(); + actions.Add(line.TrimEnd()); + } + else if (actions is null) + { + header = line; + } + } + + if (actions is not null && header is not null) + { + _candidates = actions; + + var footer = process.StandardOutput.ReadToEnd().Trim(); + return string.IsNullOrEmpty(footer) + ? new FeedbackItem(header, actions) + : new FeedbackItem(header, actions, footer, FeedbackDisplayLayout.Portrait); + } } } @@ -206,37 +315,6 @@ public bool CanAcceptFeedback(PredictionClient client, PredictorFeedbackKind fee public SuggestionPackage GetSuggestion(PredictionClient client, PredictionContext context, CancellationToken cancellationToken) { - if (_candidates is null && _notFoundFeedback is not null) - { - var text = _notFoundFeedback.AsSpan(); - // Set to null to avoid potential race condition. - _notFoundFeedback = null; - - // This loop searches for candidate results with almost no allocation. - while (true) - { - // The line is a candidate if it starts with "sudo ", such as "sudo apt install python3". - // 'sudo' is a command name that remains the same, so this check should work for all locales. - bool isCandidate = text.StartsWith("sudo ", StringComparison.Ordinal); - int index = text.IndexOf('\n'); - if (isCandidate) - { - var line = index != -1 ? text.Slice(0, index) : text; - _candidates ??= new List(); - _candidates.Add(new string(line.TrimEnd())); - } - - // Break out the loop if we are done with the last line. - if (index == -1 || index == text.Length - 1) - { - break; - } - - // Point to the rest of feedback text. - text = text.Slice(index + 1); - } - } - if (_candidates is not null) { string input = context.InputAst.Extent.Text; @@ -263,7 +341,6 @@ public SuggestionPackage GetSuggestion(PredictionClient client, PredictionContex public void OnCommandLineAccepted(PredictionClient client, IReadOnlyList history) { // Reset the candidate state. - _notFoundFeedback = null; _candidates = null; } diff --git a/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs b/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs index 1f88f48eaf5..e0d19c489c8 100644 --- a/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs +++ b/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs @@ -10,8 +10,8 @@ using System.Management.Automation.Internal; using System.Management.Automation.Language; using System.Management.Automation.Runspaces; +using System.Management.Automation.Subsystem.Feedback; using System.Runtime.InteropServices; -using System.Security; using System.Text; using System.Text.RegularExpressions; @@ -42,6 +42,8 @@ public static class HostUtilities { #region Internal Access + private static readonly char s_actionIndicator = HostSupportUnicode() ? '\u2b9e' : '>'; + private static readonly string s_checkForCommandInCurrentDirectoryScript = @" [System.Diagnostics.DebuggerHidden()] param() @@ -74,6 +76,25 @@ public static class HostUtilities private static readonly List s_suggestions = InitializeSuggestions(); + private static bool HostSupportUnicode() + { + // Reference: https://github.com/zkat/supports-unicode/blob/main/src/lib.rs + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return Environment.GetEnvironmentVariable("WT_SESSION") is not null || + Environment.GetEnvironmentVariable("TERM_PROGRAM") is "vscode" || + Environment.GetEnvironmentVariable("ConEmuTask") is "{cmd:Cmder}" || + Environment.GetEnvironmentVariable("TERM") is "xterm-256color" or "alacritty"; + } + + string ctype = Environment.GetEnvironmentVariable("LC_ALL") ?? + Environment.GetEnvironmentVariable("LC_CTYPE") ?? + Environment.GetEnvironmentVariable("LANG") ?? + string.Empty; + + return ctype.EndsWith("UTF8") || ctype.EndsWith("UTF-8"); + } + private static List InitializeSuggestions() { var suggestions = new List( @@ -811,6 +832,189 @@ public static Collection InvokeOnRunspace(PSCommand command, Runspace #endregion + #region Feedback Rendering + + /// + /// Render the feedbacks to the specified host. + /// + /// The feedback results. + /// The host to render to. + public static void RenderFeedback(List feedbacks, PSHostUserInterface ui) + { + // Caption style is dimmed bright white with italic effect, used for fixed captions, such as '[' and ']'. + string captionStyle = "\x1b[97;2;3m"; + string italics = "\x1b[3m"; + string nameStyle = PSStyle.Instance.Formatting.FeedbackName; + string textStyle = PSStyle.Instance.Formatting.FeedbackText; + string actionStyle = PSStyle.Instance.Formatting.FeedbackAction; + string ansiReset = PSStyle.Instance.Reset; + + if (!ui.SupportsVirtualTerminal) + { + captionStyle = string.Empty; + italics = string.Empty; + nameStyle = string.Empty; + textStyle = string.Empty; + actionStyle = string.Empty; + ansiReset = string.Empty; + } + + var output = new StringBuilder(); + var chkset = new HashSet(); + + foreach (FeedbackResult entry in feedbacks) + { + output.AppendLine(); + output.Append($"{captionStyle}[{ansiReset}") + .Append($"{nameStyle}{italics}{entry.Name}{ansiReset}") + .Append($"{captionStyle}]{ansiReset}"); + + FeedbackItem item = entry.Item; + chkset.Add(item); + + do + { + RenderText(output, item.Header, textStyle, ansiReset, indent: 2, startOnNewLine: true); + RenderActions(output, item, textStyle, actionStyle, ansiReset); + RenderText(output, item.Footer, textStyle, ansiReset, indent: 2, startOnNewLine: true); + + // A feedback provider may return multiple feedback items, though that may be rare. + item = item.Next; + } + while (item is not null && chkset.Add(item)); + + ui.Write(output.ToString()); + output.Clear(); + chkset.Clear(); + } + + // Feedback section ends with a new line. + ui.WriteLine(); + } + + /// + /// Helper function to render feedback message. + /// + /// The output string builder to write to. + /// The text to be rendered. + /// The style to be used. + /// The ANSI code to reset. + /// The number of spaces for indentation. + /// Indicates whether to start writing from a new line. + internal static void RenderText(StringBuilder output, string text, string style, string ansiReset, int indent, bool startOnNewLine) + { + if (text is null) + { + return; + } + + if (startOnNewLine) + { + // Start writing the text on the next line. + output.AppendLine(); + } + + // Apply the style. + output.Append(style); + + int count = 0; + var trimChars = "\r\n".AsSpan(); + var span = text.AsSpan().Trim(trimChars); + + // This loop renders the text with minimal allocation. + while (true) + { + int index = span.IndexOf('\n'); + var line = index is -1 ? span : span.Slice(0, index); + + if (startOnNewLine || count > 0) + { + output.Append(' ', indent); + } + + output.Append(line.TrimEnd('\r')).AppendLine(); + + // Break out the loop if we are done with the last line. + if (index is -1) + { + break; + } + + // Point to the rest of feedback text. + span = span.Slice(index + 1); + count++; + } + + output.Append(ansiReset); + } + + /// + /// Helper function to render feedback actions. + /// + /// The output string builder to write to. + /// The feedback item to be rendered. + /// The style used for feedback messages. + /// The style used for feedback actions. + /// The ANSI code to reset. + internal static void RenderActions(StringBuilder output, FeedbackItem item, string textStyle, string actionStyle, string ansiReset) + { + if (item.RecommendedActions is null || item.RecommendedActions.Count is 0) + { + return; + } + + List actions = item.RecommendedActions; + if (item.Layout is FeedbackDisplayLayout.Landscape) + { + // Add 4-space indentation and write the indicator. + output.Append($" {textStyle}{s_actionIndicator}{ansiReset} "); + + // Then concatenate the action texts. + for (int i = 0; i < actions.Count; i++) + { + string action = actions[i]; + if (i > 0) + { + output.Append(", "); + } + + output.Append(actionStyle).Append(action).Append(ansiReset); + } + + output.AppendLine(); + } + else + { + int lastIndex = actions.Count - 1; + for (int i = 0; i < actions.Count; i++) + { + string action = actions[i]; + + // Add 4-space indentation and write the indicator, then write the action. + output.Append($" {textStyle}{s_actionIndicator}{ansiReset} "); + + if (action.Contains('\n')) + { + // If the action is a code snippet, properly render it with the right indentation. + RenderText(output, action, actionStyle, ansiReset, indent: 6, startOnNewLine: false); + + // Append an extra line unless it's the last action. + if (i != lastIndex) + { + output.AppendLine(); + } + } + else + { + output.Append(actionStyle).Append(action).Append(ansiReset) + .AppendLine(); + } + } + } + } + + #endregion + #endregion } diff --git a/src/System.Management.Automation/resources/SuggestionStrings.resx b/src/System.Management.Automation/resources/SuggestionStrings.resx index 14e9c01b275..ea249db55e7 100644 --- a/src/System.Management.Automation/resources/SuggestionStrings.resx +++ b/src/System.Management.Automation/resources/SuggestionStrings.resx @@ -118,10 +118,13 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - The command "{0}" was not found, but does exist in the current location. PowerShell does not load commands from the current location by default. If you trust this command, instead type: "{1}". See "get-help about_Command_Precedence" for more details. + The command "{0}" was not found, but does exist in the current location. +PowerShell does not load commands from the current location by default (see 'Get-Help about_Command_Precedence'). + +If you trust this command, run the following command instead: - The most similar commands are: {0}. + The most similar commands are: Rule must be a ScriptBlock for dynamic match types. diff --git a/test/xUnit/csharp/test_Feedback.cs b/test/xUnit/csharp/test_Feedback.cs index 372c9ec8cc4..b3c1fb08ecf 100644 --- a/test/xUnit/csharp/test_Feedback.cs +++ b/test/xUnit/csharp/test_Feedback.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using System.IO; using System.Management.Automation; using System.Management.Automation.Subsystem; @@ -42,7 +43,7 @@ private MyFeedback(Guid id, string name, string description, bool delay) public string Description => _description; - public string GetFeedback(string commandLine, ErrorRecord errorRecord, CancellationToken token) + public FeedbackItem GetFeedback(string commandLine, ErrorRecord errorRecord, CancellationToken token) { if (_delay) { @@ -51,7 +52,9 @@ public string GetFeedback(string commandLine, ErrorRecord errorRecord, Cancellat Thread.Sleep(2500); } - return $"{commandLine}+{errorRecord.FullyQualifiedErrorId}"; + return new FeedbackItem( + "slow-feedback-caption", + new List { $"{commandLine}+{errorRecord.FullyQualifiedErrorId}" }); } } @@ -76,40 +79,40 @@ public static void GetFeedback() .Invoke(input: null, settings); pwsh.Commands.Clear(); - // Run a command 'feedbacktest', so as to trigger the 'General' feedback. + // Run a command 'feedbacktest', so as to trigger the 'general' feedback. pwsh.AddScript("feedbacktest").Invoke(input: null, settings); pwsh.Commands.Clear(); try { // Register the slow feedback provider. - // The 'General' feedback provider is built-in and registered by default. + // The 'general' feedback provider is built-in and registered by default. SubsystemManager.RegisterSubsystem(SubsystemKind.FeedbackProvider, MyFeedback.SlowFeedback); - // Expect the result from 'General' only because the 'slow' one cannot finish before the specified timeout. + // Expect the result from 'general' only because the 'slow' one cannot finish before the specified timeout. // The specified timeout is exaggerated to make the test reliable. // xUnit must spin up a lot tasks, which makes the test unreliable when the time difference between 'delay' and 'timeout' is small. var feedbacks = FeedbackHub.GetFeedback(pwsh.Runspace, millisecondsTimeout: 1500); string expectedCmd = Path.Combine(".", "feedbacktest"); - // Test the result from the 'General' feedback provider. + // Test the result from the 'general' feedback provider. Assert.Single(feedbacks); - Assert.Equal("General", feedbacks[0].Name); - Assert.Contains(expectedCmd, feedbacks[0].Text); + Assert.Equal("general", feedbacks[0].Name); + Assert.Equal(expectedCmd, feedbacks[0].Item.RecommendedActions[0]); - // Expect the result from both 'General' and the 'slow' feedback providers. + // Expect the result from both 'general' and the 'slow' feedback providers. // Same here -- the specified timeout is exaggerated to make the test reliable. // xUnit must spin up a lot tasks, which makes the test unreliable when the time difference between 'delay' and 'timeout' is small. feedbacks = FeedbackHub.GetFeedback(pwsh.Runspace, millisecondsTimeout: 4000); Assert.Equal(2, feedbacks.Count); - FeedbackEntry entry1 = feedbacks[0]; - Assert.Equal("General", entry1.Name); - Assert.Contains(expectedCmd, entry1.Text); + FeedbackResult entry1 = feedbacks[0]; + Assert.Equal("general", entry1.Name); + Assert.Equal(expectedCmd, entry1.Item.RecommendedActions[0]); - FeedbackEntry entry2 = feedbacks[1]; + FeedbackResult entry2 = feedbacks[1]; Assert.Equal("Slow", entry2.Name); - Assert.Equal("feedbacktest+CommandNotFoundException", entry2.Text); + Assert.Equal("feedbacktest+CommandNotFoundException", entry2.Item.RecommendedActions[0]); } finally { diff --git a/test/xUnit/csharp/test_Subsystem.cs b/test/xUnit/csharp/test_Subsystem.cs index 724c8747e5a..1318f13139d 100644 --- a/test/xUnit/csharp/test_Subsystem.cs +++ b/test/xUnit/csharp/test_Subsystem.cs @@ -65,7 +65,7 @@ private MyCompositeSubsystem(Guid id) #region IFeedbackProvider - public string GetFeedback(string commandLine, ErrorRecord errorRecord, CancellationToken token) => "nothing"; + public FeedbackItem GetFeedback(string commandLine, ErrorRecord errorRecord, CancellationToken token) => new FeedbackItem("nothing", null); #endregion