Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageVersion Include="Qdrant.Client" Version="1.16.1" />
<PackageVersion Include="RabbitMQ.Client" Version="7.2.0" />
<PackageVersion Include="Spectre.Console" Version="0.52.1-preview.0.5" />
<PackageVersion Include="Spectre.Console" Version="0.55.0" />
Comment thread
JamesNK marked this conversation as resolved.
<PackageVersion Include="StackExchange.Redis" Version="2.11.0" />
<PackageVersion Include="System.IO.Hashing" Version="10.0.3" />
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
Expand Down
1 change: 1 addition & 0 deletions eng/Signing.props
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
<FileSignInfo Include="ModelContextProtocol.dll" CertificateName="3PartySHA2" />
<FileSignInfo Include="OpenAI.dll" CertificateName="3PartySHA2" />
<FileSignInfo Include="Spectre.Console.dll" CertificateName="3PartySHA2" />
<FileSignInfo Include="Spectre.Console.Ansi.dll" CertificateName="3PartySHA2" />
<FileSignInfo Include="OpenTelemetry.dll" CertificateName="3PartySHA2" />
<FileSignInfo Include="OpenTelemetry.Api.dll" CertificateName="3PartySHA2" />
<FileSignInfo Include="OpenTelemetry.Api.ProviderBuilderExtensions.dll" CertificateName="3PartySHA2" />
Expand Down
42 changes: 42 additions & 0 deletions src/Aspire.Cli/Commands/RenderCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ internal sealed class RenderCommand : BaseCommand
["showstatus"] = "Show status spinner (first 5 emojis)",
["showstatus-markup"] = "Show status with markup rendered",
["showstatus-escaped"] = "Show status with markup escaped",
["choice"] = "Selection prompt with formatted choices",
["choice-simple"] = "Selection prompt without formatter",
["mixed"] = "Mixed interaction service methods",
["publish-summary-all"] = "Publish summary timeline (stress scenarios)",
["exit"] = "Exit",
Expand Down Expand Up @@ -145,6 +147,10 @@ private async Task<int> ExecuteChoiceAsync(string choice, int? consoleWidth, Can
return await TestShowStatusWithMarkupAsync(cancellationToken);
case "showstatus-escaped":
return await TestShowStatusEscapedAsync(cancellationToken);
case "choice":
return await TestChoiceWithFormatterAsync(cancellationToken);
case "choice-simple":
return await TestChoiceSimpleAsync(cancellationToken);
case "mixed":
await TestMixedMethodsAsync(cancellationToken);
return ExitCodeConstants.Success;
Expand Down Expand Up @@ -249,6 +255,42 @@ await InteractionService.ShowStatusAsync(
return ExitCodeConstants.Success;
}

private async Task<int> TestChoiceWithFormatterAsync(CancellationToken cancellationToken)
{
var packages = new[]
{
("Aspire.Hosting.Redis", "9.2.0", "[green]stable[/]"),
("Aspire.Hosting.PostgreSQL", "9.2.0", "[green]stable[/]"),
("Aspire.Hosting.RabbitMQ", "9.1.0", "[yellow]preview[/]"),
("Aspire.Hosting.MongoDB [Deprecated]", "9.0.0", "[red]deprecated[/]"),
("Aspire.Hosting.Kafka", "9.2.0", "[green]stable[/]"),
("Aspire.Hosting.MySql [Preview]", "9.1.0", "[yellow]preview[/]"),
};

var selected = await InteractionService.PromptForSelectionAsync(
"Select a [bold blue]package[/] to install:",
packages,
p => $"{p.Item1.EscapeMarkup()} [dim]v{p.Item2}[/] ({p.Item3})",
cancellationToken);

InteractionService.DisplayMessage(KnownEmojis.Package, $"Selected: {selected.Item1} v{selected.Item2}");
return ExitCodeConstants.Success;
}

private async Task<int> TestChoiceSimpleAsync(CancellationToken cancellationToken)
{
var environments = new[] { "Development", "Staging", "Production" };

var selected = await InteractionService.PromptForSelectionAsync(
"Select a target environment:",
environments,
e => e,
cancellationToken);

InteractionService.DisplayMessage(KnownEmojis.Rocket, $"Deploying to {selected}...");
return ExitCodeConstants.Success;
}

private async Task TestMixedMethodsAsync(CancellationToken cancellationToken)
{
InteractionService.DisplayMessage(KnownEmojis.Rocket, "Starting mixed methods test...");
Expand Down
56 changes: 4 additions & 52 deletions src/Aspire.Cli/Interaction/ConsoleInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -197,26 +197,19 @@ public async Task<T> PromptForSelectionAsync<T>(string promptText, IEnumerable<T
throw new EmptyChoicesException(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NoItemsAvailableForSelection, promptText));
}

// Wrap the caller's formatter to produce safe plain text for Spectre.Console.
// Spectre's SelectionPrompt treats converter output as markup and its search
// highlighting manipulates the markup string directly, which breaks escaped
// bracket sequences like [[Prod]]. Stripping markup after formatting ensures
// the text is safe for both rendering and search highlighting.
var safeFormatter = MakeSafeFormatter(choiceFormatter);

MessageLogger.LogInformation("Selection prompt: {PromptText}", promptText);

var prompt = new SelectionPrompt<T>()
.Title(promptText)
.UseConverter(safeFormatter)
.UseConverter(choiceFormatter)
.AddChoices(choices)
.PageSize(10)
.EnableSearch();

prompt.SearchHighlightStyle = s_searchHighlightStyle;

var result = await MessageConsole.PromptAsync(prompt, cancellationToken);
MessageLogger.LogInformation("Selection result: {Result}", safeFormatter(result));
MessageLogger.LogInformation("Selection result: {Result}", choiceFormatter(result));
return result;
}

Expand All @@ -240,13 +233,11 @@ public async Task<IReadOnlyList<T>> PromptForSelectionsAsync<T>(string promptTex

var preSelectedSet = preSelected is not null ? new HashSet<T>(preSelected) : null;

var safeFormatter = MakeSafeFormatter(choiceFormatter);

MessageLogger.LogInformation("Selection prompt: {PromptText}", promptText);

var prompt = new MultiSelectionPrompt<T>()
.Title(promptText)
.UseConverter(safeFormatter)
.UseConverter(choiceFormatter)
.PageSize(10);

prompt.Required = !optional;
Expand All @@ -261,49 +252,10 @@ public async Task<IReadOnlyList<T>> PromptForSelectionsAsync<T>(string promptTex
}

var result = await MessageConsole.PromptAsync(prompt, cancellationToken);
MessageLogger.LogInformation("Selection results: {Results}", string.Join(", ", result.Select(safeFormatter)));
MessageLogger.LogInformation("Selection results: {Results}", string.Join(", ", result.Select(choiceFormatter)));
return result;
}

/// <summary>
/// Wraps a choice formatter to produce output that is safe for Spectre.Console's
/// SelectionPrompt and MultiSelectionPrompt with search enabled. Spectre's search
/// highlighting manipulates the markup string directly, which breaks escaped bracket
/// sequences like <c>[[Prod]]</c>. This method strips all markup from the formatted
/// text and then replaces square brackets with parentheses so that Spectre never
/// encounters bracket characters in the display text.
/// </summary>
/// <remarks>
/// This is a workaround for https://github.com/spectreconsole/spectre.console/issues/2054.
/// Once the upstream fix is available, this method should be removed and callers should
/// use EscapeMarkup() directly. See https://github.com/microsoft/aspire/issues/15309.
/// </remarks>
internal static Func<T, string> MakeSafeFormatter<T>(Func<T, string> choiceFormatter)
{
return item =>
{
var formatted = choiceFormatter(item);

// Try to strip Spectre markup to get the intended display text.
// Markup.Remove() can throw if the formatted text contains unescaped
// brackets (e.g. raw "[Prod]"), so fall back to using the text as-is.
string plainText;
try
{
plainText = Markup.Remove(formatted);
}
catch (Exception)
{
plainText = formatted;
}

// Replace square brackets with parentheses. EscapeMarkup() alone is not
// sufficient because Spectre's search highlighting splits the escaped
// sequences [[...]] when inserting highlight tags, producing invalid markup.
return plainText.Replace('[', '(').Replace(']', ')');
};
}

public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion)
{
var cliInformationalVersion = VersionHelper.GetDefaultTemplateVersion();
Expand Down
13 changes: 2 additions & 11 deletions src/Aspire.Cli/Utils/CliHostEnvironment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,17 +97,8 @@ private static bool DetectAnsiSupport(IConfiguration configuration)
{
// If there is no explicit configuration to enable or disable ANSI support, attempt to detect it.
// This is required because some terminals don't support ANSI output, e.g. https://github.com/microsoft/aspire/issues/13737

// TODO: Creating a fake console here is a hack to run ANSI detection logic.
// Update this to use AnsiCapabilities once it's available in Spectre.Console 0.60+ instead of creating a full AnsiConsole instance.
var ansiConsole = AnsiConsole.Create(new AnsiConsoleSettings
{
Out = new AnsiConsoleOutput(TextWriter.Null),
Ansi = AnsiSupport.Detect,
ColorSystem = ColorSystemSupport.Detect
});

supportsAnsi = ansiConsole.Profile.Capabilities.Ansi;
var capabilities = AnsiCapabilities.Create(TextWriter.Null);
supportsAnsi = capabilities.Ansi;
}

return supportsAnsi;
Expand Down
Loading
Loading