// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Diagnostics;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Aspire.Cli.Utils.Markdown;
using Microsoft.Extensions.Logging;
using Semver;
using Spectre.Console;
using Spectre.Console.Rendering;
namespace Aspire.Cli.Interaction;
internal class ConsoleInteractionService : IInteractionService
{
private static readonly Style s_exitCodeMessageStyle = new Style(foreground: Color.RoyalBlue1, background: null, decoration: Decoration.None);
private static readonly Style s_infoMessageStyle = new Style(foreground: Color.Green, background: null, decoration: Decoration.None);
private static readonly Style s_waitingMessageStyle = new Style(foreground: Color.Yellow, background: null, decoration: Decoration.None);
private static readonly Style s_errorMessageStyle = new Style(foreground: Color.Red, background: null, decoration: Decoration.Bold);
private static readonly Style s_searchHighlightStyle = new Style(foreground: Color.Black, background: Color.Cyan1, decoration: Decoration.None);
internal const string AllChoice = "all";
internal const string NoneChoice = "none";
private readonly IAnsiConsole _outConsole;
private readonly IAnsiConsole _errorConsole;
private readonly CliExecutionContext _executionContext;
private readonly ICliHostEnvironment _hostEnvironment;
private readonly IProcessPathProvider _processPathProvider;
private readonly ILogger _stdoutLogger;
private readonly ILogger _stderrLogger;
private readonly ConsoleLogBufferContext _logBufferContext;
private int _inStatus;
/// <summary>
/// Console used for human-readable messages; routes to stderr when <see cref="Console"/> is set to <see cref="ConsoleOutput.Error"/>.
/// </summary>
private IAnsiConsole MessageConsole => GetConsoleOutput(null);
// Limit logging to prompts and messages. Don't log raw text output since it may contain sensitive information.
private ILogger MessageLogger => GetLogger(null);
private IAnsiConsole GetConsoleOutput(ConsoleOutput? consoleOverride) => (consoleOverride ?? Console) switch
{
ConsoleOutput.Error => _errorConsole,
_ => _outConsole
};
private ILogger GetLogger(ConsoleOutput? consoleOverride) => (consoleOverride ?? Console) switch
{
ConsoleOutput.Error => _stderrLogger,
_ => _stdoutLogger
};
public ConsoleOutput Console { get; set; }
public bool SupportsLinks => MessageConsole.Profile.Capabilities.Links;
private bool UsesConsoleLogging => _executionContext.ConsoleLogLevel is not null and not LogLevel.None;
private bool ShouldShowInteractiveStatus(string statusText)
{
// Use atomic check-and-set to prevent nested Spectre.Console Status operations.
// Spectre.Console throws if multiple interactive operations run concurrently.
// If already in a status, or in debug/non-interactive/console logging mode, fall back without a spinner.
// Also skip status display if statusText is empty (e.g., when outputting JSON).
// IMPORTANT: CompareExchange must be evaluated last so that short-circuit evaluation
// skips the swap when an earlier condition forces the fallback path. Otherwise the
// swap would set _inStatus to 1 but the try/finally that resets it would never run,
// permanently disabling interactive status for the lifetime of the service.
if (_executionContext.DebugMode ||
UsesConsoleLogging ||
!_hostEnvironment.SupportsInteractiveOutput ||
string.IsNullOrEmpty(statusText) ||
Interlocked.CompareExchange(ref _inStatus, 1, 0) != 0)
{
return false;
}
return true;
}
public ConsoleInteractionService(ConsoleEnvironment consoleEnvironment, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IProcessPathProvider processPathProvider, ILoggerFactory loggerFactory, ConsoleLogBufferContext logBufferContext)
{
ArgumentNullException.ThrowIfNull(consoleEnvironment);
ArgumentNullException.ThrowIfNull(executionContext);
ArgumentNullException.ThrowIfNull(hostEnvironment);
ArgumentNullException.ThrowIfNull(processPathProvider);
ArgumentNullException.ThrowIfNull(loggerFactory);
ArgumentNullException.ThrowIfNull(logBufferContext);
_outConsole = consoleEnvironment.Out;
_errorConsole = consoleEnvironment.Error;
_executionContext = executionContext;
_hostEnvironment = hostEnvironment;
_processPathProvider = processPathProvider;
_stdoutLogger = loggerFactory.CreateLogger($"Aspire.Cli.Console.{CliLogFormat.Categories.Stdout}");
_stderrLogger = loggerFactory.CreateLogger($"Aspire.Cli.Console.{CliLogFormat.Categories.Stderr}");
_logBufferContext = logBufferContext;
}
public async Task<T> ShowStatusAsync<T>(string statusText, Func<Task<T>> action, KnownEmoji? emoji = null, bool allowMarkup = false)
{
if (!allowMarkup)
{
statusText = statusText.EscapeMarkup();
}
if (emoji is { } e)
{
statusText = ConsoleHelpers.FormatEmojiPrefix(e, MessageConsole) + statusText;
}
if (!ShouldShowInteractiveStatus(statusText))
{
// Skip displaying if status text is empty (e.g., when outputting JSON)
if (!string.IsNullOrEmpty(statusText))
{
// Text has already been escaped and emoji prepended, so pass as markup
DisplaySubtleMessage(statusText, allowMarkup: true);
}
else
{
MessageLogger.LogInformation("Status: {StatusText}", statusText);
}
return await action();
}
try
{
return await MessageConsole.Status()
.Spinner(Spinner.Known.Dots3)
.StartAsync(statusText, (context) => action());
}
finally
{
Interlocked.Exchange(ref _inStatus, 0);
}
}
public async Task<T> ShowDynamicStatusAsync<T>(string initialStatusText, Func<Action<string>, Task<T>> action, KnownEmoji? emoji = null)
{
var emojiPrefix = emoji is { } e ? ConsoleHelpers.FormatEmojiPrefix(e, MessageConsole) : string.Empty;
var initialDisplayText = emojiPrefix + initialStatusText.EscapeMarkup();
if (!ShouldShowInteractiveStatus(initialStatusText))
{
if (!string.IsNullOrEmpty(initialStatusText))
{
DisplaySubtleMessage(initialDisplayText, allowMarkup: true);
}
else
{
MessageLogger.LogInformation("Status: {StatusText}", initialStatusText);
}
return await action(text =>
{
if (!string.IsNullOrEmpty(text))
{
DisplaySubtleMessage(emojiPrefix + text.EscapeMarkup(), allowMarkup: true);
}
});
}
try
{
return await MessageConsole.Status()
.Spinner(Spinner.Known.Dots3)
.StartAsync(initialDisplayText, context => action(text => context.Status = emojiPrefix + text.EscapeMarkup()));
}
finally
{
Interlocked.Exchange(ref _inStatus, 0);
}
}
public void ShowStatus(string statusText, Action action, KnownEmoji? emoji = null, bool allowMarkup = false)
{
MessageLogger.LogInformation("Status: {StatusText}", statusText);
if (!allowMarkup)
{
statusText = statusText.EscapeMarkup();
}
if (emoji is { } e)
{
statusText = ConsoleHelpers.FormatEmojiPrefix(e, MessageConsole) + statusText;
}
if (!ShouldShowInteractiveStatus(statusText))
{
if (!string.IsNullOrEmpty(statusText))
{
// Text has already been escaped and emoji prepended, so pass as markup
DisplaySubtleMessage(statusText, allowMarkup: true);
}
action();
return;
}
try
{
MessageConsole.Status()
.Spinner(Spinner.Known.Dots3)
.Start(statusText, (context) => action());
}
finally
{
Interlocked.Exchange(ref _inStatus, 0);
}
}
public async Task<string> PromptForStringAsync(string promptText, Func<string, ValidationResult>? validator = null, bool isSecret = false, bool required = false, PromptBinding<string?>? binding = null, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(promptText, nameof(promptText));
var (wasProvided, value, defaultValue) = PromptBinding.Resolve(binding);
if (wasProvided && value is not null)
{
ValidateResolvedStringValue(value, required, validator, binding!.SymbolDisplayName);
return value;
}
if (!_hostEnvironment.SupportsInteractiveInput)
{
if (binding != null)
{
if (binding.NonInteractiveDefaultValue != null)
{
ValidateResolvedStringValue(binding.NonInteractiveDefaultValue, required, validator, binding.SymbolDisplayName);
return binding.NonInteractiveDefaultValue;
}
ThrowNonInteractiveError(binding.SymbolDisplayName);
}
throw new InvalidOperationException(InteractionServiceStrings.InteractiveInputNotSupported);
}
// Buffer console logs while interactive prompts are active so
// background debug output doesn't drown the prompt UI.
using var promptScope = _logBufferContext.BeginInteractivePromptScope();
MessageLogger.LogInformation("Prompt: {PromptText} (default: {DefaultValue}, secret: {IsSecret})", promptText, isSecret ? "****" : defaultValue ?? "(none)", isSecret);
var displayDefaultValue = defaultValue?.EscapeMarkup();
var prompt = new TextPrompt<string>(promptText)
{
IsSecret = isSecret,
AllowEmpty = !required
};
if (displayDefaultValue != null)
{
prompt.DefaultValue(displayDefaultValue);
prompt.ShowDefaultValue();
prompt.DefaultValueStyle(new Style(Color.Fuchsia));
}
if (validator is not null)
{
prompt.Validate(validator);
}
var result = await MessageConsole.PromptAsync(prompt, cancellationToken);
MessageLogger.LogInformation("Prompt result: {Result}", isSecret ? "****" : result);
if (defaultValue is not null && string.Equals(result, displayDefaultValue, StringComparison.Ordinal))
{
return defaultValue;
}
return result;
}
public Task<string> PromptForFilePathAsync(string promptText, Func<string, ValidationResult>? validator = null, bool directory = false, bool required = false, PromptBinding<string?>? binding = null, bool retryOnValidationFailure = false, CancellationToken cancellationToken = default)
{
return PromptForStringAsync(promptText, validator, isSecret: false, required, binding, cancellationToken);
}
public async Task<T> PromptForSelectionAsync<T>(string promptText, IEnumerable<T> choices, Func<T, string> choiceFormatter, PromptBinding<string?>? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull
{
ArgumentNullException.ThrowIfNull(promptText, nameof(promptText));
ArgumentNullException.ThrowIfNull(choices, nameof(choices));
ArgumentNullException.ThrowIfNull(choiceFormatter, nameof(choiceFormatter));
// Materialize once to avoid re-enumerating the choices enumerable.
var choicesList = choices as IReadOnlyList<T> ?? choices.ToList();
var (wasProvided, value, defaultValue) = PromptBinding.Resolve(binding);
if (wasProvided && value is not null)
{
return MatchChoiceOrThrow(value, binding!, choicesList, choiceFormatter);
}
if (!_hostEnvironment.SupportsInteractiveInput)
{
if (binding != null)
{
if (binding.NonInteractiveDefaultValue != null)
{
return MatchChoiceOrThrow(binding.NonInteractiveDefaultValue, binding, choicesList, choiceFormatter);
}
ThrowNonInteractiveError(binding.SymbolDisplayName);
}
throw new InvalidOperationException(InteractionServiceStrings.InteractiveInputNotSupported);
}
// Check if the choices collection is empty to avoid throwing an InvalidOperationException
if (choicesList.Count == 0)
{
throw new EmptyChoicesException(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NoItemsAvailableForSelection, promptText));
}
// Buffer console logs while interactive prompts are active so
// background debug output doesn't drown the prompt UI.
using var promptScope = _logBufferContext.BeginInteractivePromptScope();
MessageLogger.LogInformation("Selection prompt: {PromptText}", promptText);
var prompt = new SelectionPrompt<T>()
.Title(promptText)
.UseConverter(choiceFormatter)
.AddChoices(choicesList)
.PageSize(10)
.EnableSearch();
prompt.SearchHighlightStyle = s_searchHighlightStyle;
var result = await MessageConsole.PromptAsync(prompt, cancellationToken);
MessageLogger.LogInformation("Selection result: {Result}", choiceFormatter(result));
// The SelectionPrompt clears its display after the user selects.
// Echo the prompt text and selected value so the user can see what was chosen.
if (echoSelected)
{
MessageConsole.MarkupLine($"{promptText} {choiceFormatter(result)}");
}
return result;
}
public async Task<IReadOnlyList<T>> PromptForSelectionsAsync<T>(string promptText, IEnumerable<T> choices, Func<T, string> choiceFormatter, IEnumerable<T>? preSelected = null, bool optional = false, PromptBinding<string?>? binding = null, bool echoSelected = true, IEnumerable<T>? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull
{
ArgumentNullException.ThrowIfNull(promptText, nameof(promptText));
ArgumentNullException.ThrowIfNull(choices, nameof(choices));
ArgumentNullException.ThrowIfNull(choiceFormatter, nameof(choiceFormatter));
// Materialize once to avoid re-enumerating the choices enumerable.
var choicesList = choices as IReadOnlyList<T> ?? choices.ToList();
// The non-interactive validation set defaults to the visible choices, but callers
// can pass a narrower bindingChoices subset when some visible items should never
// be addressable from the command-line option (e.g., a UX-only "configure MCP
// server" entry that lives in the same multi-select prompt as the real catalog).
var bindingChoicesList = bindingChoices is null
? choicesList
: bindingChoices as IReadOnlyList<T> ?? bindingChoices.ToList();
var (wasProvided, value, defaultValue) = PromptBinding.Resolve(binding);
if (wasProvided && value is not null)
{
return MatchChoicesOrThrow(value, binding!, bindingChoicesList, choiceFormatter);
}
if (!_hostEnvironment.SupportsInteractiveInput)
{
if (binding != null)
{
if (binding.NonInteractiveDefaultValue != null)
{
return MatchChoicesOrThrow(binding.NonInteractiveDefaultValue, binding, bindingChoicesList, choiceFormatter);
}
ThrowNonInteractiveError(binding.SymbolDisplayName);
}
throw new InvalidOperationException(InteractionServiceStrings.InteractiveInputNotSupported);
}
if (choicesList.Count == 0)
{
throw new EmptyChoicesException(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NoItemsAvailableForSelection, promptText));
}
// Buffer console logs while interactive prompts are active so
// background debug output doesn't drown the prompt UI.
using var promptScope = _logBufferContext.BeginInteractivePromptScope();
var preSelectedSet = preSelected is not null ? new HashSet<T>(preSelected) : null;
MessageLogger.LogInformation("Selection prompt: {PromptText}", promptText);
var prompt = new MultiSelectionPrompt<T>()
.Title(promptText)
.UseConverter(choiceFormatter)
.PageSize(10);
prompt.Required = !optional;
foreach (var choice in choicesList)
{
var item = prompt.AddChoice(choice);
if (preSelectedSet?.Contains(choice) == true)
{
item.Select();
}
}
var result = await MessageConsole.PromptAsync(prompt, cancellationToken);
MessageLogger.LogInformation("Selection results: {Results}", string.Join(", ", result.Select(choiceFormatter)));
// The MultiSelectionPrompt clears its display after the user selects.
// Echo the prompt text and selected values so the user can see what was chosen.
if (echoSelected)
{
if (result.Count == 0)
{
MessageConsole.MarkupLine($"{promptText} [dim](none)[/]");
}
else
{
MessageConsole.MarkupLine(promptText);
foreach (var item in result)
{
MessageConsole.MarkupLine($" - {choiceFormatter(item)}");
}
}
}
return result;
}
public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion)
{
var cliInformationalVersion = _executionContext.IdentityVersion;
// When both versions parse, tell the user which side is older and how to
// update it rather than the ambiguous "upgrade the AppHost or Aspire CLI".
var errorMessage = InteractionServiceStrings.AppHostNotCompatibleConsiderUpgrading;
string? updateCommand = null;
if (SemVersion.TryParse(appHostHostingVersion, SemVersionStyles.Any, out var hostingVersion) &&
SemVersion.TryParse(cliInformationalVersion, SemVersionStyles.Any, out var cliVersion))
{
var comparison = SemVersion.ComparePrecedence(hostingVersion, cliVersion);
if (comparison < 0)
{
errorMessage = InteractionServiceStrings.AppHostNotCompatibleUpdateAppHost;
updateCommand = "aspire update";
}
else if (comparison > 0)
{
errorMessage = InteractionServiceStrings.AppHostNotCompatibleUpdateCli;
updateCommand = DotNetToolDetection.GetDotNetToolUpdateCommand(_processPathProvider.ProcessPath)
?? NpmInstallDetection.GetNpmUpdateCommand()
?? "aspire update";
}
}
DisplayError(errorMessage);
MessageConsole.WriteLine();
var grid = new Grid();
grid.AddColumn(new GridColumn { Padding = new Padding(2, 0, 1, 0), NoWrap = true });
grid.AddColumn(new GridColumn { Padding = new Padding(0) });
grid.AddRow(
new Markup($"[bold]{InteractionServiceStrings.AspireHostingSDKVersion.EscapeMarkup()}[/]"),
new Markup(appHostHostingVersion.EscapeMarkup()));
grid.AddRow(
new Markup($"[bold]{InteractionServiceStrings.AspireCLIVersion.EscapeMarkup()}[/]"),
new Markup(cliInformationalVersion.EscapeMarkup()));
grid.AddRow(
new Markup($"[bold]{InteractionServiceStrings.RequiredCapability.EscapeMarkup()}[/]"),
new Markup(ex.RequiredCapability.EscapeMarkup()));
MessageConsole.Write(grid);
if (updateCommand is not null)
{
MessageConsole.WriteLine();
MessageConsole.MarkupLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ToUpdateRunCommand, updateCommand.EscapeMarkup()));
}
MessageConsole.WriteLine();
return CliExitCodes.AppHostIncompatible;
}
public void DisplayError(string errorMessage, bool allowMarkup = false)
{
var formatted = allowMarkup ? errorMessage : errorMessage.EscapeMarkup();
// Always write errors to stderr so callers can capture them separately from stdout.
WriteEmojiMessage(_errorConsole, _stderrLogger, KnownEmojis.CrossMark, $"[red bold]{formatted}[/]", allowMarkup: true);
}
public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false, ConsoleOutput? consoleOverride = null)
{
WriteEmojiMessage(GetConsoleOutput(consoleOverride), GetLogger(consoleOverride), emoji, message, allowMarkup);
}
private static void WriteEmojiMessage(IAnsiConsole target, ILogger logger, KnownEmoji emoji, string message, bool allowMarkup)
{
if (logger.IsEnabled(LogLevel.Information))
{
// Only attempt to parse/remove markup when the message is expected to contain it.
// Plain text messages may contain characters like '[' that would be rejected by the markup parser.
var logMessage = allowMarkup ? StringUtils.RemoveMarkup(message) : message;
logger.LogInformation("{Message}", ConsoleHelpers.FormatEmojiPrefix(emoji, target, replaceEmoji: true) + logMessage);
}
var displayMessage = allowMarkup ? message : message.EscapeMarkup();
var grid = ConsoleHelpers.CreateEmojiGrid(emoji, target, new Markup(displayMessage));
target.Write(grid);
}
public void DisplayPlainText(string message)
{
// Write directly to avoid Spectre.Console line wrapping
MessageConsole.Profile.Out.Writer.WriteLine(message);
}
public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null)
{
var effectiveConsole = consoleOverride ?? Console;
// Write raw text directly to avoid console wrapping.
// When consoleOverride is null, respect the Console setting.
var target = effectiveConsole == ConsoleOutput.Error ? _errorConsole : _outConsole;
target.Profile.Out.Writer.WriteLine(text);
}
public void DisplayMarkdown(string markdown, ConsoleOutput? consoleOverride = null, int? maxWidth = null)
{
var effectiveConsole = consoleOverride ?? Console;
if (ShouldDisplayMarkdownAsPlainText(effectiveConsole))
{
var plainText = MarkdownToSpectreConverter.ConvertToPlainText(markdown);
DisplayRawText(plainText, effectiveConsole);
return;
}
var target = effectiveConsole == ConsoleOutput.Error ? _errorConsole : _outConsole;
var originalWidth = target.Profile.Width;
if (maxWidth is not null)
{
target.Profile.Width = Math.Min(originalWidth, maxWidth.Value);
}
try
{
var renderable = MarkdownToSpectreConverter.ConvertToRenderable(markdown);
target.Write(renderable);
// A row automatically includes a newline, so we don't need to call WriteLine after writing the renderable.
if (renderable is not Rows)
{
target.WriteLine();
}
}
finally
{
if (maxWidth is not null)
{
target.Profile.Width = originalWidth;
}
}
}
private bool ShouldDisplayMarkdownAsPlainText(ConsoleOutput effectiveConsole)
{
if (!_hostEnvironment.SupportsInteractiveOutput)
{
return true;
}
return effectiveConsole == ConsoleOutput.Error
? System.Console.IsErrorRedirected
: System.Console.IsOutputRedirected;
}
public void DisplayMarkupLine(string markup)
{
MessageConsole.MarkupLine(markup);
}
public void WriteConsoleLog(string message, int? lineNumber = null, string? type = null, bool isErrorMessage = false)
{
var style = isErrorMessage ? s_errorMessageStyle
: type switch
{
ConsoleLogTypes.Waiting => s_waitingMessageStyle,
ConsoleLogTypes.Running => s_infoMessageStyle,
ConsoleLogTypes.ExitCode => s_exitCodeMessageStyle,
ConsoleLogTypes.FailedToStart => s_errorMessageStyle,
_ => s_infoMessageStyle
};
var prefix = lineNumber.HasValue ? $"#{lineNumber.Value}: " : "";
MessageConsole.WriteLine($"{prefix}{message}", style);
}
public void DisplaySuccess(string message, bool allowMarkup = false)
{
DisplayMessage(KnownEmojis.CheckMarkButton, message, allowMarkup);
}
public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines)
{
var linesArray = lines.ToArray();
// Special case one stderr line to include error icon.
if (linesArray.Length == 1 && linesArray[0].Stream == OutputLineStream.StdErr)
{
DisplayError(linesArray[0].Line);
return;
}
foreach (var (stream, line) in linesArray)
{
if (stream == OutputLineStream.StdOut)
{
MessageConsole.MarkupLine(line.EscapeMarkup());
}
else
{
MessageConsole.MarkupLine($"[red]{line.EscapeMarkup()}[/]");
}
}
}
public void DisplayRenderable(IRenderable renderable)
{
MessageConsole.Write(renderable);
}
public async Task DisplayLiveAsync(IRenderable initialRenderable, Func<Action<IRenderable>, Task> callback)
{
await MessageConsole.Live(initialRenderable)
.AutoClear(false)
.StartAsync(async ctx =>
{
await callback(renderable => ctx.UpdateTarget(renderable));
});
}
public void DisplayCancellationMessage(string? message = null, ConsoleOutput? consoleOverride = null)
{
GetConsoleOutput(consoleOverride).WriteLine();
DisplayMessage(KnownEmojis.StopSign, $"[teal bold]{message ?? InteractionServiceStrings.StoppingAspire}[/]", allowMarkup: true, consoleOverride: consoleOverride);
}
public async Task<bool> PromptConfirmAsync(string promptText, PromptBinding<bool>? binding = null, CancellationToken cancellationToken = default)
{
var (wasProvided, value, defaultValue) = PromptBinding.Resolve(binding);
if (wasProvided)
{
return value;
}
if (!_hostEnvironment.SupportsInteractiveInput)
{
if (binding is not null)
{
if (binding.HasExplicitDefault)
{
return binding.NonInteractiveDefaultValue;
}
ThrowNonInteractiveError(binding.SymbolDisplayName);
}
throw new InvalidOperationException(InteractionServiceStrings.InteractiveInputNotSupported);
}
// Buffer console logs while interactive prompts are active so
// background debug output doesn't drown the prompt UI.
using var promptScope = _logBufferContext.BeginInteractivePromptScope();
// When no binding is provided, default to true (matching the historical behavior
// where the old ConfirmAsync signature had defaultValue = true).
if (binding is null)
{
defaultValue = true;
}
MessageLogger.LogInformation("Confirm: {PromptText} (default: {DefaultValue})", promptText, defaultValue);
// Use [Y/n] or [y/N] convention where the capitalized letter indicates the default value.
var yesChoice = defaultValue ? 'Y' : 'y';
var noChoice = defaultValue ? 'n' : 'N';
var result = await PromptConfirmWithSingleKeyAsync(promptText, yesChoice, noChoice, defaultValue, cancellationToken);
MessageLogger.LogInformation("Confirm result: {Result}", result);
return result;
}
private async Task<bool> PromptConfirmWithSingleKeyAsync(string promptText, char yesChoice, char noChoice, bool defaultValue, CancellationToken cancellationToken)
{
MessageConsole.Markup(promptText);
MessageConsole.Markup($" [blue][[{yesChoice}/{noChoice}]][/]: ");
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
if (await MessageConsole.Input.ReadKeyAsync(intercept: true, cancellationToken) is not { } key)
{
continue;
}
if (key.Key == ConsoleKey.Enter || key.KeyChar is '\r' or '\n')
{
MessageConsole.WriteLine((defaultValue ? yesChoice : noChoice).ToString()); // Echo the default choice
return defaultValue;
}
if (char.ToLowerInvariant(key.KeyChar) == char.ToLowerInvariant(yesChoice))
{
MessageConsole.WriteLine(key.KeyChar.ToString());
return true;
}
if (char.ToLowerInvariant(key.KeyChar) == char.ToLowerInvariant(noChoice))
{
MessageConsole.WriteLine(key.KeyChar.ToString());
return false;
}
}
}
public void DisplaySubtleMessage(string message, bool allowMarkup = false)
{
MessageLogger.LogInformation("{Message}", message);
var displayMessage = allowMarkup ? message : message.EscapeMarkup();
MessageConsole.MarkupLine($"[dim]{displayMessage}[/]");
}
public void DisplayEmptyLine()
{
MessageConsole.WriteLine();
}
private const string UpdateUrl = "https://aka.ms/aspire/update";
public void DisplayVersionUpdateNotification(string newerVersion, string? updateCommand = null)
{
// Write to stderr to avoid corrupting stdout when JSON output is used
_errorConsole.WriteLine();
_errorConsole.MarkupLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NewCliVersionAvailable, newerVersion.EscapeMarkup()));
if (!string.IsNullOrEmpty(updateCommand))
{
_errorConsole.MarkupLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ToUpdateRunCommand, updateCommand.EscapeMarkup()));
}
_errorConsole.MarkupLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.MoreInfoNewCliVersion, MarkupHelpers.SafeLink(this, UpdateUrl)));
}
internal static T? MatchChoice<T>(string value, IEnumerable<T> choices, Func<T, string> choiceFormatter) where T : notnull
{
return choices.FirstOrDefault(c => string.Equals(choiceFormatter(c), value, StringComparison.OrdinalIgnoreCase))
?? choices.FirstOrDefault(c => string.Equals(c.ToString(), value, StringComparison.OrdinalIgnoreCase));
}
internal static IReadOnlyList<T>? MatchChoices<T>(string commaSeparatedValues, IReadOnlyList<T> choices, Func<T, string> choiceFormatter) where T : notnull
{
if (string.Equals(commaSeparatedValues, AllChoice, StringComparison.OrdinalIgnoreCase))
{
return choices;
}
if (string.Equals(commaSeparatedValues, NoneChoice, StringComparison.OrdinalIgnoreCase))
{
return [];
}
var values = commaSeparatedValues.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var matched = new List<T>();
foreach (var val in values)
{
var match = MatchChoice(val, choices, choiceFormatter);
if (match is null)
{
return null; // Signal that matching failed
}
// MatchChoice returns the instance from the choices list via FirstOrDefault,
// so reference equality is correct here for deduplication.
if (!matched.Contains(match))
{
matched.Add(match);
}
}
return matched;
}
[DoesNotReturn]
private void ThrowNonInteractiveError(string symbolDisplayName)
{
DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NonInteractiveOptionRequired, symbolDisplayName));
throw new NonInteractiveException(symbolDisplayName);
}
internal void ValidateResolvedStringValue(string value, bool required, Func<string, ValidationResult>? validator, string symbolDisplayName)
{
if (required && string.IsNullOrEmpty(value))
{
ThrowNonInteractiveError(symbolDisplayName);
}
if (validator is not null)
{
var result = validator(value);
if (!result.Successful)
{
DisplayError(result.Message ?? string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NonInteractiveInvalidValue, value, symbolDisplayName));
throw new NonInteractiveException(symbolDisplayName);
}
}
}
[DoesNotReturn]
internal void ThrowNonInteractiveInvalidValue<T>(string value, string symbolDisplayName, IEnumerable<T> choices, Func<T, string> choiceFormatter) where T : notnull
{
DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NonInteractiveInvalidValue, value, symbolDisplayName));
// Strip Spectre markup from each formatted choice so non-interactive callers see plain
// text. Some choice formatters intentionally include [bold]/[dim]/etc. tokens for the
// interactive multi-select renderer; those tokens would otherwise leak verbatim through
// DisplaySubtleMessage and confuse anyone diagnosing a typoed --option value.
var availableChoices = string.Join(", ", choices.Select(c => StringUtils.RemoveMarkup(choiceFormatter(c))));
DisplaySubtleMessage(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NonInteractiveAvailableValues, availableChoices));
throw new NonInteractiveException(symbolDisplayName);
}
internal T MatchChoiceOrThrow<T>(string value, PromptBinding<string?> binding, IEnumerable<T> choices, Func<T, string> choiceFormatter) where T : notnull
{
var match = MatchChoice(value, choices, choiceFormatter);
if (match is not null)
{
return match;
}
ThrowNonInteractiveInvalidValue(value, binding.SymbolDisplayName, choices, choiceFormatter);
return default; // unreachable
}
internal IReadOnlyList<T> MatchChoicesOrThrow<T>(string value, PromptBinding<string?> binding, IEnumerable<T> choices, Func<T, string> choiceFormatter) where T : notnull
{
var choicesList = choices.ToList();
var matched = MatchChoices(value, choicesList, choiceFormatter);
if (matched is not null)
{
return matched;
}
ThrowNonInteractiveInvalidValue(value, binding.SymbolDisplayName, choicesList, choiceFormatter);
return default; // unreachable
}
}