From 331f9c2c1cd2b12a1c9d5df70734c6271a67bde9 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 12:19:26 +1100 Subject: [PATCH 01/34] Add aspire monitor TUI command (PoC) - Add MonitorCommand behind MonitorCommandEnabled feature flag - Create AspireMonitorTui with Hex1b-based TUI layout: - Drawer panel for AppHost selection - TabPanel with Resources and Parameters tabs - InfoBar with keyboard shortcuts - Create AspireMonitorSplash with braille Aspire logo - Wire into existing backchannel infrastructure for AppHost discovery and resource streaming - Update Hex1b packages to 0.75.0 - Add MonitorCommandStrings resource strings --- src/Aspire.Cli/Aspire.Cli.csproj | 13 + src/Aspire.Cli/Commands/MonitorCommand.cs | 44 +++ src/Aspire.Cli/Commands/RootCommand.cs | 6 + src/Aspire.Cli/KnownFeatures.cs | 8 +- src/Aspire.Cli/Program.cs | 1 + .../MonitorCommandStrings.Designer.cs | 114 ++++++ .../Resources/MonitorCommandStrings.resx | 153 ++++++++ .../xlf/MonitorCommandStrings.cs.xlf | 62 +++ .../xlf/MonitorCommandStrings.de.xlf | 62 +++ .../xlf/MonitorCommandStrings.es.xlf | 62 +++ .../xlf/MonitorCommandStrings.fr.xlf | 62 +++ .../xlf/MonitorCommandStrings.it.xlf | 62 +++ .../xlf/MonitorCommandStrings.ja.xlf | 62 +++ .../xlf/MonitorCommandStrings.ko.xlf | 62 +++ .../xlf/MonitorCommandStrings.pl.xlf | 62 +++ .../xlf/MonitorCommandStrings.pt-BR.xlf | 62 +++ .../xlf/MonitorCommandStrings.ru.xlf | 62 +++ .../xlf/MonitorCommandStrings.tr.xlf | 62 +++ .../xlf/MonitorCommandStrings.zh-Hans.xlf | 62 +++ .../xlf/MonitorCommandStrings.zh-Hant.xlf | 62 +++ src/Aspire.Cli/UI/AspireMonitorSplash.cs | 53 +++ src/Aspire.Cli/UI/AspireMonitorTui.cs | 359 ++++++++++++++++++ 22 files changed, 1556 insertions(+), 1 deletion(-) create mode 100644 src/Aspire.Cli/Commands/MonitorCommand.cs create mode 100644 src/Aspire.Cli/Resources/MonitorCommandStrings.Designer.cs create mode 100644 src/Aspire.Cli/Resources/MonitorCommandStrings.resx create mode 100644 src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.cs.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.de.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.es.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.fr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.it.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ja.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ko.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.pl.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.pt-BR.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ru.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.tr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.zh-Hans.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.zh-Hant.xlf create mode 100644 src/Aspire.Cli/UI/AspireMonitorSplash.cs create mode 100644 src/Aspire.Cli/UI/AspireMonitorTui.cs diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 654c480a3a3..407adcc0761 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -38,6 +38,7 @@ + @@ -163,6 +164,11 @@ True McpCommandStrings.resx + + True + True + MonitorCommandStrings.resx + @@ -258,5 +264,12 @@ ResXFileCodeGenerator McpCommandStrings.Designer.cs + + Resx + EmbeddedResource + Designer + ResXFileCodeGenerator + MonitorCommandStrings.Designer.cs + diff --git a/src/Aspire.Cli/Commands/MonitorCommand.cs b/src/Aspire.Cli/Commands/MonitorCommand.cs new file mode 100644 index 00000000000..63c4f52749d --- /dev/null +++ b/src/Aspire.Cli/Commands/MonitorCommand.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.UI; +using Aspire.Cli.Utils; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Commands; + +internal sealed class MonitorCommand : BaseCommand +{ + private readonly IAuxiliaryBackchannelMonitor _backchannelMonitor; + private readonly ILogger _logger; + + public MonitorCommand( + IInteractionService interactionService, + IAuxiliaryBackchannelMonitor backchannelMonitor, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + AspireCliTelemetry telemetry, + ILogger logger) + : base("monitor", MonitorCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) + { + _backchannelMonitor = backchannelMonitor; + _logger = logger; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + using var activity = Telemetry.StartDiagnosticActivity(Name); + + var tui = new AspireMonitorTui(_backchannelMonitor, _logger); + await tui.RunAsync(cancellationToken).ConfigureAwait(false); + + return ExitCodeConstants.Success; + } +} diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index db956046a9a..a011f98b209 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -130,6 +130,7 @@ public RootCommand( DocsCommand docsCommand, SdkCommand sdkCommand, SetupCommand setupCommand, + MonitorCommand monitorCommand, ExtensionInternalCommand extensionInternalCommand, IFeatures featureFlags, IInteractionService interactionService) @@ -220,5 +221,10 @@ public RootCommand( Subcommands.Add(sdkCommand); } + if (featureFlags.IsFeatureEnabled(KnownFeatures.MonitorCommandEnabled, false)) + { + Subcommands.Add(monitorCommand); + } + } } diff --git a/src/Aspire.Cli/KnownFeatures.cs b/src/Aspire.Cli/KnownFeatures.cs index 97e0fb1d558..d933b3384fb 100644 --- a/src/Aspire.Cli/KnownFeatures.cs +++ b/src/Aspire.Cli/KnownFeatures.cs @@ -31,6 +31,7 @@ internal static class KnownFeatures public static string ExperimentalPolyglotPython => "experimentalPolyglot:python"; public static string DotNetSdkInstallationEnabled => "dotnetSdkInstallationEnabled"; public static string RunningInstanceDetectionEnabled => "runningInstanceDetectionEnabled"; + public static string MonitorCommandEnabled => "monitorCommandEnabled"; private static readonly Dictionary s_featureMetadata = new() { @@ -112,7 +113,12 @@ internal static class KnownFeatures [RunningInstanceDetectionEnabled] = new( RunningInstanceDetectionEnabled, "Enable or disable detection of already running Aspire instances to prevent conflicts", - DefaultValue: true) + DefaultValue: true), + + [MonitorCommandEnabled] = new( + MonitorCommandEnabled, + "Enable or disable the 'aspire monitor' command for launching a TUI to monitor running AppHosts and resources", + DefaultValue: false) }; /// diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index e7e1e91cde5..51bcccc4d1a 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -374,6 +374,7 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/Resources/MonitorCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/MonitorCommandStrings.Designer.cs new file mode 100644 index 00000000000..a01681394d8 --- /dev/null +++ b/src/Aspire.Cli/Resources/MonitorCommandStrings.Designer.cs @@ -0,0 +1,114 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Aspire.Cli.Resources { + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class MonitorCommandStrings { + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal MonitorCommandStrings() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + public static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Aspire.Cli.Resources.MonitorCommandStrings", typeof(MonitorCommandStrings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + public static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + public static string Description { + get { + return ResourceManager.GetString("Description", resourceCulture); + } + } + + public static string ScanningForRunningAppHosts { + get { + return ResourceManager.GetString("ScanningForRunningAppHosts", resourceCulture); + } + } + + public static string NoRunningAppHostsFound { + get { + return ResourceManager.GetString("NoRunningAppHostsFound", resourceCulture); + } + } + + public static string ResourcesTab { + get { + return ResourceManager.GetString("ResourcesTab", resourceCulture); + } + } + + public static string ParametersTab { + get { + return ResourceManager.GetString("ParametersTab", resourceCulture); + } + } + + public static string AppHostsDrawerTitle { + get { + return ResourceManager.GetString("AppHostsDrawerTitle", resourceCulture); + } + } + + public static string QuitShortcut { + get { + return ResourceManager.GetString("QuitShortcut", resourceCulture); + } + } + + public static string TabShortcut { + get { + return ResourceManager.GetString("TabShortcut", resourceCulture); + } + } + + public static string NoResourcesAvailable { + get { + return ResourceManager.GetString("NoResourcesAvailable", resourceCulture); + } + } + + public static string NoParametersAvailable { + get { + return ResourceManager.GetString("NoParametersAvailable", resourceCulture); + } + } + + public static string ConnectingToAppHost { + get { + return ResourceManager.GetString("ConnectingToAppHost", resourceCulture); + } + } + } +} diff --git a/src/Aspire.Cli/Resources/MonitorCommandStrings.resx b/src/Aspire.Cli/Resources/MonitorCommandStrings.resx new file mode 100644 index 00000000000..9a8ee7946a8 --- /dev/null +++ b/src/Aspire.Cli/Resources/MonitorCommandStrings.resx @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Launch a TUI to monitor running Aspire apphosts and resources. + + + Scanning for running AppHosts... + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + Resources + + + Parameters + + + App Hosts + + + Quit + + + Switch Tab + + + No resources available. + + + No parameters available. + + + Connecting to AppHost... + + diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.cs.xlf new file mode 100644 index 00000000000..7e4114c7b61 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.cs.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.de.xlf new file mode 100644 index 00000000000..bdd85bd31e7 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.de.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.es.xlf new file mode 100644 index 00000000000..0bdfe7aef1e --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.es.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.fr.xlf new file mode 100644 index 00000000000..85f608ecce5 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.fr.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.it.xlf new file mode 100644 index 00000000000..a26dbc39e3b --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.it.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ja.xlf new file mode 100644 index 00000000000..889e61dd3e5 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ja.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ko.xlf new file mode 100644 index 00000000000..415470122f4 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ko.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.pl.xlf new file mode 100644 index 00000000000..0af82c39b66 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.pl.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.pt-BR.xlf new file mode 100644 index 00000000000..c4de4bcc0d5 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.pt-BR.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ru.xlf new file mode 100644 index 00000000000..65eaf7ddd4d --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ru.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.tr.xlf new file mode 100644 index 00000000000..6b8cd0eb676 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.tr.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.zh-Hans.xlf new file mode 100644 index 00000000000..3fbcf2e0a34 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.zh-Hans.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.zh-Hant.xlf new file mode 100644 index 00000000000..d251b225500 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.zh-Hant.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/UI/AspireMonitorSplash.cs b/src/Aspire.Cli/UI/AspireMonitorSplash.cs new file mode 100644 index 00000000000..24c58a30911 --- /dev/null +++ b/src/Aspire.Cli/UI/AspireMonitorSplash.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Hex1b; +using Hex1b.Widgets; + +namespace Aspire.Cli.UI; + +/// +/// Splash screen for the Aspire Monitor TUI using braille-art rendering. +/// +internal static class AspireMonitorSplash +{ + public const int SplashDurationMs = 2000; + + // Braille-art representation of ".NET Aspire" logo + private static readonly string[] s_logoLines = + [ + "⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿", + "⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿", + "⣿⣿⠋⠀⠀⠀⠀⠙⣿⣿⡿⠋⠀⠀⠀⠀⠙⣿⡿⠋⠀⠀⠀⠀⢻⣿⣿⣿⣿⠋⠀⠀⠀⠀⠀⠀⠀⣿⡏⠀⠀⠀⠀⢹⣿⠉⠀⠀⠀⠀⠉⣿⣿⡟⠀⠀⠀⣿", + "⣿⣿⠀⣶⣶⣶⣶⠀⢸⣿⡇⠀⣶⣶⣶⣶⠀⢸⡇⠀⣶⣶⣶⣦⠀⣿⣿⣿⣿⠀⣶⣶⣶⣶⣶⣶⠀⣿⡇⠀⣶⣶⣶⡀⣿⠀⣶⣶⣶⣶⠀⢸⣿⡇⠀⣿⠀⣿", + "⣿⣿⠀⣿⣿⣿⣿⠀⢸⣿⡇⠀⣿⣿⣿⣿⠀⢸⡇⠀⣿⣿⣿⣿⠀⣿⣿⣿⣿⠀⣿⣿⣿⣿⣿⣿⠀⣿⡇⠀⣿⣿⣿⣷⢹⠀⣿⣿⣿⣿⠀⢸⣿⡇⠀⣿⠀⣿", + "⣿⣿⠀⣿⣿⣿⣿⠀⢸⣿⡇⠀⣿⣿⣿⣿⠀⢸⡇⠀⣿⣿⣿⠏⢀⣿⣿⣿⣿⠀⣿⣿⣿⡏⠀⠀⠀⣿⡇⠀⣿⣿⣿⣿⢸⠀⣿⣿⠀⠀⠀⠀⣿⡇⠀⣿⠀⣿", + "⣿⣿⠀⣿⣿⣿⣿⠀⢸⣿⡇⠀⣿⣿⣿⣿⠀⢸⡇⠀⠀⠀⠀⣠⣿⣿⣿⣿⣿⠀⣿⣿⣿⣿⣿⣶⠀⣿⡇⠀⣿⣿⣿⣿⣿⠀⣿⣿⣿⣿⣶⠀⣿⡇⠀⣿⠀⣿", + "⣿⣿⠀⣿⣿⣿⣿⠀⢸⣿⡇⠀⣿⣿⣿⣿⠀⢸⡇⠀⣿⣿⣿⣿⠀⣿⣿⣿⣿⠀⣿⣿⣿⣿⣿⣿⠀⣿⡇⠀⣿⣿⣿⡿⣿⠀⣿⣿⣿⣿⣿⠀⣿⡇⠀⣿⠀⣿", + "⣿⣿⠀⣿⣿⣿⣿⠀⢸⣿⡇⠀⣿⣿⣿⣿⠀⢸⡇⠀⣿⣿⣿⣿⠀⣿⣿⣿⣿⠀⣿⣿⣿⣿⣿⣿⠀⣿⡇⠀⣿⣿⣿⠁⣿⠀⣿⣿⣿⣿⣿⠀⣿⡇⠀⣿⠀⣿", + "⣿⣿⠀⠛⠛⠛⠛⠀⢸⣿⡇⠀⠛⠛⠛⠛⠀⢸⡇⠀⠛⠛⠛⠛⠀⣿⣿⣿⣿⠀⠛⠛⠛⠛⠛⠛⠀⣿⡇⠀⠛⠛⠛⠀⣿⠀⠛⠛⠛⠛⠛⠀⣿⡇⠀⣿⠀⣿", + "⣿⣿⣄⣀⣀⣀⣀⣠⣼⣿⣧⣄⣀⣀⣀⣀⣠⣼⣧⣄⣀⣀⣀⣀⣼⣿⣿⣿⣿⣄⣀⣀⣀⣀⣀⣀⣀⣿⣧⣄⣀⣀⣀⣼⣿⣄⣀⣀⣀⣀⣀⣤⣿⣿⣄⣿⣀⣿", + "⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿", + "⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿", + ]; + + public static Hex1bWidget Build(RootContext ctx) + { + return ctx.VStack(outer => [ + outer.Text("").Fill(), + outer.Center( + ctx.Surface(layerCtx => [ + layerCtx.Layer((surface) => + { + for (var row = 0; row < s_logoLines.Length && row < surface.Height; row++) + { + var line = s_logoLines[row]; + surface.WriteText(0, row, line); + } + }) + ]).Size(56, s_logoLines.Length) + ), + outer.Text("").Fill() + ]).Fill(); + } +} diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs new file mode 100644 index 00000000000..8f5b03dcbc2 --- /dev/null +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -0,0 +1,359 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Backchannel; +using Aspire.Cli.Resources; +using Hex1b; +using Hex1b.Widgets; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.UI; + +/// +/// Represents an AppHost entry in the drawer list. +/// +internal sealed class AppHostEntry +{ + public required string DisplayName { get; init; } + public required string FullPath { get; init; } + public required IAppHostAuxiliaryBackchannel Connection { get; init; } +} + +/// +/// Main TUI for the aspire monitor command. +/// +internal sealed class AspireMonitorTui +{ + private readonly IAuxiliaryBackchannelMonitor _backchannelMonitor; + private readonly ILogger _logger; + + // Mutable state — captured by closures in the widget builder + private List _appHosts = []; + private int _selectedAppHostIndex; + private readonly Dictionary _resources = []; + private bool _isConnecting; + private string? _errorMessage; + private bool _showSplash = true; + private bool _isNavExpanded = true; + private CancellationTokenSource? _watchCts; + private Hex1bApp? _app; + + public AspireMonitorTui(IAuxiliaryBackchannelMonitor backchannelMonitor, ILogger logger) + { + _backchannelMonitor = backchannelMonitor; + _logger = logger; + } + + public async Task RunAsync(CancellationToken cancellationToken) + { + await _backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false); + + _appHosts = _backchannelMonitor.Connections + .Where(c => c.AppHostInfo is not null) + .OrderByDescending(c => c.IsInScope) + .Select(c => new AppHostEntry + { + DisplayName = ShortenPath(c.AppHostInfo!.AppHostPath ?? "Unknown"), + FullPath = c.AppHostInfo!.AppHostPath ?? "Unknown", + Connection = c + }) + .ToList(); + + await using var terminal = Hex1bTerminal.CreateBuilder() + .WithHex1bApp((app, options) => + { + _app = app; + options.EnableDefaultCtrlCExit = true; + return BuildWidget; + }) + .WithMouse() + .Build(); + + // After a brief splash, transition to the main screen and connect + _ = Task.Run(async () => + { + await Task.Delay(AspireMonitorSplash.SplashDurationMs, cancellationToken).ConfigureAwait(false); + _showSplash = false; + _app?.Invalidate(); + + if (_appHosts.Count > 0) + { + await ConnectToAppHostAsync(0, cancellationToken).ConfigureAwait(false); + } + }, cancellationToken); + + await terminal.RunAsync(cancellationToken).ConfigureAwait(false); + } + + private Hex1bWidget BuildWidget(RootContext ctx) + { + if (_showSplash) + { + return AspireMonitorSplash.Build(ctx); + } + + return BuildMainScreen(ctx); + } + + private Hex1bWidget BuildMainScreen(RootContext ctx) + { + var appHostItems = _appHosts.Select(a => a.DisplayName).ToArray(); + + // Tab panel with Resources and Parameters tabs + var tabPanel = ctx.TabPanel(tabs => [ + tabs.Tab(MonitorCommandStrings.ResourcesTab, c => BuildResourcesTab(c)), + tabs.Tab(MonitorCommandStrings.ParametersTab, c => BuildParametersTab(c)) + ]).Fill(); + + // Wrap tab panel in notification panel + var mainContent = ctx.NotificationPanel(tabPanel).Fill(); + + // Drawer content: list of AppHosts + Hex1bWidget drawerBody = appHostItems.Length > 0 + ? ctx.List(appHostItems) + .OnSelectionChanged(args => + { + if (args.SelectedIndex != _selectedAppHostIndex) + { + _selectedAppHostIndex = args.SelectedIndex; + _ = ConnectToAppHostAsync(_selectedAppHostIndex, CancellationToken.None); + } + }) + .Fill() + : ctx.Text(MonitorCommandStrings.NoRunningAppHostsFound); + + // Main layout: drawer on the left, content on the right + var body = ctx.HStack(h => [ + h.Drawer() + .Expanded(_isNavExpanded) + .ExpandedContent(e => [ + e.VStack(nav => [ + nav.HStack(header => [ + header.Text($" {MonitorCommandStrings.AppHostsDrawerTitle}"), + header.Text("").Fill(), + header.Button("«").OnClick(_ => { _isNavExpanded = false; }) + ]).FixedHeight(1), + nav.Separator(), + ..BuildAppHostList(nav) + ]) + ]) + .CollapsedContent(c => [ + c.Button("»").OnClick(_ => { _isNavExpanded = true; }) + ]), + h.Border(mainContent, title: GetSelectedAppHostTitle()).Fill() + ]).Fill(); + + return ctx.VStack(outer => [ + body, + outer.InfoBar(bar => [ + bar.Section("q: " + MonitorCommandStrings.QuitShortcut), + bar.Separator(" │ "), + bar.Section("Tab: " + MonitorCommandStrings.TabShortcut), + bar.Separator(" │ "), + bar.Section(GetStatusText()).FillWidth() + ]) + ]).Fill(); + } + + private IEnumerable BuildAppHostList(WidgetContext nav) + { + if (_appHosts.Count == 0) + { + return [nav.Text($" {MonitorCommandStrings.NoRunningAppHostsFound}")]; + } + + return _appHosts.Select((appHost, i) => + nav.Button(_selectedAppHostIndex == i + ? $" ▸ {appHost.DisplayName}" + : $" {appHost.DisplayName}") + .OnClick(e => + { + if (i != _selectedAppHostIndex) + { + _selectedAppHostIndex = i; + _ = ConnectToAppHostAsync(i, CancellationToken.None); + } + }) + ); + } + + private IEnumerable BuildResourcesTab(WidgetContext ctx) + { + if (_isConnecting) + { + return [ctx.Text($" {MonitorCommandStrings.ConnectingToAppHost}")]; + } + + if (_errorMessage is not null) + { + return [ctx.Text($" Error: {_errorMessage}")]; + } + + var resources = _resources.Values + .Where(r => !string.Equals(r.ResourceType, "Parameter", StringComparison.OrdinalIgnoreCase)) + .OrderBy(r => r.Name) + .ToList(); + + if (resources.Count == 0) + { + return [ctx.Text($" {MonitorCommandStrings.NoResourcesAvailable}")]; + } + + var resourceItems = resources + .Select(FormatResourceLine) + .ToArray(); + + return [ctx.List(resourceItems).Fill()]; + } + + private IEnumerable BuildParametersTab(WidgetContext ctx) + { + if (_isConnecting) + { + return [ctx.Text($" {MonitorCommandStrings.ConnectingToAppHost}")]; + } + + var parameters = _resources.Values + .Where(r => string.Equals(r.ResourceType, "Parameter", StringComparison.OrdinalIgnoreCase)) + .OrderBy(r => r.Name) + .ToList(); + + if (parameters.Count == 0) + { + return [ctx.Text($" {MonitorCommandStrings.NoParametersAvailable}")]; + } + + var paramItems = parameters + .Select(p => $"{p.DisplayName ?? p.Name} │ {p.State ?? "Unknown"}") + .ToArray(); + + return [ctx.List(paramItems).Fill()]; + } + + private async Task ConnectToAppHostAsync(int index, CancellationToken cancellationToken) + { + if (_watchCts is not null) + { + await _watchCts.CancelAsync().ConfigureAwait(false); + _watchCts.Dispose(); + } + _watchCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + _isConnecting = true; + _errorMessage = null; + _resources.Clear(); + _selectedAppHostIndex = index; + _app?.Invalidate(); + + try + { + if (index >= _appHosts.Count) + { + return; + } + + var connection = _appHosts[index].Connection; + + var snapshots = await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false); + _isConnecting = false; + foreach (var snapshot in snapshots) + { + _resources[snapshot.Name] = snapshot; + } + _app?.Invalidate(); + + _ = Task.Run(async () => + { + try + { + await foreach (var snapshot in connection.WatchResourceSnapshotsAsync(_watchCts.Token).ConfigureAwait(false)) + { + _resources[snapshot.Name] = snapshot; + _app?.Invalidate(); + } + } + catch (OperationCanceledException) + { + // Expected when switching AppHosts or shutting down + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error watching resource snapshots"); + _errorMessage = ex.Message; + _app?.Invalidate(); + } + }, _watchCts.Token); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error connecting to AppHost"); + _isConnecting = false; + _errorMessage = ex.Message; + _app?.Invalidate(); + } + } + + private string GetSelectedAppHostTitle() + { + if (_appHosts.Count == 0) + { + return "No AppHost"; + } + + return _selectedAppHostIndex < _appHosts.Count + ? _appHosts[_selectedAppHostIndex].DisplayName + : "Unknown"; + } + + private string GetStatusText() + { + if (_isConnecting) + { + return MonitorCommandStrings.ConnectingToAppHost; + } + + var resourceCount = _resources.Values + .Count(r => !string.Equals(r.ResourceType, "Parameter", StringComparison.OrdinalIgnoreCase)); + + return $"{resourceCount} resource(s)"; + } + + private static string FormatResourceLine(ResourceSnapshot resource) + { + var name = resource.DisplayName ?? resource.Name; + var type = resource.ResourceType ?? ""; + var state = resource.State ?? "Unknown"; + var health = resource.HealthStatus ?? ""; + var endpoints = resource.Urls.Length > 0 + ? string.Join(", ", resource.Urls.Select(u => u.Url)) + : ""; + + return string.IsNullOrEmpty(endpoints) + ? $"{name,-20} │ {type,-10} │ {state,-12} │ {health}" + : $"{name,-20} │ {type,-10} │ {state,-12} │ {health,-10} │ {endpoints}"; + } + + private static string ShortenPath(string path) + { + var fileName = Path.GetFileName(path); + + if (string.IsNullOrEmpty(fileName)) + { + return path; + } + + if (fileName.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) + { + return fileName; + } + + var directory = Path.GetDirectoryName(path); + var parentFolder = !string.IsNullOrEmpty(directory) + ? Path.GetFileName(directory) + : null; + + return !string.IsNullOrEmpty(parentFolder) + ? $"{parentFolder}/{fileName}" + : fileName; + } +} From 1f000196028979e5697228221fc099341f21ffb8 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 12:40:48 +1100 Subject: [PATCH 02/34] Suppress CS8002 strong name warning for Hex1b --- src/Aspire.Cli/Aspire.Cli.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 407adcc0761..c4fad7e9f8c 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -9,7 +9,7 @@ false aspire Aspire.Cli - $(NoWarn);CS1591 + $(NoWarn);CS1591;CS8002 true Size $(DefineConstants);CLI From 60b0bfa919686b92bb37389c900b6bef5ca3b5cf Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 12:44:34 +1100 Subject: [PATCH 03/34] Use TableWidget for Resources and Parameters tabs Replace List widget with Table widget for structured column display with headers (Name, Type, State, Health, Endpoints) and proper sizing. --- src/Aspire.Cli/UI/AspireMonitorTui.cs | 60 ++++++++++++++++----------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index 8f5b03dcbc2..8c1ae80ab86 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -4,6 +4,7 @@ using Aspire.Cli.Backchannel; using Aspire.Cli.Resources; using Hex1b; +using Hex1b.Layout; using Hex1b.Widgets; using Microsoft.Extensions.Logging; @@ -199,11 +200,27 @@ private IEnumerable BuildResourcesTab(WidgetContext c return [ctx.Text($" {MonitorCommandStrings.NoResourcesAvailable}")]; } - var resourceItems = resources - .Select(FormatResourceLine) - .ToArray(); - - return [ctx.List(resourceItems).Fill()]; + return [ + ctx.Table(resources) + .RowKey(r => r.Name) + .Header(h => [ + h.Cell("Name").Width(SizeHint.Fill), + h.Cell("Type").Fixed(12), + h.Cell("State").Fixed(12), + h.Cell("Health").Fixed(12), + h.Cell("Endpoints").Width(SizeHint.Fill) + ]) + .Row((r, resource, state) => [ + r.Cell(resource.DisplayName ?? resource.Name), + r.Cell(resource.ResourceType ?? ""), + r.Cell(resource.State ?? "Unknown"), + r.Cell(resource.HealthStatus ?? ""), + r.Cell(resource.Urls.Length > 0 + ? string.Join(", ", resource.Urls.Select(u => u.Url)) + : "") + ]) + .Fill() + ]; } private IEnumerable BuildParametersTab(WidgetContext ctx) @@ -223,11 +240,19 @@ private IEnumerable BuildParametersTab(WidgetContext return [ctx.Text($" {MonitorCommandStrings.NoParametersAvailable}")]; } - var paramItems = parameters - .Select(p => $"{p.DisplayName ?? p.Name} │ {p.State ?? "Unknown"}") - .ToArray(); - - return [ctx.List(paramItems).Fill()]; + return [ + ctx.Table(parameters) + .RowKey(r => r.Name) + .Header(h => [ + h.Cell("Name").Width(SizeHint.Fill), + h.Cell("State").Fixed(16) + ]) + .Row((r, param, state) => [ + r.Cell(param.DisplayName ?? param.Name), + r.Cell(param.State ?? "Unknown") + ]) + .Fill() + ]; } private async Task ConnectToAppHostAsync(int index, CancellationToken cancellationToken) @@ -318,21 +343,6 @@ private string GetStatusText() return $"{resourceCount} resource(s)"; } - private static string FormatResourceLine(ResourceSnapshot resource) - { - var name = resource.DisplayName ?? resource.Name; - var type = resource.ResourceType ?? ""; - var state = resource.State ?? "Unknown"; - var health = resource.HealthStatus ?? ""; - var endpoints = resource.Urls.Length > 0 - ? string.Join(", ", resource.Urls.Select(u => u.Url)) - : ""; - - return string.IsNullOrEmpty(endpoints) - ? $"{name,-20} │ {type,-10} │ {state,-12} │ {health}" - : $"{name,-20} │ {type,-10} │ {state,-12} │ {health,-10} │ {endpoints}"; - } - private static string ShortenPath(string path) { var fileName = Path.GetFileName(path); From 1b8a198f0af972562a1b4d99898c905b105c9d56 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 13:13:43 +1100 Subject: [PATCH 04/34] Add Aspire brand color theme and wrap app in ThemePanel Define AspireTheme with colors derived from the Aspire logo SVG: - #512BD4 deep purple (primary) - #7455DD / #9780E5 / #B9AAEE / #DCD5F6 purple gradient - Dark surface palette for backgrounds Theme covers Global, Table, Border, TabPanel, TabBar, InfoBar, Button, List, Separator, and NotificationCard elements. Entire TUI is wrapped in a ThemePanel for consistent styling. --- src/Aspire.Cli/UI/AspireMonitorTui.cs | 9 +-- src/Aspire.Cli/UI/AspireTheme.cs | 106 ++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 src/Aspire.Cli/UI/AspireTheme.cs diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index 8c1ae80ab86..d93916f3dce 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -88,12 +88,11 @@ public async Task RunAsync(CancellationToken cancellationToken) private Hex1bWidget BuildWidget(RootContext ctx) { - if (_showSplash) - { - return AspireMonitorSplash.Build(ctx); - } + var content = _showSplash + ? AspireMonitorSplash.Build(ctx) + : BuildMainScreen(ctx); - return BuildMainScreen(ctx); + return ctx.ThemePanel(AspireTheme.Apply, content).Fill(); } private Hex1bWidget BuildMainScreen(RootContext ctx) diff --git a/src/Aspire.Cli/UI/AspireTheme.cs b/src/Aspire.Cli/UI/AspireTheme.cs new file mode 100644 index 00000000000..cbf82deb820 --- /dev/null +++ b/src/Aspire.Cli/UI/AspireTheme.cs @@ -0,0 +1,106 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Hex1b.Theming; + +namespace Aspire.Cli.UI; + +/// +/// Defines the Aspire color theme derived from the Aspire brand palette. +/// +internal static class AspireTheme +{ + // Brand colors from the Aspire logo SVG + private static readonly Hex1bColor s_purple = Hex1bColor.FromRgb(81, 43, 212); // #512BD4 — primary + private static readonly Hex1bColor s_purpleMedium = Hex1bColor.FromRgb(116, 85, 221); // #7455DD + private static readonly Hex1bColor s_purpleLight = Hex1bColor.FromRgb(151, 128, 229); // #9780E5 + private static readonly Hex1bColor s_purpleFaint = Hex1bColor.FromRgb(185, 170, 238); // #B9AAEE + private static readonly Hex1bColor s_lavender = Hex1bColor.FromRgb(220, 213, 246); // #DCD5F6 + + // Dark surfaces + private static readonly Hex1bColor s_bgDark = Hex1bColor.FromRgb(13, 17, 23); // #0D1117 + private static readonly Hex1bColor s_bgSurface = Hex1bColor.FromRgb(22, 27, 34); // #161B22 + private static readonly Hex1bColor s_bgElevated = Hex1bColor.FromRgb(33, 38, 45); // #21262D + private static readonly Hex1bColor s_border = Hex1bColor.FromRgb(48, 54, 61); // #30363D + + // Text + private static readonly Hex1bColor s_textPrimary = Hex1bColor.FromRgb(230, 237, 243); // #E6EDF3 + private static readonly Hex1bColor s_textMuted = Hex1bColor.FromRgb(139, 148, 158); // #8B949E + + // Highlights + private static readonly Hex1bColor s_focusedRow = Hex1bColor.FromRgb(45, 31, 94); // #2D1F5E + private static readonly Hex1bColor s_selectedRow = Hex1bColor.FromRgb(30, 20, 69); // #1E1445 + private static readonly Hex1bColor s_headerBg = Hex1bColor.FromRgb(28, 20, 50); // #1C1432 + + /// + /// Applies the Aspire color theme to the given base theme. + /// + public static Hex1bTheme Apply(Hex1bTheme theme) + { + return theme + // Global + .Set(GlobalTheme.BackgroundColor, s_bgDark) + .Set(GlobalTheme.ForegroundColor, s_textPrimary) + + // Table + .Set(TableTheme.BorderColor, s_border) + .Set(TableTheme.FocusedBorderColor, s_purpleMedium) + .Set(TableTheme.TableFocusedBorderColor, s_purple) + .Set(TableTheme.HeaderBackground, s_headerBg) + .Set(TableTheme.HeaderForeground, s_purpleLight) + .Set(TableTheme.RowBackground, s_bgDark) + .Set(TableTheme.RowForeground, s_textPrimary) + .Set(TableTheme.AlternateRowBackground, s_bgSurface) + .Set(TableTheme.FocusedRowBackground, s_focusedRow) + .Set(TableTheme.FocusedRowForeground, s_lavender) + .Set(TableTheme.SelectedRowBackground, s_selectedRow) + .Set(TableTheme.SelectedRowForeground, s_purpleFaint) + .Set(TableTheme.EmptyTextForeground, s_textMuted) + .Set(TableTheme.LoadingTextForeground, s_textMuted) + .Set(TableTheme.ScrollbarThumbColor, s_purpleMedium) + .Set(TableTheme.ScrollbarTrackColor, s_border) + + // Border + .Set(BorderTheme.BorderColor, s_border) + .Set(BorderTheme.TitleColor, s_purpleLight) + + // TabPanel + .Set(TabPanelTheme.ContentBackgroundColor, s_bgDark) + .Set(TabPanelTheme.ContentForegroundColor, s_textPrimary) + + // TabBar + .Set(TabBarTheme.BackgroundColor, s_bgSurface) + .Set(TabBarTheme.ForegroundColor, s_textMuted) + .Set(TabBarTheme.SelectedBackgroundColor, s_bgDark) + .Set(TabBarTheme.SelectedForegroundColor, s_lavender) + + // InfoBar + .Set(InfoBarTheme.BackgroundColor, s_bgElevated) + .Set(InfoBarTheme.ForegroundColor, s_textMuted) + + // Button + .Set(ButtonTheme.ForegroundColor, s_textPrimary) + .Set(ButtonTheme.BackgroundColor, s_bgSurface) + .Set(ButtonTheme.FocusedForegroundColor, s_lavender) + .Set(ButtonTheme.FocusedBackgroundColor, s_purple) + .Set(ButtonTheme.HoveredForegroundColor, s_purpleFaint) + .Set(ButtonTheme.HoveredBackgroundColor, s_bgElevated) + + // List (AppHost drawer) + .Set(ListTheme.BackgroundColor, s_bgSurface) + .Set(ListTheme.ForegroundColor, s_textPrimary) + .Set(ListTheme.SelectedBackgroundColor, s_purple) + .Set(ListTheme.SelectedForegroundColor, s_lavender) + .Set(ListTheme.HoveredBackgroundColor, s_bgElevated) + .Set(ListTheme.HoveredForegroundColor, s_purpleFaint) + + // Separator + .Set(SeparatorTheme.Color, s_border) + + // Notification card + .Set(NotificationCardTheme.BackgroundColor, s_bgElevated) + .Set(NotificationCardTheme.TitleColor, s_purpleLight) + .Set(NotificationCardTheme.BodyColor, s_textPrimary) + .Set(NotificationCardTheme.ProgressBarColor, s_purple); + } +} From e89f66e2388b9757df87fc0846a9f8d1c84c8fa8 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 13:17:54 +1100 Subject: [PATCH 05/34] Set resource and parameter tables to Full render mode Adds horizontal separators between rows for better readability. --- src/Aspire.Cli/UI/AspireMonitorTui.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index d93916f3dce..1661dc4e8c7 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -219,6 +219,7 @@ private IEnumerable BuildResourcesTab(WidgetContext c : "") ]) .Fill() + .Full() ]; } @@ -251,6 +252,7 @@ private IEnumerable BuildParametersTab(WidgetContext r.Cell(param.State ?? "Unknown") ]) .Fill() + .Full() ]; } From 0e1015f46d93bd4929f616e0671d9311d2ad7659 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 13:20:57 +1100 Subject: [PATCH 06/34] Add keyboard row selection to resource and parameter tables --- src/Aspire.Cli/UI/AspireMonitorTui.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index 1661dc4e8c7..0632802d75a 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -36,6 +36,8 @@ internal sealed class AspireMonitorTui private string? _errorMessage; private bool _showSplash = true; private bool _isNavExpanded = true; + private object? _focusedResourceKey; + private object? _focusedParameterKey; private CancellationTokenSource? _watchCts; private Hex1bApp? _app; @@ -202,6 +204,8 @@ private IEnumerable BuildResourcesTab(WidgetContext c return [ ctx.Table(resources) .RowKey(r => r.Name) + .Focus(_focusedResourceKey) + .OnFocusChanged(key => { _focusedResourceKey = key; }) .Header(h => [ h.Cell("Name").Width(SizeHint.Fill), h.Cell("Type").Fixed(12), @@ -243,6 +247,8 @@ private IEnumerable BuildParametersTab(WidgetContext return [ ctx.Table(parameters) .RowKey(r => r.Name) + .Focus(_focusedParameterKey) + .OnFocusChanged(key => { _focusedParameterKey = key; }) .Header(h => [ h.Cell("Name").Width(SizeHint.Fill), h.Cell("State").Fixed(16) From 340c655d82937482c5617b88d0de40fcb38e0286 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 13:26:03 +1100 Subject: [PATCH 07/34] Add Actions column with start/stop/restart buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename Endpoints column to URLs. Add Actions column with inline ▶ (start), ■ (stop), ↻ (restart) buttons that execute resource commands via the backchannel. --- src/Aspire.Cli/UI/AspireMonitorTui.cs | 35 +++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index 0632802d75a..08d3da65db5 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -211,7 +211,8 @@ private IEnumerable BuildResourcesTab(WidgetContext c h.Cell("Type").Fixed(12), h.Cell("State").Fixed(12), h.Cell("Health").Fixed(12), - h.Cell("Endpoints").Width(SizeHint.Fill) + h.Cell("URLs").Width(SizeHint.Fill), + h.Cell("Actions").Fixed(20) ]) .Row((r, resource, state) => [ r.Cell(resource.DisplayName ?? resource.Name), @@ -220,7 +221,21 @@ private IEnumerable BuildResourcesTab(WidgetContext c r.Cell(resource.HealthStatus ?? ""), r.Cell(resource.Urls.Length > 0 ? string.Join(", ", resource.Urls.Select(u => u.Url)) - : "") + : ""), + r.Cell(cell => cell.HStack(h => [ + h.Button("▶").OnClick(e => + { + _ = ExecuteResourceCommandAsync(resource.Name, "resource-start"); + }), + h.Button("■").OnClick(e => + { + _ = ExecuteResourceCommandAsync(resource.Name, "resource-stop"); + }), + h.Button("↻").OnClick(e => + { + _ = ExecuteResourceCommandAsync(resource.Name, "resource-restart"); + }) + ])) ]) .Fill() .Full() @@ -325,6 +340,22 @@ private async Task ConnectToAppHostAsync(int index, CancellationToken cancellati } } + private async Task ExecuteResourceCommandAsync(string resourceName, string commandName) + { + try + { + if (_selectedAppHostIndex < _appHosts.Count) + { + var connection = _appHosts[_selectedAppHostIndex].Connection; + await connection.ExecuteResourceCommandAsync(resourceName, commandName, CancellationToken.None).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error executing {Command} on {Resource}", commandName, resourceName); + } + } + private string GetSelectedAppHostTitle() { if (_appHosts.Count == 0) From 0ff03225ce55d10cc39136b01d117d2055cf70b4 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 13:52:11 +1100 Subject: [PATCH 08/34] Replace drawer with resizable HSplitter for AppHost list The left pane is now a fixed-width splitter panel (30 cols) instead of a collapsible drawer, allowing mouse-drag resizing. --- src/Aspire.Cli/UI/AspireMonitorTui.cs | 46 +++++++-------------------- 1 file changed, 12 insertions(+), 34 deletions(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index 08d3da65db5..563381160da 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -35,7 +35,6 @@ internal sealed class AspireMonitorTui private bool _isConnecting; private string? _errorMessage; private bool _showSplash = true; - private bool _isNavExpanded = true; private object? _focusedResourceKey; private object? _focusedParameterKey; private CancellationTokenSource? _watchCts; @@ -110,41 +109,20 @@ private Hex1bWidget BuildMainScreen(RootContext ctx) // Wrap tab panel in notification panel var mainContent = ctx.NotificationPanel(tabPanel).Fill(); - // Drawer content: list of AppHosts - Hex1bWidget drawerBody = appHostItems.Length > 0 - ? ctx.List(appHostItems) - .OnSelectionChanged(args => - { - if (args.SelectedIndex != _selectedAppHostIndex) - { - _selectedAppHostIndex = args.SelectedIndex; - _ = ConnectToAppHostAsync(_selectedAppHostIndex, CancellationToken.None); - } - }) - .Fill() - : ctx.Text(MonitorCommandStrings.NoRunningAppHostsFound); - - // Main layout: drawer on the left, content on the right - var body = ctx.HStack(h => [ - h.Drawer() - .Expanded(_isNavExpanded) - .ExpandedContent(e => [ - e.VStack(nav => [ - nav.HStack(header => [ - header.Text($" {MonitorCommandStrings.AppHostsDrawerTitle}"), - header.Text("").Fill(), - header.Button("«").OnClick(_ => { _isNavExpanded = false; }) - ]).FixedHeight(1), - nav.Separator(), - ..BuildAppHostList(nav) - ]) - ]) - .CollapsedContent(c => [ - c.Button("»").OnClick(_ => { _isNavExpanded = true; }) - ]), - h.Border(mainContent, title: GetSelectedAppHostTitle()).Fill() + // AppHost list for the left pane + var appHostList = ctx.VStack(nav => [ + nav.Text($" {MonitorCommandStrings.AppHostsDrawerTitle}").FixedHeight(1), + nav.Separator(), + ..BuildAppHostList(nav) ]).Fill(); + // Main layout: splitter with AppHost list on the left, content on the right + var body = ctx.HSplitter( + ctx.Border(appHostList, title: "App Hosts"), + ctx.Border(mainContent, title: GetSelectedAppHostTitle()).Fill(), + leftWidth: 30 + ).Fill(); + return ctx.VStack(outer => [ body, outer.InfoBar(bar => [ From 888a40c5abad98de4d5e44e3ca33b433dc464555 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 14:17:51 +1100 Subject: [PATCH 09/34] Add animated pixel splash screen with Aspire logo --- src/Aspire.Cli/UI/AspireMonitorSplash.cs | 347 +++++++++++++++++++++-- src/Aspire.Cli/UI/AspireMonitorTui.cs | 23 +- 2 files changed, 335 insertions(+), 35 deletions(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorSplash.cs b/src/Aspire.Cli/UI/AspireMonitorSplash.cs index 24c58a30911..d5467b53839 100644 --- a/src/Aspire.Cli/UI/AspireMonitorSplash.cs +++ b/src/Aspire.Cli/UI/AspireMonitorSplash.cs @@ -2,52 +2,341 @@ // The .NET Foundation licenses this file to you under the MIT license. using Hex1b; +using Hex1b.Theming; using Hex1b.Widgets; namespace Aspire.Cli.UI; /// -/// Splash screen for the Aspire Monitor TUI using braille-art rendering. +/// Animated splash screen that renders the Aspire logo using half-block characters. +/// Pixels fly in from random off-screen positions to their final locations. /// -internal static class AspireMonitorSplash +internal sealed class AspireMonitorSplash { public const int SplashDurationMs = 2000; - // Braille-art representation of ".NET Aspire" logo - private static readonly string[] s_logoLines = - [ - "⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿", - "⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿", - "⣿⣿⠋⠀⠀⠀⠀⠙⣿⣿⡿⠋⠀⠀⠀⠀⠙⣿⡿⠋⠀⠀⠀⠀⢻⣿⣿⣿⣿⠋⠀⠀⠀⠀⠀⠀⠀⣿⡏⠀⠀⠀⠀⢹⣿⠉⠀⠀⠀⠀⠉⣿⣿⡟⠀⠀⠀⣿", - "⣿⣿⠀⣶⣶⣶⣶⠀⢸⣿⡇⠀⣶⣶⣶⣶⠀⢸⡇⠀⣶⣶⣶⣦⠀⣿⣿⣿⣿⠀⣶⣶⣶⣶⣶⣶⠀⣿⡇⠀⣶⣶⣶⡀⣿⠀⣶⣶⣶⣶⠀⢸⣿⡇⠀⣿⠀⣿", - "⣿⣿⠀⣿⣿⣿⣿⠀⢸⣿⡇⠀⣿⣿⣿⣿⠀⢸⡇⠀⣿⣿⣿⣿⠀⣿⣿⣿⣿⠀⣿⣿⣿⣿⣿⣿⠀⣿⡇⠀⣿⣿⣿⣷⢹⠀⣿⣿⣿⣿⠀⢸⣿⡇⠀⣿⠀⣿", - "⣿⣿⠀⣿⣿⣿⣿⠀⢸⣿⡇⠀⣿⣿⣿⣿⠀⢸⡇⠀⣿⣿⣿⠏⢀⣿⣿⣿⣿⠀⣿⣿⣿⡏⠀⠀⠀⣿⡇⠀⣿⣿⣿⣿⢸⠀⣿⣿⠀⠀⠀⠀⣿⡇⠀⣿⠀⣿", - "⣿⣿⠀⣿⣿⣿⣿⠀⢸⣿⡇⠀⣿⣿⣿⣿⠀⢸⡇⠀⠀⠀⠀⣠⣿⣿⣿⣿⣿⠀⣿⣿⣿⣿⣿⣶⠀⣿⡇⠀⣿⣿⣿⣿⣿⠀⣿⣿⣿⣿⣶⠀⣿⡇⠀⣿⠀⣿", - "⣿⣿⠀⣿⣿⣿⣿⠀⢸⣿⡇⠀⣿⣿⣿⣿⠀⢸⡇⠀⣿⣿⣿⣿⠀⣿⣿⣿⣿⠀⣿⣿⣿⣿⣿⣿⠀⣿⡇⠀⣿⣿⣿⡿⣿⠀⣿⣿⣿⣿⣿⠀⣿⡇⠀⣿⠀⣿", - "⣿⣿⠀⣿⣿⣿⣿⠀⢸⣿⡇⠀⣿⣿⣿⣿⠀⢸⡇⠀⣿⣿⣿⣿⠀⣿⣿⣿⣿⠀⣿⣿⣿⣿⣿⣿⠀⣿⡇⠀⣿⣿⣿⠁⣿⠀⣿⣿⣿⣿⣿⠀⣿⡇⠀⣿⠀⣿", - "⣿⣿⠀⠛⠛⠛⠛⠀⢸⣿⡇⠀⠛⠛⠛⠛⠀⢸⡇⠀⠛⠛⠛⠛⠀⣿⣿⣿⣿⠀⠛⠛⠛⠛⠛⠛⠀⣿⡇⠀⠛⠛⠛⠀⣿⠀⠛⠛⠛⠛⠛⠀⣿⡇⠀⣿⠀⣿", - "⣿⣿⣄⣀⣀⣀⣀⣠⣼⣿⣧⣄⣀⣀⣀⣀⣠⣼⣧⣄⣀⣀⣀⣀⣼⣿⣿⣿⣿⣄⣀⣀⣀⣀⣀⣀⣀⣿⣧⣄⣀⣀⣀⣼⣿⣄⣀⣀⣀⣀⣀⣤⣿⣿⣄⣿⣀⣿", - "⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿", - "⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿", - ]; + // Logo dimensions in logical pixels (each pixel is half a character cell vertically) + private const int LogoWidth = 40; + private const int LogoHeight = 40; + private const int LogoCellHeight = LogoHeight / 2; + public const int AnimationDurationMs = 1200; + + private readonly record struct PixelState( + int FinalX, int FinalY, byte R, byte G, byte B, + float StartOffsetX, float StartOffsetY); + + private readonly PixelState[] _pixels; + private readonly long _startTicks; + + public AspireMonitorSplash() + { + _startTicks = Environment.TickCount64; + var rng = new Random(42); // Fixed seed for deterministic animation + + var pixelCount = s_pixelData.Length / 5; + _pixels = new PixelState[pixelCount]; + + for (var i = 0; i < pixelCount; i++) + { + var offset = i * 5; + var x = s_pixelData[offset]; + var y = s_pixelData[offset + 1]; + var r = s_pixelData[offset + 2]; + var g = s_pixelData[offset + 3]; + var b = s_pixelData[offset + 4]; + + // Random start direction — fly in from outside the logo bounds + var angle = rng.NextDouble() * Math.PI * 2; + var distance = 30 + rng.NextDouble() * 40; + var startOffsetX = (float)(Math.Cos(angle) * distance); + var startOffsetY = (float)(Math.Sin(angle) * distance); + + _pixels[i] = new PixelState(x, y, r, g, b, startOffsetX, startOffsetY); + } + } + + /// + /// Gets the animation progress from 0.0 to 1.0. + /// + public float Progress + { + get + { + var elapsed = Environment.TickCount64 - _startTicks; + return Math.Clamp(elapsed / (float)AnimationDurationMs, 0f, 1f); + } + } + + public bool IsComplete => Progress >= 1f; - public static Hex1bWidget Build(RootContext ctx) + public Hex1bWidget Build(RootContext ctx) { - return ctx.VStack(outer => [ + var progress = Progress; + + // Ease-out cubic for smooth deceleration + var t = 1f - (1f - progress) * (1f - progress) * (1f - progress); + + return ctx.ThemePanel(AspireTheme.Apply, ctx.VStack(outer => [ outer.Text("").Fill(), outer.Center( ctx.Surface(layerCtx => [ - layerCtx.Layer((surface) => + layerCtx.Layer(surface => { - for (var row = 0; row < s_logoLines.Length && row < surface.Height; row++) - { - var line = s_logoLines[row]; - surface.WriteText(0, row, line); - } + RenderFrame(surface, t); }) - ]).Size(56, s_logoLines.Length) + ]).Size(LogoWidth, LogoCellHeight) ), outer.Text("").Fill() - ]).Fill(); + ]).Fill()).Fill(); } + + private void RenderFrame(Hex1b.Surfaces.Surface surface, float t) + { + // Build a pixel grid: for each cell (cx, cy), we need the top and bottom logical pixel + // Top pixel = logical row cy*2, bottom pixel = logical row cy*2+1 + // Use ▀ with fg=top color, bg=bottom color + + // Collect animated pixel positions into a sparse grid + var grid = new (byte R, byte G, byte B, bool HasPixel)[LogoWidth, LogoHeight]; + + foreach (var pixel in _pixels) + { + // Lerp from start to final position + var currentX = pixel.FinalX + pixel.StartOffsetX * (1f - t); + var currentY = pixel.FinalY + pixel.StartOffsetY * (1f - t); + + var ix = (int)Math.Round(currentX); + var iy = (int)Math.Round(currentY); + + if (ix >= 0 && ix < LogoWidth && iy >= 0 && iy < LogoHeight) + { + grid[ix, iy] = (pixel.R, pixel.G, pixel.B, true); + } + } + + // Render half-block pairs + for (var cy = 0; cy < LogoCellHeight && cy < surface.Height; cy++) + { + for (var cx = 0; cx < LogoWidth && cx < surface.Width; cx++) + { + var topRow = cy * 2; + var botRow = cy * 2 + 1; + + var top = grid[cx, topRow]; + var bot = botRow < LogoHeight ? grid[cx, botRow] : default; + + if (top.HasPixel && bot.HasPixel) + { + // Both pixels visible: ▀ with fg=top, bg=bottom + var fg = Hex1bColor.FromRgb(top.R, top.G, top.B); + var bg = Hex1bColor.FromRgb(bot.R, bot.G, bot.B); + surface.WriteChar(cx, cy, '\u2580', fg, bg); + } + else if (top.HasPixel) + { + // Only top pixel: ▀ with fg=top color + var fg = Hex1bColor.FromRgb(top.R, top.G, top.B); + surface.WriteChar(cx, cy, '\u2580', fg); + } + else if (bot.HasPixel) + { + // Only bottom pixel: ▄ with fg=bottom color + var fg = Hex1bColor.FromRgb(bot.R, bot.G, bot.B); + surface.WriteChar(cx, cy, '\u2584', fg); + } + } + } + } + + private static readonly byte[] s_pixelData = + [ + 0x13, 0x03, 0xE1, 0xDB, 0xF8, 0x14, 0x03, 0xE1, 0xDB, 0xF8, 0x11, 0x04, 0xD2, 0xC7, 0xF4, 0x12, 0x04, 0x6C, 0x4D, 0xDB, + 0x13, 0x04, 0x4F, 0x2A, 0xD4, 0x14, 0x04, 0x4F, 0x29, 0xD4, 0x15, 0x04, 0x6C, 0x4C, 0xDB, 0x16, 0x04, 0xD1, 0xC6, 0xF4, + 0x10, 0x05, 0xDD, 0xD5, 0xF7, 0x11, 0x05, 0x4F, 0x27, 0xD4, 0x12, 0x05, 0x47, 0x1F, 0xD2, 0x13, 0x05, 0x52, 0x2C, 0xD4, + 0x14, 0x05, 0x52, 0x2C, 0xD4, 0x15, 0x05, 0x47, 0x20, 0xD2, 0x16, 0x05, 0x4F, 0x27, 0xD4, 0x17, 0x05, 0xDD, 0xD5, 0xF7, + 0x10, 0x06, 0x7A, 0x5D, 0xDE, 0x11, 0x06, 0x46, 0x1E, 0xD1, 0x12, 0x06, 0x5B, 0x37, 0xD7, 0x13, 0x06, 0x6F, 0x4F, 0xDC, + 0x14, 0x06, 0x6F, 0x4F, 0xDB, 0x15, 0x06, 0x5B, 0x37, 0xD7, 0x16, 0x06, 0x46, 0x1E, 0xD1, 0x17, 0x06, 0x7A, 0x5C, 0xDE, + 0x0F, 0x07, 0xC2, 0xB4, 0xF0, 0x10, 0x07, 0x46, 0x1D, 0xD1, 0x11, 0x07, 0x53, 0x2D, 0xD4, 0x12, 0x07, 0x6D, 0x4C, 0xDB, + 0x13, 0x07, 0x77, 0x58, 0xDD, 0x14, 0x07, 0x77, 0x58, 0xDD, 0x15, 0x07, 0x6D, 0x4C, 0xDB, 0x16, 0x07, 0x53, 0x2D, 0xD4, + 0x17, 0x07, 0x46, 0x1D, 0xD1, 0x18, 0x07, 0xC2, 0xB4, 0xF0, 0x0F, 0x08, 0x66, 0x43, 0xD9, 0x10, 0x08, 0x4A, 0x22, 0xD2, + 0x11, 0x08, 0x5F, 0x3D, 0xD8, 0x12, 0x08, 0x76, 0x58, 0xDE, 0x13, 0x08, 0x73, 0x54, 0xDD, 0x14, 0x08, 0x73, 0x54, 0xDD, + 0x15, 0x08, 0x76, 0x58, 0xDE, 0x16, 0x08, 0x60, 0x3C, 0xD8, 0x17, 0x08, 0x4A, 0x22, 0xD2, 0x18, 0x08, 0x66, 0x43, 0xD9, + 0x0E, 0x09, 0xA7, 0x94, 0xE9, 0x0F, 0x09, 0x43, 0x1B, 0xD1, 0x10, 0x09, 0x56, 0x31, 0xD5, 0x11, 0x09, 0x71, 0x51, 0xDC, + 0x12, 0x09, 0x75, 0x56, 0xDD, 0x13, 0x09, 0x74, 0x55, 0xDD, 0x14, 0x09, 0x74, 0x55, 0xDD, 0x15, 0x09, 0x75, 0x56, 0xDD, + 0x16, 0x09, 0x70, 0x51, 0xDC, 0x17, 0x09, 0x55, 0x31, 0xD5, 0x18, 0x09, 0x43, 0x1A, 0xD1, 0x19, 0x09, 0xA7, 0x93, 0xEA, + 0x0D, 0x0A, 0xE9, 0xE4, 0xFA, 0x0E, 0x0A, 0x56, 0x30, 0xD5, 0x0F, 0x0A, 0x4D, 0x26, 0xD3, 0x10, 0x0A, 0x64, 0x42, 0xD9, + 0x11, 0x0A, 0x77, 0x58, 0xDE, 0x12, 0x0A, 0x73, 0x54, 0xDD, 0x13, 0x0A, 0x74, 0x55, 0xDD, 0x14, 0x0A, 0x74, 0x55, 0xDD, + 0x15, 0x0A, 0x73, 0x54, 0xDD, 0x16, 0x0A, 0x77, 0x58, 0xDE, 0x17, 0x0A, 0x64, 0x42, 0xD9, 0x18, 0x0A, 0x4D, 0x27, 0xD3, + 0x19, 0x0A, 0x56, 0x2F, 0xD5, 0x1A, 0x0A, 0xE9, 0xE3, 0xFA, 0x0D, 0x0B, 0x8D, 0x73, 0xE3, 0x0E, 0x0B, 0x44, 0x1C, 0xD1, + 0x0F, 0x0B, 0x59, 0x35, 0xD6, 0x10, 0x0B, 0x74, 0x55, 0xDD, 0x11, 0x0B, 0x74, 0x55, 0xDD, 0x12, 0x0B, 0x74, 0x55, 0xDD, + 0x13, 0x0B, 0x74, 0x55, 0xDD, 0x14, 0x0B, 0x74, 0x55, 0xDD, 0x15, 0x0B, 0x74, 0x55, 0xDD, 0x16, 0x0B, 0x74, 0x55, 0xDD, + 0x17, 0x0B, 0x74, 0x54, 0xDD, 0x18, 0x0B, 0x59, 0x34, 0xD6, 0x19, 0x0B, 0x44, 0x1C, 0xD1, 0x1A, 0x0B, 0x8D, 0x72, 0xE3, + 0x0C, 0x0C, 0xD5, 0xCB, 0xF4, 0x0D, 0x0C, 0x4B, 0x23, 0xD3, 0x0E, 0x0C, 0x51, 0x2A, 0xD4, 0x0F, 0x0C, 0x69, 0x47, 0xDA, + 0x10, 0x0C, 0x77, 0x58, 0xDE, 0x11, 0x0C, 0x73, 0x54, 0xDD, 0x12, 0x0C, 0x74, 0x55, 0xDD, 0x13, 0x0C, 0x74, 0x55, 0xDD, + 0x14, 0x0C, 0x74, 0x55, 0xDD, 0x15, 0x0C, 0x74, 0x55, 0xDD, 0x16, 0x0C, 0x73, 0x54, 0xDD, 0x17, 0x0C, 0x76, 0x58, 0xDE, + 0x18, 0x0C, 0x69, 0x47, 0xDA, 0x19, 0x0C, 0x50, 0x2A, 0xD4, 0x1A, 0x0C, 0x4B, 0x23, 0xD2, 0x1B, 0x0C, 0xD5, 0xCA, 0xF4, + 0x0C, 0x0D, 0x75, 0x56, 0xDD, 0x0D, 0x0D, 0x47, 0x1E, 0xD1, 0x0E, 0x0D, 0x5B, 0x38, 0xD7, 0x0F, 0x0D, 0x78, 0x59, 0xDE, + 0x10, 0x0D, 0x72, 0x53, 0xDD, 0x11, 0x0D, 0x74, 0x55, 0xDD, 0x12, 0x0D, 0x74, 0x55, 0xDD, 0x13, 0x0D, 0x74, 0x55, 0xDD, + 0x14, 0x0D, 0x74, 0x55, 0xDD, 0x15, 0x0D, 0x74, 0x55, 0xDD, 0x16, 0x0D, 0x74, 0x55, 0xDD, 0x17, 0x0D, 0x74, 0x54, 0xDD, + 0x18, 0x0D, 0x76, 0x57, 0xDD, 0x19, 0x0D, 0x5C, 0x39, 0xD7, 0x1A, 0x0D, 0x47, 0x1F, 0xD2, 0x1B, 0x0D, 0x74, 0x55, 0xDD, + 0x0B, 0x0E, 0xBC, 0xAC, 0xEE, 0x0C, 0x0E, 0x45, 0x1C, 0xD1, 0x0D, 0x0E, 0x53, 0x2E, 0xD5, 0x0E, 0x0E, 0x84, 0x69, 0xE0, + 0x0F, 0x0E, 0x98, 0x81, 0xE5, 0x10, 0x0E, 0x85, 0x6A, 0xE1, 0x11, 0x0E, 0x72, 0x53, 0xDD, 0x12, 0x0E, 0x74, 0x56, 0xDD, + 0x13, 0x0E, 0x74, 0x55, 0xDD, 0x14, 0x0E, 0x74, 0x55, 0xDD, 0x15, 0x0E, 0x74, 0x55, 0xDD, 0x16, 0x0E, 0x74, 0x55, 0xDD, + 0x17, 0x0E, 0x74, 0x55, 0xDD, 0x18, 0x0E, 0x76, 0x57, 0xDD, 0x19, 0x0E, 0x6E, 0x4D, 0xDB, 0x1A, 0x0E, 0x53, 0x2E, 0xD5, + 0x1B, 0x0E, 0x45, 0x1C, 0xD1, 0x1C, 0x0E, 0xBC, 0xAC, 0xEE, 0x0B, 0x0F, 0x61, 0x3E, 0xD8, 0x0C, 0x0F, 0x48, 0x20, 0xD2, + 0x0D, 0x0F, 0x70, 0x50, 0xDB, 0x0E, 0x0F, 0x9D, 0x87, 0xE6, 0x0F, 0x0F, 0x97, 0x80, 0xE5, 0x10, 0x0F, 0x99, 0x83, 0xE5, + 0x11, 0x0F, 0x7C, 0x5F, 0xDF, 0x12, 0x0F, 0x72, 0x53, 0xDD, 0x13, 0x0F, 0x74, 0x56, 0xDD, 0x14, 0x0F, 0x74, 0x55, 0xDD, + 0x15, 0x0F, 0x74, 0x55, 0xDD, 0x16, 0x0F, 0x74, 0x55, 0xDD, 0x17, 0x0F, 0x74, 0x55, 0xDD, 0x18, 0x0F, 0x73, 0x54, 0xDD, + 0x19, 0x0F, 0x77, 0x58, 0xDE, 0x1A, 0x0F, 0x61, 0x3E, 0xD8, 0x1B, 0x0F, 0x4B, 0x23, 0xD2, 0x1C, 0x0F, 0x60, 0x3D, 0xD8, + 0x1D, 0x0F, 0xF4, 0xF0, 0xFC, 0x0A, 0x10, 0xA1, 0x8B, 0xE7, 0x0B, 0x10, 0x43, 0x1A, 0xD1, 0x0C, 0x10, 0x59, 0x35, 0xD6, + 0x0D, 0x10, 0x92, 0x7A, 0xE4, 0x0E, 0x10, 0x98, 0x82, 0xE5, 0x0F, 0x10, 0x96, 0x7F, 0xE5, 0x10, 0x10, 0x99, 0x82, 0xE5, + 0x11, 0x10, 0x8F, 0x77, 0xE3, 0x12, 0x10, 0x73, 0x54, 0xDD, 0x13, 0x10, 0x74, 0x55, 0xDD, 0x14, 0x10, 0x74, 0x55, 0xDD, + 0x15, 0x10, 0x74, 0x55, 0xDD, 0x16, 0x10, 0x74, 0x55, 0xDD, 0x17, 0x10, 0x74, 0x55, 0xDD, 0x18, 0x10, 0x74, 0x55, 0xDD, + 0x19, 0x10, 0x75, 0x56, 0xDD, 0x1A, 0x10, 0x71, 0x52, 0xDC, 0x1B, 0x10, 0x56, 0x32, 0xD5, 0x1C, 0x10, 0x43, 0x1A, 0xD1, + 0x1D, 0x10, 0xA1, 0x8B, 0xE8, 0x09, 0x11, 0xE4, 0xDD, 0xF8, 0x0A, 0x11, 0x52, 0x2C, 0xD4, 0x0B, 0x11, 0x4C, 0x25, 0xD3, + 0x0C, 0x11, 0x7A, 0x5D, 0xDE, 0x0D, 0x11, 0x9C, 0x86, 0xE6, 0x0E, 0x11, 0x96, 0x7F, 0xE5, 0x0F, 0x11, 0x97, 0x80, 0xE5, + 0x10, 0x11, 0x96, 0x7F, 0xE5, 0x11, 0x11, 0x99, 0x83, 0xE6, 0x12, 0x11, 0x81, 0x65, 0xE0, 0x13, 0x11, 0x72, 0x52, 0xDC, + 0x14, 0x11, 0x75, 0x56, 0xDD, 0x15, 0x11, 0x74, 0x55, 0xDD, 0x16, 0x11, 0x74, 0x55, 0xDD, 0x17, 0x11, 0x74, 0x55, 0xDD, + 0x18, 0x11, 0x74, 0x55, 0xDD, 0x19, 0x11, 0x73, 0x54, 0xDD, 0x1A, 0x11, 0x77, 0x58, 0xDE, 0x1B, 0x11, 0x65, 0x44, 0xD9, + 0x1C, 0x11, 0x4E, 0x28, 0xD3, 0x1D, 0x11, 0x52, 0x2B, 0xD4, 0x1E, 0x11, 0xE4, 0xDD, 0xF8, 0x09, 0x12, 0x86, 0x6B, 0xE1, + 0x0A, 0x12, 0x43, 0x1A, 0xD1, 0x0B, 0x12, 0x60, 0x3D, 0xD8, 0x0C, 0x12, 0x98, 0x81, 0xE5, 0x0D, 0x12, 0x97, 0x80, 0xE5, + 0x0E, 0x12, 0x97, 0x80, 0xE5, 0x0F, 0x12, 0x97, 0x80, 0xE5, 0x10, 0x12, 0x97, 0x80, 0xE5, 0x11, 0x12, 0x98, 0x81, 0xE5, + 0x12, 0x12, 0x94, 0x7C, 0xE4, 0x13, 0x12, 0x76, 0x57, 0xDD, 0x14, 0x12, 0x74, 0x54, 0xDD, 0x15, 0x12, 0x74, 0x55, 0xDD, + 0x16, 0x12, 0x74, 0x55, 0xDD, 0x17, 0x12, 0x74, 0x55, 0xDD, 0x18, 0x12, 0x74, 0x55, 0xDD, 0x19, 0x12, 0x74, 0x55, 0xDD, + 0x1A, 0x12, 0x74, 0x55, 0xDD, 0x1B, 0x12, 0x74, 0x55, 0xDD, 0x1C, 0x12, 0x5A, 0x36, 0xD6, 0x1D, 0x12, 0x45, 0x1C, 0xD1, + 0x1E, 0x12, 0x86, 0x6B, 0xE1, 0x08, 0x13, 0xCE, 0xC3, 0xF3, 0x09, 0x13, 0x49, 0x21, 0xD2, 0x0A, 0x13, 0x50, 0x29, 0xD4, + 0x0B, 0x13, 0x84, 0x68, 0xE0, 0x0C, 0x13, 0x9B, 0x85, 0xE6, 0x0D, 0x13, 0x96, 0x7F, 0xE5, 0x0E, 0x13, 0x97, 0x80, 0xE5, + 0x0F, 0x13, 0x97, 0x80, 0xE5, 0x10, 0x13, 0x97, 0x80, 0xE5, 0x11, 0x13, 0x96, 0x7F, 0xE5, 0x12, 0x13, 0x9A, 0x83, 0xE6, + 0x13, 0x13, 0x87, 0x6C, 0xE1, 0x14, 0x13, 0x72, 0x52, 0xDC, 0x15, 0x13, 0x75, 0x56, 0xDD, 0x16, 0x13, 0x74, 0x55, 0xDD, + 0x17, 0x13, 0x74, 0x55, 0xDD, 0x18, 0x13, 0x74, 0x55, 0xDD, 0x19, 0x13, 0x74, 0x55, 0xDD, 0x1A, 0x13, 0x74, 0x54, 0xDD, + 0x1B, 0x13, 0x76, 0x58, 0xDE, 0x1C, 0x13, 0x6A, 0x49, 0xDB, 0x1D, 0x13, 0x51, 0x2B, 0xD4, 0x1E, 0x13, 0x48, 0x20, 0xD2, + 0x1F, 0x13, 0xCE, 0xC2, 0xF3, 0x08, 0x14, 0x6F, 0x4E, 0xDB, 0x09, 0x14, 0x46, 0x1E, 0xD1, 0x0A, 0x14, 0x69, 0x48, 0xDA, + 0x0B, 0x14, 0x9C, 0x85, 0xE6, 0x0C, 0x14, 0x96, 0x7F, 0xE5, 0x0D, 0x14, 0x97, 0x80, 0xE5, 0x0E, 0x14, 0x97, 0x80, 0xE5, + 0x0F, 0x14, 0x97, 0x80, 0xE5, 0x10, 0x14, 0x97, 0x80, 0xE5, 0x11, 0x14, 0x97, 0x80, 0xE5, 0x12, 0x14, 0x97, 0x80, 0xE5, + 0x13, 0x14, 0x97, 0x80, 0xE5, 0x14, 0x14, 0x79, 0x5B, 0xDE, 0x15, 0x14, 0x73, 0x54, 0xDD, 0x16, 0x14, 0x74, 0x56, 0xDD, + 0x17, 0x14, 0x74, 0x55, 0xDD, 0x18, 0x14, 0x74, 0x55, 0xDD, 0x19, 0x14, 0x74, 0x55, 0xDD, 0x1A, 0x14, 0x74, 0x55, 0xDD, + 0x1B, 0x14, 0x74, 0x54, 0xDD, 0x1C, 0x14, 0x76, 0x58, 0xDE, 0x1D, 0x14, 0x5E, 0x3B, 0xD7, 0x1E, 0x14, 0x48, 0x20, 0xD2, + 0x1F, 0x14, 0x6F, 0x4E, 0xDC, 0x07, 0x15, 0xB4, 0xA3, 0xED, 0x08, 0x15, 0x45, 0x1C, 0xD1, 0x09, 0x15, 0x52, 0x2C, 0xD4, + 0x0A, 0x15, 0x8A, 0x6F, 0xE1, 0x0B, 0x15, 0x97, 0x80, 0xE5, 0x0C, 0x15, 0x95, 0x7E, 0xE5, 0x0D, 0x15, 0x96, 0x7F, 0xE5, + 0x0E, 0x15, 0x96, 0x7F, 0xE5, 0x0F, 0x15, 0x96, 0x7F, 0xE5, 0x10, 0x15, 0x96, 0x7F, 0xE5, 0x11, 0x15, 0x96, 0x7F, 0xE5, + 0x12, 0x15, 0x95, 0x7E, 0xE5, 0x13, 0x15, 0x98, 0x82, 0xE5, 0x14, 0x15, 0x8B, 0x71, 0xE2, 0x15, 0x15, 0x71, 0x51, 0xDC, + 0x16, 0x15, 0x73, 0x54, 0xDD, 0x17, 0x15, 0x73, 0x54, 0xDD, 0x18, 0x15, 0x73, 0x54, 0xDD, 0x19, 0x15, 0x73, 0x54, 0xDD, + 0x1A, 0x15, 0x73, 0x54, 0xDD, 0x1B, 0x15, 0x73, 0x53, 0xDD, 0x1C, 0x15, 0x74, 0x55, 0xDD, 0x1D, 0x15, 0x6E, 0x4D, 0xDB, + 0x1E, 0x15, 0x53, 0x2E, 0xD5, 0x1F, 0x15, 0x44, 0x1B, 0xD1, 0x20, 0x15, 0xB4, 0xA3, 0xED, 0x06, 0x16, 0xF0, 0xED, 0xFB, + 0x07, 0x16, 0x5E, 0x3A, 0xD7, 0x08, 0x16, 0x46, 0x1D, 0xD1, 0x09, 0x16, 0x86, 0x6B, 0xE1, 0x0A, 0x16, 0xD3, 0xCA, 0xF4, + 0x0B, 0x16, 0xC2, 0xB5, 0xF0, 0x0C, 0x16, 0xB0, 0xA0, 0xEC, 0x0D, 0x16, 0xB2, 0xA1, 0xEC, 0x0E, 0x16, 0xB2, 0xA1, 0xEC, + 0x0F, 0x16, 0xB2, 0xA1, 0xEC, 0x10, 0x16, 0xB2, 0xA1, 0xEC, 0x11, 0x16, 0xB2, 0xA1, 0xEC, 0x12, 0x16, 0xB2, 0xA1, 0xEC, + 0x13, 0x16, 0xB1, 0xA0, 0xEC, 0x14, 0x16, 0xB3, 0xA3, 0xED, 0x15, 0x16, 0x99, 0x82, 0xE6, 0x16, 0x16, 0x8E, 0x74, 0xE3, + 0x17, 0x16, 0x90, 0x77, 0xE3, 0x18, 0x16, 0x8F, 0x77, 0xE3, 0x19, 0x16, 0x8F, 0x77, 0xE3, 0x1A, 0x16, 0x8F, 0x77, 0xE3, + 0x1B, 0x16, 0x8F, 0x77, 0xE3, 0x1C, 0x16, 0x8E, 0x76, 0xE3, 0x1D, 0x16, 0x94, 0x7C, 0xE4, 0x1E, 0x16, 0x70, 0x51, 0xDC, + 0x1F, 0x16, 0x49, 0x22, 0xD2, 0x20, 0x16, 0x5D, 0x38, 0xD7, 0x21, 0x16, 0xF0, 0xED, 0xFB, 0x06, 0x17, 0x9A, 0x83, 0xE6, + 0x07, 0x17, 0x41, 0x18, 0xD0, 0x08, 0x17, 0x60, 0x3D, 0xD8, 0x09, 0x17, 0xD6, 0xCD, 0xF5, 0x0A, 0x17, 0xE2, 0xDC, 0xF7, + 0x0B, 0x17, 0xE0, 0xDB, 0xF7, 0x0C, 0x17, 0xC8, 0xBC, 0xF2, 0x0D, 0x17, 0xB8, 0xA9, 0xEE, 0x0E, 0x17, 0xBB, 0xAD, 0xEE, + 0x0F, 0x17, 0xBB, 0xAC, 0xEE, 0x10, 0x17, 0xBB, 0xAC, 0xEE, 0x11, 0x17, 0xBB, 0xAC, 0xEE, 0x12, 0x17, 0xBB, 0xAC, 0xEE, + 0x13, 0x17, 0xBA, 0xAC, 0xEE, 0x14, 0x17, 0xBC, 0xAE, 0xEF, 0x15, 0x17, 0xB4, 0xA5, 0xED, 0x16, 0x17, 0x98, 0x81, 0xE5, + 0x17, 0x17, 0x99, 0x82, 0xE5, 0x18, 0x17, 0x99, 0x82, 0xE5, 0x19, 0x17, 0x99, 0x82, 0xE5, 0x1A, 0x17, 0x99, 0x82, 0xE5, + 0x1B, 0x17, 0x99, 0x82, 0xE5, 0x1C, 0x17, 0x98, 0x82, 0xE5, 0x1D, 0x17, 0x9A, 0x83, 0xE6, 0x1E, 0x17, 0x94, 0x7D, 0xE4, + 0x1F, 0x17, 0x5B, 0x36, 0xD6, 0x20, 0x17, 0x43, 0x1A, 0xD1, 0x21, 0x17, 0x99, 0x82, 0xE6, 0x05, 0x18, 0xDF, 0xD7, 0xF7, + 0x06, 0x18, 0x51, 0x2A, 0xD4, 0x07, 0x18, 0x48, 0x20, 0xD2, 0x08, 0x18, 0xA7, 0x93, 0xE9, 0x09, 0x18, 0xE6, 0xE1, 0xF8, + 0x0A, 0x18, 0xD9, 0xD1, 0xF5, 0x0B, 0x18, 0xDC, 0xD5, 0xF6, 0x0C, 0x18, 0xD9, 0xD2, 0xF5, 0x0D, 0x18, 0xBB, 0xAC, 0xEE, + 0x0E, 0x18, 0xB8, 0xA9, 0xEE, 0x0F, 0x18, 0xB9, 0xAA, 0xEE, 0x10, 0x18, 0xB9, 0xAA, 0xEE, 0x11, 0x18, 0xB9, 0xAA, 0xEE, + 0x12, 0x18, 0xB9, 0xAA, 0xEE, 0x13, 0x18, 0xB9, 0xAA, 0xEE, 0x14, 0x18, 0xB8, 0xA9, 0xEE, 0x15, 0x18, 0xBB, 0xAD, 0xEF, + 0x16, 0x18, 0xA5, 0x91, 0xE9, 0x17, 0x18, 0x94, 0x7D, 0xE4, 0x18, 0x18, 0x97, 0x80, 0xE5, 0x19, 0x18, 0x97, 0x80, 0xE5, + 0x1A, 0x18, 0x97, 0x80, 0xE5, 0x1B, 0x18, 0x97, 0x80, 0xE5, 0x1C, 0x18, 0x97, 0x80, 0xE5, 0x1D, 0x18, 0x95, 0x7E, 0xE5, + 0x1E, 0x18, 0x9C, 0x86, 0xE6, 0x1F, 0x18, 0x7D, 0x60, 0xDF, 0x20, 0x18, 0x4D, 0x26, 0xD3, 0x21, 0x18, 0x50, 0x28, 0xD4, + 0x22, 0x18, 0xDF, 0xD7, 0xF7, 0x05, 0x19, 0x80, 0x63, 0xE0, 0x06, 0x19, 0x41, 0x17, 0xD0, 0x07, 0x19, 0x70, 0x50, 0xDC, + 0x08, 0x19, 0xDF, 0xD9, 0xF7, 0x09, 0x19, 0xDB, 0xD4, 0xF6, 0x0A, 0x19, 0xDC, 0xD5, 0xF6, 0x0B, 0x19, 0xDB, 0xD4, 0xF6, + 0x0C, 0x19, 0xDE, 0xD8, 0xF7, 0x0D, 0x19, 0xCE, 0xC4, 0xF3, 0x0E, 0x19, 0xB7, 0xA7, 0xED, 0x0F, 0x19, 0xB9, 0xAB, 0xEE, + 0x10, 0x19, 0xB9, 0xAA, 0xEE, 0x11, 0x19, 0xB9, 0xAA, 0xEE, 0x12, 0x19, 0xB9, 0xAA, 0xEE, 0x13, 0x19, 0xB9, 0xAA, 0xEE, + 0x14, 0x19, 0xB9, 0xAA, 0xEE, 0x15, 0x19, 0xBA, 0xAB, 0xEE, 0x16, 0x19, 0xB7, 0xA7, 0xED, 0x17, 0x19, 0x99, 0x83, 0xE6, + 0x18, 0x19, 0x96, 0x7F, 0xE5, 0x19, 0x19, 0x97, 0x80, 0xE5, 0x1A, 0x19, 0x97, 0x80, 0xE5, 0x1B, 0x19, 0x97, 0x80, 0xE5, + 0x1C, 0x19, 0x97, 0x80, 0xE5, 0x1D, 0x19, 0x97, 0x80, 0xE5, 0x1E, 0x19, 0x97, 0x80, 0xE5, 0x1F, 0x19, 0x99, 0x82, 0xE5, + 0x20, 0x19, 0x62, 0x3F, 0xD8, 0x21, 0x19, 0x44, 0x1B, 0xD1, 0x22, 0x19, 0x80, 0x63, 0xE0, 0x04, 0x1A, 0xC8, 0xBA, 0xF1, + 0x05, 0x1A, 0x48, 0x1F, 0xD2, 0x06, 0x1A, 0x4E, 0x27, 0xD3, 0x07, 0x1A, 0xBB, 0xAB, 0xEE, 0x08, 0x1A, 0xE3, 0xDE, 0xF8, + 0x09, 0x1A, 0xDA, 0xD3, 0xF6, 0x0A, 0x1A, 0xDC, 0xD5, 0xF6, 0x0B, 0x1A, 0xDC, 0xD5, 0xF6, 0x0C, 0x1A, 0xDC, 0xD5, 0xF6, + 0x0D, 0x1A, 0xDD, 0xD6, 0xF6, 0x0E, 0x1A, 0xC1, 0xB4, 0xF0, 0x0F, 0x1A, 0xB7, 0xA8, 0xEE, 0x10, 0x1A, 0xB9, 0xAB, 0xEE, + 0x11, 0x1A, 0xB9, 0xAA, 0xEE, 0x12, 0x1A, 0xB9, 0xAA, 0xEE, 0x13, 0x1A, 0xB9, 0xAA, 0xEE, 0x14, 0x1A, 0xB9, 0xAA, 0xEE, + 0x15, 0x1A, 0xB8, 0xA9, 0xEE, 0x16, 0x1A, 0xBB, 0xAD, 0xEF, 0x17, 0x1A, 0xAA, 0x98, 0xEA, 0x18, 0x1A, 0x95, 0x7D, 0xE4, + 0x19, 0x1A, 0x98, 0x81, 0xE5, 0x1A, 0x1A, 0x97, 0x80, 0xE5, 0x1B, 0x1A, 0x97, 0x80, 0xE5, 0x1C, 0x1A, 0x97, 0x80, 0xE5, + 0x1D, 0x1A, 0x97, 0x80, 0xE5, 0x1E, 0x1A, 0x96, 0x7F, 0xE5, 0x1F, 0x1A, 0x9B, 0x84, 0xE6, 0x20, 0x1A, 0x86, 0x6B, 0xE1, + 0x21, 0x1A, 0x51, 0x2B, 0xD4, 0x22, 0x1A, 0x47, 0x1F, 0xD2, 0x23, 0x1A, 0xC8, 0xBB, 0xF1, 0x04, 0x1B, 0x6B, 0x49, 0xDA, + 0x05, 0x1B, 0x42, 0x19, 0xD0, 0x06, 0x1B, 0x83, 0x66, 0xE0, 0x07, 0x1B, 0xE5, 0xE0, 0xF8, 0x08, 0x1B, 0xDA, 0xD2, 0xF5, + 0x09, 0x1B, 0xDC, 0xD5, 0xF6, 0x0A, 0x1B, 0xDC, 0xD5, 0xF6, 0x0B, 0x1B, 0xDC, 0xD5, 0xF6, 0x0C, 0x1B, 0xDC, 0xD5, 0xF6, + 0x0D, 0x1B, 0xDE, 0xD7, 0xF6, 0x0E, 0x1B, 0xD5, 0xCD, 0xF4, 0x0F, 0x1B, 0xB9, 0xA9, 0xEE, 0x10, 0x1B, 0xB9, 0xAA, 0xEE, + 0x11, 0x1B, 0xB9, 0xAA, 0xEE, 0x12, 0x1B, 0xB9, 0xAA, 0xEE, 0x13, 0x1B, 0xB9, 0xAA, 0xEE, 0x14, 0x1B, 0xB9, 0xAA, 0xEE, + 0x15, 0x1B, 0xB9, 0xAA, 0xEE, 0x16, 0x1B, 0xB9, 0xAA, 0xEE, 0x17, 0x1B, 0xB9, 0xAA, 0xEE, 0x18, 0x1B, 0x9D, 0x87, 0xE7, + 0x19, 0x1B, 0x96, 0x7E, 0xE5, 0x1A, 0x1B, 0x97, 0x80, 0xE5, 0x1B, 0x1B, 0x97, 0x80, 0xE5, 0x1C, 0x1B, 0x97, 0x80, 0xE5, + 0x1D, 0x1B, 0x97, 0x80, 0xE5, 0x1E, 0x1B, 0x97, 0x80, 0xE5, 0x1F, 0x1B, 0x96, 0x7F, 0xE5, 0x20, 0x1B, 0x9B, 0x85, 0xE6, + 0x21, 0x1B, 0x6B, 0x4B, 0xDA, 0x22, 0x1B, 0x46, 0x1E, 0xD1, 0x23, 0x1B, 0x69, 0x49, 0xDA, 0x03, 0x1C, 0xAE, 0x9B, 0xEB, + 0x04, 0x1C, 0x43, 0x1A, 0xD1, 0x05, 0x1C, 0x58, 0x32, 0xD6, 0x06, 0x1C, 0xCB, 0xC0, 0xF2, 0x07, 0x1C, 0xE0, 0xDA, 0xF7, + 0x08, 0x1C, 0xDB, 0xD4, 0xF6, 0x09, 0x1C, 0xDC, 0xD5, 0xF6, 0x0A, 0x1C, 0xDC, 0xD5, 0xF6, 0x0B, 0x1C, 0xDC, 0xD5, 0xF6, + 0x0C, 0x1C, 0xDC, 0xD5, 0xF6, 0x0D, 0x1C, 0xDB, 0xD4, 0xF6, 0x0E, 0x1C, 0xDF, 0xD8, 0xF7, 0x0F, 0x1C, 0xC9, 0xBD, 0xF2, + 0x10, 0x1C, 0xB6, 0xA7, 0xED, 0x11, 0x1C, 0xBA, 0xAB, 0xEE, 0x12, 0x1C, 0xB9, 0xAA, 0xEE, 0x13, 0x1C, 0xB9, 0xAA, 0xEE, + 0x14, 0x1C, 0xB9, 0xAA, 0xEE, 0x15, 0x1C, 0xB9, 0xAA, 0xEE, 0x16, 0x1C, 0xB9, 0xA9, 0xEE, 0x17, 0x1C, 0xBB, 0xAD, 0xEE, + 0x18, 0x1C, 0xB0, 0x9E, 0xEB, 0x19, 0x1C, 0x96, 0x7E, 0xE4, 0x1A, 0x1C, 0x97, 0x81, 0xE5, 0x1B, 0x1C, 0x97, 0x80, 0xE5, + 0x1C, 0x1C, 0x97, 0x80, 0xE5, 0x1D, 0x1C, 0x97, 0x80, 0xE5, 0x1E, 0x1C, 0x97, 0x80, 0xE5, 0x1F, 0x1C, 0x97, 0x7F, 0xE5, + 0x20, 0x1C, 0x99, 0x82, 0xE5, 0x21, 0x1C, 0x8F, 0x76, 0xE3, 0x22, 0x1C, 0x56, 0x31, 0xD5, 0x23, 0x1C, 0x44, 0x1A, 0xD1, + 0x24, 0x1C, 0xAD, 0x9A, 0xEB, 0x02, 0x1D, 0xEA, 0xE5, 0xFA, 0x03, 0x1D, 0x5A, 0x35, 0xD6, 0x04, 0x1D, 0x44, 0x1B, 0xD1, + 0x05, 0x1D, 0x97, 0x7F, 0xE5, 0x06, 0x1D, 0xE6, 0xE2, 0xF8, 0x07, 0x1D, 0xD9, 0xD2, 0xF5, 0x08, 0x1D, 0xDC, 0xD5, 0xF6, + 0x09, 0x1D, 0xDC, 0xD5, 0xF6, 0x0A, 0x1D, 0xDC, 0xD5, 0xF6, 0x0B, 0x1D, 0xDC, 0xD5, 0xF6, 0x0C, 0x1D, 0xDC, 0xD5, 0xF6, + 0x0D, 0x1D, 0xDC, 0xD5, 0xF6, 0x0E, 0x1D, 0xDC, 0xD5, 0xF6, 0x0F, 0x1D, 0xDB, 0xD3, 0xF6, 0x10, 0x1D, 0xBD, 0xAF, 0xEF, + 0x11, 0x1D, 0xB8, 0xA9, 0xEE, 0x12, 0x1D, 0xB9, 0xAA, 0xEE, 0x13, 0x1D, 0xB9, 0xAA, 0xEE, 0x14, 0x1D, 0xB9, 0xAA, 0xEE, + 0x15, 0x1D, 0xB9, 0xAA, 0xEE, 0x16, 0x1D, 0xB9, 0xAA, 0xEE, 0x17, 0x1D, 0xB9, 0xA9, 0xEE, 0x18, 0x1D, 0xBB, 0xAC, 0xEF, + 0x19, 0x1D, 0xA2, 0x8D, 0xE8, 0x1A, 0x1D, 0x95, 0x7D, 0xE4, 0x1B, 0x1D, 0x97, 0x81, 0xE5, 0x1C, 0x1D, 0x97, 0x80, 0xE5, + 0x1D, 0x1D, 0x97, 0x80, 0xE5, 0x1E, 0x1D, 0x97, 0x80, 0xE5, 0x1F, 0x1D, 0x97, 0x80, 0xE5, 0x20, 0x1D, 0x96, 0x7F, 0xE5, + 0x21, 0x1D, 0x9C, 0x86, 0xE6, 0x22, 0x1D, 0x75, 0x56, 0xDD, 0x23, 0x1D, 0x4A, 0x22, 0xD2, 0x24, 0x1D, 0x59, 0x34, 0xD6, + 0x25, 0x1D, 0xEA, 0xE6, 0xFA, 0x02, 0x1E, 0xA8, 0x94, 0xEA, 0x03, 0x1E, 0x3F, 0x16, 0xD0, 0x04, 0x1E, 0x67, 0x45, 0xD9, + 0x05, 0x1E, 0xD7, 0xCF, 0xF5, 0x06, 0x1E, 0xDD, 0xD7, 0xF6, 0x07, 0x1E, 0xDB, 0xD4, 0xF6, 0x08, 0x1E, 0xDC, 0xD5, 0xF6, + 0x09, 0x1E, 0xDC, 0xD5, 0xF6, 0x0A, 0x1E, 0xDC, 0xD5, 0xF6, 0x0B, 0x1E, 0xDC, 0xD5, 0xF6, 0x0C, 0x1E, 0xDC, 0xD5, 0xF6, + 0x0D, 0x1E, 0xDC, 0xD5, 0xF6, 0x0E, 0x1E, 0xDB, 0xD4, 0xF6, 0x0F, 0x1E, 0xDE, 0xD8, 0xF6, 0x10, 0x1E, 0xD0, 0xC7, 0xF3, + 0x11, 0x1E, 0xB7, 0xA8, 0xEE, 0x12, 0x1E, 0xB9, 0xAB, 0xEE, 0x13, 0x1E, 0xB9, 0xAA, 0xEE, 0x14, 0x1E, 0xB9, 0xAA, 0xEE, + 0x15, 0x1E, 0xB9, 0xAA, 0xEE, 0x16, 0x1E, 0xB9, 0xAA, 0xEE, 0x17, 0x1E, 0xB9, 0xAA, 0xEE, 0x18, 0x1E, 0xBA, 0xAB, 0xEE, + 0x19, 0x1E, 0xB4, 0xA3, 0xED, 0x1A, 0x1E, 0x98, 0x81, 0xE5, 0x1B, 0x1E, 0x97, 0x80, 0xE5, 0x1C, 0x1E, 0x97, 0x80, 0xE5, + 0x1D, 0x1E, 0x97, 0x80, 0xE5, 0x1E, 0x1E, 0x97, 0x80, 0xE5, 0x1F, 0x1E, 0x97, 0x80, 0xE5, 0x20, 0x1E, 0x97, 0x80, 0xE5, + 0x21, 0x1E, 0x98, 0x81, 0xE5, 0x22, 0x1E, 0x95, 0x7D, 0xE4, 0x23, 0x1E, 0x5E, 0x3A, 0xD7, 0x24, 0x1E, 0x41, 0x18, 0xD0, + 0x25, 0x1E, 0xA8, 0x93, 0xE9, 0x02, 0x1F, 0x8F, 0x77, 0xE3, 0x03, 0x1F, 0x3C, 0x11, 0xCF, 0x04, 0x1F, 0x92, 0x78, 0xE4, + 0x05, 0x1F, 0xE9, 0xE6, 0xF9, 0x06, 0x1F, 0xD8, 0xCF, 0xF5, 0x07, 0x1F, 0xDB, 0xD4, 0xF6, 0x08, 0x1F, 0xDB, 0xD4, 0xF6, + 0x09, 0x1F, 0xDB, 0xD4, 0xF6, 0x0A, 0x1F, 0xDB, 0xD4, 0xF6, 0x0B, 0x1F, 0xDB, 0xD4, 0xF6, 0x0C, 0x1F, 0xDB, 0xD4, 0xF6, + 0x0D, 0x1F, 0xDB, 0xD4, 0xF6, 0x0E, 0x1F, 0xDB, 0xD4, 0xF6, 0x0F, 0x1F, 0xDB, 0xD4, 0xF6, 0x10, 0x1F, 0xDB, 0xD4, 0xF6, + 0x11, 0x1F, 0xBB, 0xAD, 0xEF, 0x12, 0x1F, 0xB7, 0xA8, 0xEE, 0x13, 0x1F, 0xB8, 0xA9, 0xEE, 0x14, 0x1F, 0xB8, 0xA9, 0xEE, + 0x15, 0x1F, 0xB8, 0xA9, 0xEE, 0x16, 0x1F, 0xB8, 0xA9, 0xEE, 0x17, 0x1F, 0xB8, 0xA9, 0xEE, 0x18, 0x1F, 0xB8, 0xA9, 0xEE, + 0x19, 0x1F, 0xBB, 0xAD, 0xEE, 0x1A, 0x1F, 0x9F, 0x8A, 0xE7, 0x1B, 0x1F, 0x94, 0x7D, 0xE5, 0x1C, 0x1F, 0x96, 0x80, 0xE5, + 0x1D, 0x1F, 0x96, 0x7F, 0xE5, 0x1E, 0x1F, 0x96, 0x7F, 0xE5, 0x1F, 0x1F, 0x96, 0x7F, 0xE5, 0x20, 0x1F, 0x96, 0x7F, 0xE5, + 0x21, 0x1F, 0x95, 0x7E, 0xE5, 0x22, 0x1F, 0x9E, 0x88, 0xE6, 0x23, 0x1F, 0x73, 0x54, 0xDC, 0x24, 0x1F, 0x42, 0x18, 0xD0, + 0x25, 0x1F, 0x8E, 0x76, 0xE3, 0x02, 0x20, 0xA0, 0x89, 0xE8, 0x03, 0x20, 0x3F, 0x15, 0xD0, 0x04, 0x20, 0x6E, 0x4F, 0xDB, + 0x05, 0x20, 0xD6, 0xCE, 0xF5, 0x06, 0x20, 0xE3, 0xDE, 0xF8, 0x07, 0x20, 0xE1, 0xDB, 0xF7, 0x08, 0x20, 0xE2, 0xDC, 0xF7, + 0x09, 0x20, 0xE2, 0xDC, 0xF7, 0x0A, 0x20, 0xE2, 0xDC, 0xF7, 0x0B, 0x20, 0xE2, 0xDC, 0xF7, 0x0C, 0x20, 0xE2, 0xDC, 0xF7, + 0x0D, 0x20, 0xE2, 0xDC, 0xF7, 0x0E, 0x20, 0xE1, 0xDB, 0xF7, 0x0F, 0x20, 0xE3, 0xDD, 0xF8, 0x10, 0x20, 0xD5, 0xCD, 0xF5, + 0x11, 0x20, 0xBD, 0xAE, 0xEF, 0x12, 0x20, 0xBD, 0xAF, 0xEF, 0x13, 0x20, 0xBD, 0xAF, 0xEF, 0x14, 0x20, 0xBD, 0xAF, 0xEF, + 0x15, 0x20, 0xBD, 0xAF, 0xEF, 0x16, 0x20, 0xBD, 0xAF, 0xEF, 0x17, 0x20, 0xBD, 0xAF, 0xEF, 0x18, 0x20, 0xBE, 0xB0, 0xEF, + 0x19, 0x20, 0xB7, 0xA7, 0xED, 0x1A, 0x20, 0x9C, 0x86, 0xE6, 0x1B, 0x20, 0x99, 0x83, 0xE6, 0x1C, 0x20, 0x9A, 0x83, 0xE6, + 0x1D, 0x20, 0x9A, 0x83, 0xE6, 0x1E, 0x20, 0x9A, 0x83, 0xE6, 0x1F, 0x20, 0x9A, 0x83, 0xE6, 0x20, 0x20, 0x9A, 0x83, 0xE6, + 0x21, 0x20, 0x9A, 0x84, 0xE6, 0x22, 0x20, 0x94, 0x7C, 0xE4, 0x23, 0x20, 0x62, 0x40, 0xD8, 0x24, 0x20, 0x42, 0x18, 0xD0, + 0x25, 0x20, 0xA0, 0x89, 0xE8, 0x02, 0x21, 0xE1, 0xD9, 0xF8, 0x03, 0x21, 0x50, 0x29, 0xD4, 0x04, 0x21, 0x47, 0x20, 0xD1, + 0x05, 0x21, 0x63, 0x41, 0xD8, 0x06, 0x21, 0x77, 0x5A, 0xDE, 0x07, 0x21, 0x75, 0x57, 0xDD, 0x08, 0x21, 0x75, 0x57, 0xDD, + 0x09, 0x21, 0x75, 0x57, 0xDD, 0x0A, 0x21, 0x75, 0x57, 0xDD, 0x0B, 0x21, 0x75, 0x57, 0xDD, 0x0C, 0x21, 0x75, 0x57, 0xDD, + 0x0D, 0x21, 0x75, 0x57, 0xDD, 0x0E, 0x21, 0x75, 0x58, 0xDD, 0x0F, 0x21, 0x75, 0x57, 0xDD, 0x10, 0x21, 0x6E, 0x4C, 0xDB, + 0x11, 0x21, 0x6D, 0x4B, 0xDB, 0x12, 0x21, 0x6E, 0x4B, 0xDB, 0x13, 0x21, 0x6D, 0x4B, 0xDB, 0x14, 0x21, 0x6D, 0x4B, 0xDB, + 0x15, 0x21, 0x6D, 0x4B, 0xDB, 0x16, 0x21, 0x6D, 0x4B, 0xDB, 0x17, 0x21, 0x6D, 0x4B, 0xDB, 0x18, 0x21, 0x6E, 0x4C, 0xDB, + 0x19, 0x21, 0x67, 0x45, 0xDA, 0x1A, 0x21, 0x64, 0x42, 0xD9, 0x1B, 0x21, 0x66, 0x43, 0xD9, 0x1C, 0x21, 0x65, 0x43, 0xD9, + 0x1D, 0x21, 0x65, 0x43, 0xD9, 0x1E, 0x21, 0x65, 0x43, 0xD9, 0x1F, 0x21, 0x65, 0x43, 0xD9, 0x20, 0x21, 0x65, 0x43, 0xD9, + 0x21, 0x21, 0x66, 0x44, 0xD9, 0x22, 0x21, 0x5C, 0x39, 0xD7, 0x23, 0x21, 0x4A, 0x23, 0xD2, 0x24, 0x21, 0x4F, 0x28, 0xD4, + 0x25, 0x21, 0xE0, 0xD9, 0xF8, 0x03, 0x22, 0xC3, 0xB6, 0xF0, 0x04, 0x22, 0x57, 0x31, 0xD5, 0x05, 0x22, 0x42, 0x18, 0xD0, + 0x06, 0x22, 0x3D, 0x12, 0xCF, 0x07, 0x22, 0x3D, 0x12, 0xCF, 0x08, 0x22, 0x3D, 0x12, 0xCF, 0x09, 0x22, 0x3D, 0x12, 0xCF, + 0x0A, 0x22, 0x3D, 0x12, 0xCF, 0x0B, 0x22, 0x3D, 0x12, 0xCF, 0x0C, 0x22, 0x3D, 0x12, 0xCF, 0x0D, 0x22, 0x3D, 0x12, 0xCF, + 0x0E, 0x22, 0x3D, 0x12, 0xCF, 0x0F, 0x22, 0x3D, 0x12, 0xCF, 0x10, 0x22, 0x3F, 0x14, 0xCF, 0x11, 0x22, 0x3F, 0x15, 0xD0, + 0x12, 0x22, 0x3F, 0x15, 0xD0, 0x13, 0x22, 0x3F, 0x15, 0xD0, 0x14, 0x22, 0x3F, 0x15, 0xD0, 0x15, 0x22, 0x3F, 0x15, 0xD0, + 0x16, 0x22, 0x3F, 0x15, 0xD0, 0x17, 0x22, 0x3F, 0x15, 0xD0, 0x18, 0x22, 0x3F, 0x14, 0xD0, 0x19, 0x22, 0x40, 0x16, 0xD0, + 0x1A, 0x22, 0x41, 0x17, 0xD0, 0x1B, 0x22, 0x41, 0x17, 0xD0, 0x1C, 0x22, 0x41, 0x17, 0xD0, 0x1D, 0x22, 0x41, 0x17, 0xD0, + 0x1E, 0x22, 0x41, 0x17, 0xD0, 0x1F, 0x22, 0x41, 0x17, 0xD0, 0x20, 0x22, 0x41, 0x17, 0xD0, 0x21, 0x22, 0x41, 0x17, 0xD0, + 0x22, 0x22, 0x44, 0x1A, 0xD1, 0x23, 0x22, 0x57, 0x31, 0xD5, 0x24, 0x22, 0xC3, 0xB6, 0xF0, 0x04, 0x23, 0xED, 0xE8, 0xFB, + 0x05, 0x23, 0xBC, 0xAD, 0xEE, 0x06, 0x23, 0xB2, 0xA1, 0xEC, 0x07, 0x23, 0xB4, 0xA2, 0xEC, 0x08, 0x23, 0xB3, 0xA2, 0xEC, + 0x09, 0x23, 0xB3, 0xA2, 0xEC, 0x0A, 0x23, 0xB3, 0xA2, 0xEC, 0x0B, 0x23, 0xB3, 0xA2, 0xEC, 0x0C, 0x23, 0xB3, 0xA2, 0xEC, + 0x0D, 0x23, 0xB3, 0xA2, 0xEC, 0x0E, 0x23, 0xB3, 0xA2, 0xEC, 0x0F, 0x23, 0xB3, 0xA2, 0xEC, 0x10, 0x23, 0xB3, 0xA1, 0xEC, + 0x11, 0x23, 0xB3, 0xA1, 0xEC, 0x12, 0x23, 0xB3, 0xA1, 0xEC, 0x13, 0x23, 0xB3, 0xA1, 0xEC, 0x14, 0x23, 0xB3, 0xA1, 0xEC, + 0x15, 0x23, 0xB3, 0xA1, 0xEC, 0x16, 0x23, 0xB3, 0xA1, 0xEC, 0x17, 0x23, 0xB3, 0xA1, 0xEC, 0x18, 0x23, 0xB3, 0xA1, 0xEC, + 0x19, 0x23, 0xB3, 0xA1, 0xEC, 0x1A, 0x23, 0xB3, 0xA1, 0xEC, 0x1B, 0x23, 0xB3, 0xA1, 0xEC, 0x1C, 0x23, 0xB3, 0xA1, 0xEC, + 0x1D, 0x23, 0xB3, 0xA1, 0xEC, 0x1E, 0x23, 0xB3, 0xA1, 0xEC, 0x1F, 0x23, 0xB3, 0xA1, 0xEC, 0x20, 0x23, 0xB3, 0xA1, 0xEC, + 0x21, 0x23, 0xB2, 0xA0, 0xEC, 0x22, 0x23, 0xBC, 0xAC, 0xEE, 0x23, 0x23, 0xED, 0xE9, 0xFB + ]; } diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index 563381160da..d022192e67f 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -35,6 +35,7 @@ internal sealed class AspireMonitorTui private bool _isConnecting; private string? _errorMessage; private bool _showSplash = true; + private readonly AspireMonitorSplash _splash = new(); private object? _focusedResourceKey; private object? _focusedParameterKey; private CancellationTokenSource? _watchCts; @@ -71,10 +72,19 @@ public async Task RunAsync(CancellationToken cancellationToken) .WithMouse() .Build(); - // After a brief splash, transition to the main screen and connect + // Animate the splash, then transition to main screen _ = Task.Run(async () => { - await Task.Delay(AspireMonitorSplash.SplashDurationMs, cancellationToken).ConfigureAwait(false); + // Drive the animation by invalidating at ~30fps + while (!_splash.IsComplete) + { + _app?.Invalidate(); + await Task.Delay(33, cancellationToken).ConfigureAwait(false); + } + _app?.Invalidate(); + + // Hold the completed logo briefly + await Task.Delay(AspireMonitorSplash.SplashDurationMs - AspireMonitorSplash.AnimationDurationMs, cancellationToken).ConfigureAwait(false); _showSplash = false; _app?.Invalidate(); @@ -89,11 +99,12 @@ public async Task RunAsync(CancellationToken cancellationToken) private Hex1bWidget BuildWidget(RootContext ctx) { - var content = _showSplash - ? AspireMonitorSplash.Build(ctx) - : BuildMainScreen(ctx); + if (_showSplash) + { + return _splash.Build(ctx); + } - return ctx.ThemePanel(AspireTheme.Apply, content).Fill(); + return ctx.ThemePanel(AspireTheme.Apply, BuildMainScreen(ctx)).Fill(); } private Hex1bWidget BuildMainScreen(RootContext ctx) From 8ca9886f3302cdbea1d0c4f587624539cca4d371 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 14:19:03 +1100 Subject: [PATCH 10/34] Make splash surface fill the entire terminal --- src/Aspire.Cli/UI/AspireMonitorSplash.cs | 55 +++++++++++------------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorSplash.cs b/src/Aspire.Cli/UI/AspireMonitorSplash.cs index d5467b53839..acc26ef52ea 100644 --- a/src/Aspire.Cli/UI/AspireMonitorSplash.cs +++ b/src/Aspire.Cli/UI/AspireMonitorSplash.cs @@ -76,71 +76,66 @@ public Hex1bWidget Build(RootContext ctx) // Ease-out cubic for smooth deceleration var t = 1f - (1f - progress) * (1f - progress) * (1f - progress); - return ctx.ThemePanel(AspireTheme.Apply, ctx.VStack(outer => [ - outer.Text("").Fill(), - outer.Center( - ctx.Surface(layerCtx => [ - layerCtx.Layer(surface => - { - RenderFrame(surface, t); - }) - ]).Size(LogoWidth, LogoCellHeight) - ), - outer.Text("").Fill() - ]).Fill()).Fill(); + return ctx.ThemePanel(AspireTheme.Apply, + ctx.Surface(layerCtx => [ + layerCtx.Layer(surface => + { + // Center the logo within the full terminal surface + var offsetX = Math.Max(0, (surface.Width - LogoWidth) / 2); + var offsetY = Math.Max(0, (surface.Height - LogoCellHeight) / 2); + RenderFrame(surface, t, offsetX, offsetY); + }) + ]).Fill() + ).Fill(); } - private void RenderFrame(Hex1b.Surfaces.Surface surface, float t) + private void RenderFrame(Hex1b.Surfaces.Surface surface, float t, int offsetX, int offsetY) { - // Build a pixel grid: for each cell (cx, cy), we need the top and bottom logical pixel - // Top pixel = logical row cy*2, bottom pixel = logical row cy*2+1 - // Use ▀ with fg=top color, bg=bottom color - - // Collect animated pixel positions into a sparse grid - var grid = new (byte R, byte G, byte B, bool HasPixel)[LogoWidth, LogoHeight]; + // Use the full surface for scattering, then render half-block pairs + // Grid covers the entire terminal surface in logical pixels (2 rows per cell) + var gridW = surface.Width; + var gridH = surface.Height * 2; + var grid = new (byte R, byte G, byte B, bool HasPixel)[gridW, gridH]; foreach (var pixel in _pixels) { - // Lerp from start to final position - var currentX = pixel.FinalX + pixel.StartOffsetX * (1f - t); - var currentY = pixel.FinalY + pixel.StartOffsetY * (1f - t); + // Lerp from start to final position, offset to center + var currentX = (pixel.FinalX + offsetX) + pixel.StartOffsetX * (1f - t); + var currentY = (pixel.FinalY + offsetY * 2) + pixel.StartOffsetY * (1f - t); var ix = (int)Math.Round(currentX); var iy = (int)Math.Round(currentY); - if (ix >= 0 && ix < LogoWidth && iy >= 0 && iy < LogoHeight) + if (ix >= 0 && ix < gridW && iy >= 0 && iy < gridH) { grid[ix, iy] = (pixel.R, pixel.G, pixel.B, true); } } - // Render half-block pairs - for (var cy = 0; cy < LogoCellHeight && cy < surface.Height; cy++) + // Render half-block pairs across the entire surface + for (var cy = 0; cy < surface.Height; cy++) { - for (var cx = 0; cx < LogoWidth && cx < surface.Width; cx++) + for (var cx = 0; cx < surface.Width; cx++) { var topRow = cy * 2; var botRow = cy * 2 + 1; var top = grid[cx, topRow]; - var bot = botRow < LogoHeight ? grid[cx, botRow] : default; + var bot = botRow < gridH ? grid[cx, botRow] : default; if (top.HasPixel && bot.HasPixel) { - // Both pixels visible: ▀ with fg=top, bg=bottom var fg = Hex1bColor.FromRgb(top.R, top.G, top.B); var bg = Hex1bColor.FromRgb(bot.R, bot.G, bot.B); surface.WriteChar(cx, cy, '\u2580', fg, bg); } else if (top.HasPixel) { - // Only top pixel: ▀ with fg=top color var fg = Hex1bColor.FromRgb(top.R, top.G, top.B); surface.WriteChar(cx, cy, '\u2580', fg); } else if (bot.HasPixel) { - // Only bottom pixel: ▄ with fg=bottom color var fg = Hex1bColor.FromRgb(bot.R, bot.G, bot.B); surface.WriteChar(cx, cy, '\u2584', fg); } From 4fff92d1b2e9ae1117e5747b2f4adc84ce569bd0 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 14:23:37 +1100 Subject: [PATCH 11/34] Slow down splash animation and hold logo longer before transition --- src/Aspire.Cli/UI/AspireMonitorSplash.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorSplash.cs b/src/Aspire.Cli/UI/AspireMonitorSplash.cs index acc26ef52ea..08635d7eb37 100644 --- a/src/Aspire.Cli/UI/AspireMonitorSplash.cs +++ b/src/Aspire.Cli/UI/AspireMonitorSplash.cs @@ -13,13 +13,13 @@ namespace Aspire.Cli.UI; /// internal sealed class AspireMonitorSplash { - public const int SplashDurationMs = 2000; + public const int SplashDurationMs = 3500; // Logo dimensions in logical pixels (each pixel is half a character cell vertically) private const int LogoWidth = 40; private const int LogoHeight = 40; private const int LogoCellHeight = LogoHeight / 2; - public const int AnimationDurationMs = 1200; + public const int AnimationDurationMs = 1800; private readonly record struct PixelState( int FinalX, int FinalY, byte R, byte G, byte B, From a8204a3df79a83bec5a5af485672492fc8aa6308 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 14:34:51 +1100 Subject: [PATCH 12/34] Add dissolve exit animation with braille particles, gravity, and bounce --- src/Aspire.Cli/UI/AspireMonitorSplash.cs | 321 +++++++++++++++++++++-- src/Aspire.Cli/UI/AspireMonitorTui.cs | 5 +- 2 files changed, 294 insertions(+), 32 deletions(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorSplash.cs b/src/Aspire.Cli/UI/AspireMonitorSplash.cs index 08635d7eb37..dccf35251f8 100644 --- a/src/Aspire.Cli/UI/AspireMonitorSplash.cs +++ b/src/Aspire.Cli/UI/AspireMonitorSplash.cs @@ -9,29 +9,59 @@ namespace Aspire.Cli.UI; /// /// Animated splash screen that renders the Aspire logo using half-block characters. -/// Pixels fly in from random off-screen positions to their final locations. +/// Pixels fly in from random off-screen positions, hold, then dissolve into braille +/// dots that fall with gravity and bounce off the bottom of the screen. /// internal sealed class AspireMonitorSplash { - public const int SplashDurationMs = 3500; + // Timing + private const int FlyInDurationMs = 1800; + private const int HoldEndMs = 2800; + private const int DissolveDurationMs = 600; + private const int ExitDurationMs = 2200; + public const int TotalDurationMs = HoldEndMs + ExitDurationMs; // Logo dimensions in logical pixels (each pixel is half a character cell vertically) private const int LogoWidth = 40; private const int LogoHeight = 40; private const int LogoCellHeight = LogoHeight / 2; - public const int AnimationDurationMs = 1800; + + // Physics (in braille sub-pixel units per second) + private const float Gravity = 80f; + private const float BounceDecay = 0.3f; + private const float SettleThreshold = 3f; + private const int FadeOutMs = 500; private readonly record struct PixelState( int FinalX, int FinalY, byte R, byte G, byte B, float StartOffsetX, float StartOffsetY); + private sealed class Particle + { + public float X; + public float Y; + public float Vx; + public float Vy; + public byte R; + public byte G; + public byte B; + public float SpawnTimeMs; + public bool Settled; + } + private readonly PixelState[] _pixels; private readonly long _startTicks; + private List? _particles; + private long _lastPhysicsTick; + private float _maxBrailleY; + + // Braille dot bit positions: index = col*4 + row + private static readonly int[] s_brailleBits = [0x01, 0x02, 0x04, 0x40, 0x08, 0x10, 0x20, 0x80]; public AspireMonitorSplash() { _startTicks = Environment.TickCount64; - var rng = new Random(42); // Fixed seed for deterministic animation + var rng = new Random(42); var pixelCount = s_pixelData.Length / 5; _pixels = new PixelState[pixelCount]; @@ -45,7 +75,6 @@ public AspireMonitorSplash() var g = s_pixelData[offset + 3]; var b = s_pixelData[offset + 4]; - // Random start direction — fly in from outside the logo bounds var angle = rng.NextDouble() * Math.PI * 2; var distance = 30 + rng.NextDouble() * 40; var startOffsetX = (float)(Math.Cos(angle) * distance); @@ -55,51 +84,48 @@ public AspireMonitorSplash() } } - /// - /// Gets the animation progress from 0.0 to 1.0. - /// - public float Progress - { - get - { - var elapsed = Environment.TickCount64 - _startTicks; - return Math.Clamp(elapsed / (float)AnimationDurationMs, 0f, 1f); - } - } + private long ElapsedMs => Environment.TickCount64 - _startTicks; - public bool IsComplete => Progress >= 1f; + public bool IsComplete => ElapsedMs >= TotalDurationMs; public Hex1bWidget Build(RootContext ctx) { - var progress = Progress; - - // Ease-out cubic for smooth deceleration - var t = 1f - (1f - progress) * (1f - progress) * (1f - progress); + var elapsed = ElapsedMs; return ctx.ThemePanel(AspireTheme.Apply, ctx.Surface(layerCtx => [ layerCtx.Layer(surface => { - // Center the logo within the full terminal surface var offsetX = Math.Max(0, (surface.Width - LogoWidth) / 2); var offsetY = Math.Max(0, (surface.Height - LogoCellHeight) / 2); - RenderFrame(surface, t, offsetX, offsetY); + + if (elapsed < FlyInDurationMs) + { + var t = EaseOutCubic(elapsed / (float)FlyInDurationMs); + RenderFlyIn(surface, t, offsetX, offsetY); + } + else if (elapsed < HoldEndMs) + { + RenderStatic(surface, offsetX, offsetY); + } + else if (elapsed < TotalDurationMs) + { + var exitElapsed = elapsed - HoldEndMs; + RenderExit(surface, exitElapsed, offsetX, offsetY); + } }) ]).Fill() ).Fill(); } - private void RenderFrame(Hex1b.Surfaces.Surface surface, float t, int offsetX, int offsetY) + private void RenderFlyIn(Hex1b.Surfaces.Surface surface, float t, int offsetX, int offsetY) { - // Use the full surface for scattering, then render half-block pairs - // Grid covers the entire terminal surface in logical pixels (2 rows per cell) var gridW = surface.Width; var gridH = surface.Height * 2; var grid = new (byte R, byte G, byte B, bool HasPixel)[gridW, gridH]; foreach (var pixel in _pixels) { - // Lerp from start to final position, offset to center var currentX = (pixel.FinalX + offsetX) + pixel.StartOffsetX * (1f - t); var currentY = (pixel.FinalY + offsetY * 2) + pixel.StartOffsetY * (1f - t); @@ -112,7 +138,242 @@ private void RenderFrame(Hex1b.Surfaces.Surface surface, float t, int offsetX, i } } - // Render half-block pairs across the entire surface + RenderHalfBlocks(surface, grid, gridH); + } + + private void RenderStatic(Hex1b.Surfaces.Surface surface, int offsetX, int offsetY) + { + var gridW = surface.Width; + var gridH = surface.Height * 2; + var grid = new (byte R, byte G, byte B, bool HasPixel)[gridW, gridH]; + + foreach (var pixel in _pixels) + { + var sx = pixel.FinalX + offsetX; + var sy = pixel.FinalY + offsetY * 2; + + if (sx >= 0 && sx < gridW && sy >= 0 && sy < gridH) + { + grid[sx, sy] = (pixel.R, pixel.G, pixel.B, true); + } + } + + RenderHalfBlocks(surface, grid, gridH); + } + + private void RenderExit(Hex1b.Surfaces.Surface surface, long exitElapsedMs, int offsetX, int offsetY) + { + EnsureParticlesCreated(surface.Height, offsetX, offsetY); + UpdateParticles(exitElapsedMs); + + var dissolveProgress = Math.Clamp(exitElapsedMs / (float)DissolveDurationMs, 0f, 1f); + var dissolvedUpToRow = (int)(dissolveProgress * LogoCellHeight); + + // Fade factor for the end of the exit + var fadeStart = ExitDurationMs - FadeOutMs; + var fadeFactor = exitElapsedMs > fadeStart + ? 1f - Math.Clamp((exitElapsedMs - fadeStart) / (float)FadeOutMs, 0f, 1f) + : 1f; + + // Render undissolved rows as half-blocks + if (dissolvedUpToRow < LogoCellHeight) + { + RenderStaticRows(surface, offsetX, offsetY, dissolvedUpToRow, LogoCellHeight, fadeFactor); + } + + // Render particles as braille + RenderBrailleParticles(surface, exitElapsedMs, fadeFactor); + } + + private void EnsureParticlesCreated(int surfaceHeight, int offsetX, int offsetY) + { + if (_particles is not null) + { + return; + } + + _maxBrailleY = surfaceHeight * 4 - 1; + _lastPhysicsTick = Environment.TickCount64; + _particles = new List(_pixels.Length * 4); + var rng = new Random(123); + + foreach (var pixel in _pixels) + { + var sx = offsetX + pixel.FinalX; + var sy = offsetY + pixel.FinalY / 2; + var isTopHalf = pixel.FinalY % 2 == 0; + var cellRow = pixel.FinalY / 2; + + var spawnTimeMs = (cellRow / (float)LogoCellHeight) * DissolveDurationMs; + + var baseBx = sx * 2; + var baseBy = sy * 4 + (isTopHalf ? 0 : 2); + + for (var dr = 0; dr < 2; dr++) + { + for (var dc = 0; dc < 2; dc++) + { + _particles.Add(new Particle + { + X = baseBx + dc, + Y = baseBy + dr, + Vx = (float)(rng.NextDouble() * 30 - 15), + Vy = (float)(rng.NextDouble() * 25 + 10), + R = pixel.R, + G = pixel.G, + B = pixel.B, + SpawnTimeMs = spawnTimeMs + }); + } + } + } + } + + private void UpdateParticles(long exitElapsedMs) + { + if (_particles is null) + { + return; + } + + var currentTick = Environment.TickCount64; + var dt = (currentTick - _lastPhysicsTick) / 1000f; + _lastPhysicsTick = currentTick; + dt = Math.Min(dt, 0.1f); + + foreach (var p in _particles) + { + if (p.SpawnTimeMs > exitElapsedMs || p.Settled) + { + continue; + } + + p.Vy += Gravity * dt; + p.X += p.Vx * dt; + p.Y += p.Vy * dt; + + if (p.Y >= _maxBrailleY) + { + p.Y = _maxBrailleY; + p.Vy = -p.Vy * BounceDecay; + p.Vx *= 0.8f; + + if (Math.Abs(p.Vy) < SettleThreshold) + { + p.Settled = true; + } + } + } + } + + private void RenderStaticRows(Hex1b.Surfaces.Surface surface, int offsetX, int offsetY, int fromRow, int toRow, float fadeFactor) + { + var grid = new (byte R, byte G, byte B, bool HasPixel)[LogoWidth, LogoHeight]; + + foreach (var pixel in _pixels) + { + var cellRow = pixel.FinalY / 2; + if (cellRow >= fromRow && cellRow < toRow) + { + grid[pixel.FinalX, pixel.FinalY] = (pixel.R, pixel.G, pixel.B, true); + } + } + + for (var cy = fromRow; cy < toRow; cy++) + { + for (var cx = 0; cx < LogoWidth; cx++) + { + var sx = offsetX + cx; + var sy = offsetY + cy; + if (sx < 0 || sx >= surface.Width || sy < 0 || sy >= surface.Height) + { + continue; + } + + var topRow = cy * 2; + var botRow = cy * 2 + 1; + var top = grid[cx, topRow]; + var bot = botRow < LogoHeight ? grid[cx, botRow] : default; + + if (top.HasPixel && bot.HasPixel) + { + var fg = Hex1bColor.FromRgb(Dim(top.R, fadeFactor), Dim(top.G, fadeFactor), Dim(top.B, fadeFactor)); + var bg = Hex1bColor.FromRgb(Dim(bot.R, fadeFactor), Dim(bot.G, fadeFactor), Dim(bot.B, fadeFactor)); + surface.WriteChar(sx, sy, '\u2580', fg, bg); + } + else if (top.HasPixel) + { + var fg = Hex1bColor.FromRgb(Dim(top.R, fadeFactor), Dim(top.G, fadeFactor), Dim(top.B, fadeFactor)); + surface.WriteChar(sx, sy, '\u2580', fg); + } + else if (bot.HasPixel) + { + var fg = Hex1bColor.FromRgb(Dim(bot.R, fadeFactor), Dim(bot.G, fadeFactor), Dim(bot.B, fadeFactor)); + surface.WriteChar(sx, sy, '\u2584', fg); + } + } + } + } + + private void RenderBrailleParticles(Hex1b.Surfaces.Surface surface, long exitElapsedMs, float fadeFactor) + { + if (_particles is null) + { + return; + } + + // Group active particles by cell position, accumulating color for averaging + var cells = new Dictionary<(int cx, int cy), (int pattern, int r, int g, int b, int count)>(); + + foreach (var p in _particles) + { + if (p.SpawnTimeMs > exitElapsedMs) + { + continue; + } + + var bx = (int)Math.Round(p.X); + var by = (int)Math.Round(p.Y); + + if (bx < 0 || bx >= surface.Width * 2 || by < 0 || by >= surface.Height * 4) + { + continue; + } + + var cx = bx / 2; + var cy = by / 4; + var dotCol = bx % 2; + var dotRow = by % 4; + var bit = s_brailleBits[dotCol * 4 + dotRow]; + + var key = (cx, cy); + if (cells.TryGetValue(key, out var existing)) + { + cells[key] = (existing.pattern | bit, existing.r + p.R, existing.g + p.G, existing.b + p.B, existing.count + 1); + } + else + { + cells[key] = (bit, p.R, p.G, p.B, 1); + } + } + + foreach (var ((cx, cy), (pattern, totalR, totalG, totalB, count)) in cells) + { + if (cx < 0 || cx >= surface.Width || cy < 0 || cy >= surface.Height) + { + continue; + } + + var r = Dim((byte)(totalR / count), fadeFactor); + var g = Dim((byte)(totalG / count), fadeFactor); + var b = Dim((byte)(totalB / count), fadeFactor); + var ch = (char)(0x2800 | pattern); + surface.WriteChar(cx, cy, ch, Hex1bColor.FromRgb(r, g, b)); + } + } + + private static void RenderHalfBlocks(Hex1b.Surfaces.Surface surface, (byte R, byte G, byte B, bool HasPixel)[,] grid, int gridH) + { for (var cy = 0; cy < surface.Height; cy++) { for (var cx = 0; cx < surface.Width; cx++) @@ -143,6 +404,10 @@ private void RenderFrame(Hex1b.Surfaces.Surface surface, float t, int offsetX, i } } + private static float EaseOutCubic(float x) => 1f - (1f - x) * (1f - x) * (1f - x); + + private static byte Dim(byte value, float factor) => (byte)(value * factor); + private static readonly byte[] s_pixelData = [ 0x13, 0x03, 0xE1, 0xDB, 0xF8, 0x14, 0x03, 0xE1, 0xDB, 0xF8, 0x11, 0x04, 0xD2, 0xC7, 0xF4, 0x12, 0x04, 0x6C, 0x4D, 0xDB, diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index d022192e67f..a5ae18b9bb5 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -75,16 +75,13 @@ public async Task RunAsync(CancellationToken cancellationToken) // Animate the splash, then transition to main screen _ = Task.Run(async () => { - // Drive the animation by invalidating at ~30fps + // Drive the animation by invalidating at ~30fps until all phases complete while (!_splash.IsComplete) { _app?.Invalidate(); await Task.Delay(33, cancellationToken).ConfigureAwait(false); } - _app?.Invalidate(); - // Hold the completed logo briefly - await Task.Delay(AspireMonitorSplash.SplashDurationMs - AspireMonitorSplash.AnimationDurationMs, cancellationToken).ConfigureAwait(false); _showSplash = false; _app?.Invalidate(); From f80872c30716e086feeb8975b816a5dea46643e5 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 14:41:01 +1100 Subject: [PATCH 13/34] Fix edge pixel white bleed and extend dissolve/fall duration - Re-processed logo pixels: replaced white background with black at full resolution before downsampling, so edge anti-aliasing blends toward dark purple instead of white - Extended dissolve wave from 600ms to 900ms - Extended total exit phase from 2200ms to 3200ms (total splash: 6.0s) --- src/Aspire.Cli/UI/AspireMonitorSplash.cs | 385 ++++++++++++----------- 1 file changed, 195 insertions(+), 190 deletions(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorSplash.cs b/src/Aspire.Cli/UI/AspireMonitorSplash.cs index dccf35251f8..e605d3ce72f 100644 --- a/src/Aspire.Cli/UI/AspireMonitorSplash.cs +++ b/src/Aspire.Cli/UI/AspireMonitorSplash.cs @@ -17,8 +17,8 @@ internal sealed class AspireMonitorSplash // Timing private const int FlyInDurationMs = 1800; private const int HoldEndMs = 2800; - private const int DissolveDurationMs = 600; - private const int ExitDurationMs = 2200; + private const int DissolveDurationMs = 900; + private const int ExitDurationMs = 3200; public const int TotalDurationMs = HoldEndMs + ExitDurationMs; // Logo dimensions in logical pixels (each pixel is half a character cell vertically) @@ -410,193 +410,198 @@ private static void RenderHalfBlocks(Hex1b.Surfaces.Surface surface, (byte R, by private static readonly byte[] s_pixelData = [ - 0x13, 0x03, 0xE1, 0xDB, 0xF8, 0x14, 0x03, 0xE1, 0xDB, 0xF8, 0x11, 0x04, 0xD2, 0xC7, 0xF4, 0x12, 0x04, 0x6C, 0x4D, 0xDB, - 0x13, 0x04, 0x4F, 0x2A, 0xD4, 0x14, 0x04, 0x4F, 0x29, 0xD4, 0x15, 0x04, 0x6C, 0x4C, 0xDB, 0x16, 0x04, 0xD1, 0xC6, 0xF4, - 0x10, 0x05, 0xDD, 0xD5, 0xF7, 0x11, 0x05, 0x4F, 0x27, 0xD4, 0x12, 0x05, 0x47, 0x1F, 0xD2, 0x13, 0x05, 0x52, 0x2C, 0xD4, - 0x14, 0x05, 0x52, 0x2C, 0xD4, 0x15, 0x05, 0x47, 0x20, 0xD2, 0x16, 0x05, 0x4F, 0x27, 0xD4, 0x17, 0x05, 0xDD, 0xD5, 0xF7, - 0x10, 0x06, 0x7A, 0x5D, 0xDE, 0x11, 0x06, 0x46, 0x1E, 0xD1, 0x12, 0x06, 0x5B, 0x37, 0xD7, 0x13, 0x06, 0x6F, 0x4F, 0xDC, - 0x14, 0x06, 0x6F, 0x4F, 0xDB, 0x15, 0x06, 0x5B, 0x37, 0xD7, 0x16, 0x06, 0x46, 0x1E, 0xD1, 0x17, 0x06, 0x7A, 0x5C, 0xDE, - 0x0F, 0x07, 0xC2, 0xB4, 0xF0, 0x10, 0x07, 0x46, 0x1D, 0xD1, 0x11, 0x07, 0x53, 0x2D, 0xD4, 0x12, 0x07, 0x6D, 0x4C, 0xDB, - 0x13, 0x07, 0x77, 0x58, 0xDD, 0x14, 0x07, 0x77, 0x58, 0xDD, 0x15, 0x07, 0x6D, 0x4C, 0xDB, 0x16, 0x07, 0x53, 0x2D, 0xD4, - 0x17, 0x07, 0x46, 0x1D, 0xD1, 0x18, 0x07, 0xC2, 0xB4, 0xF0, 0x0F, 0x08, 0x66, 0x43, 0xD9, 0x10, 0x08, 0x4A, 0x22, 0xD2, - 0x11, 0x08, 0x5F, 0x3D, 0xD8, 0x12, 0x08, 0x76, 0x58, 0xDE, 0x13, 0x08, 0x73, 0x54, 0xDD, 0x14, 0x08, 0x73, 0x54, 0xDD, - 0x15, 0x08, 0x76, 0x58, 0xDE, 0x16, 0x08, 0x60, 0x3C, 0xD8, 0x17, 0x08, 0x4A, 0x22, 0xD2, 0x18, 0x08, 0x66, 0x43, 0xD9, - 0x0E, 0x09, 0xA7, 0x94, 0xE9, 0x0F, 0x09, 0x43, 0x1B, 0xD1, 0x10, 0x09, 0x56, 0x31, 0xD5, 0x11, 0x09, 0x71, 0x51, 0xDC, - 0x12, 0x09, 0x75, 0x56, 0xDD, 0x13, 0x09, 0x74, 0x55, 0xDD, 0x14, 0x09, 0x74, 0x55, 0xDD, 0x15, 0x09, 0x75, 0x56, 0xDD, - 0x16, 0x09, 0x70, 0x51, 0xDC, 0x17, 0x09, 0x55, 0x31, 0xD5, 0x18, 0x09, 0x43, 0x1A, 0xD1, 0x19, 0x09, 0xA7, 0x93, 0xEA, - 0x0D, 0x0A, 0xE9, 0xE4, 0xFA, 0x0E, 0x0A, 0x56, 0x30, 0xD5, 0x0F, 0x0A, 0x4D, 0x26, 0xD3, 0x10, 0x0A, 0x64, 0x42, 0xD9, - 0x11, 0x0A, 0x77, 0x58, 0xDE, 0x12, 0x0A, 0x73, 0x54, 0xDD, 0x13, 0x0A, 0x74, 0x55, 0xDD, 0x14, 0x0A, 0x74, 0x55, 0xDD, - 0x15, 0x0A, 0x73, 0x54, 0xDD, 0x16, 0x0A, 0x77, 0x58, 0xDE, 0x17, 0x0A, 0x64, 0x42, 0xD9, 0x18, 0x0A, 0x4D, 0x27, 0xD3, - 0x19, 0x0A, 0x56, 0x2F, 0xD5, 0x1A, 0x0A, 0xE9, 0xE3, 0xFA, 0x0D, 0x0B, 0x8D, 0x73, 0xE3, 0x0E, 0x0B, 0x44, 0x1C, 0xD1, - 0x0F, 0x0B, 0x59, 0x35, 0xD6, 0x10, 0x0B, 0x74, 0x55, 0xDD, 0x11, 0x0B, 0x74, 0x55, 0xDD, 0x12, 0x0B, 0x74, 0x55, 0xDD, - 0x13, 0x0B, 0x74, 0x55, 0xDD, 0x14, 0x0B, 0x74, 0x55, 0xDD, 0x15, 0x0B, 0x74, 0x55, 0xDD, 0x16, 0x0B, 0x74, 0x55, 0xDD, - 0x17, 0x0B, 0x74, 0x54, 0xDD, 0x18, 0x0B, 0x59, 0x34, 0xD6, 0x19, 0x0B, 0x44, 0x1C, 0xD1, 0x1A, 0x0B, 0x8D, 0x72, 0xE3, - 0x0C, 0x0C, 0xD5, 0xCB, 0xF4, 0x0D, 0x0C, 0x4B, 0x23, 0xD3, 0x0E, 0x0C, 0x51, 0x2A, 0xD4, 0x0F, 0x0C, 0x69, 0x47, 0xDA, - 0x10, 0x0C, 0x77, 0x58, 0xDE, 0x11, 0x0C, 0x73, 0x54, 0xDD, 0x12, 0x0C, 0x74, 0x55, 0xDD, 0x13, 0x0C, 0x74, 0x55, 0xDD, - 0x14, 0x0C, 0x74, 0x55, 0xDD, 0x15, 0x0C, 0x74, 0x55, 0xDD, 0x16, 0x0C, 0x73, 0x54, 0xDD, 0x17, 0x0C, 0x76, 0x58, 0xDE, - 0x18, 0x0C, 0x69, 0x47, 0xDA, 0x19, 0x0C, 0x50, 0x2A, 0xD4, 0x1A, 0x0C, 0x4B, 0x23, 0xD2, 0x1B, 0x0C, 0xD5, 0xCA, 0xF4, - 0x0C, 0x0D, 0x75, 0x56, 0xDD, 0x0D, 0x0D, 0x47, 0x1E, 0xD1, 0x0E, 0x0D, 0x5B, 0x38, 0xD7, 0x0F, 0x0D, 0x78, 0x59, 0xDE, - 0x10, 0x0D, 0x72, 0x53, 0xDD, 0x11, 0x0D, 0x74, 0x55, 0xDD, 0x12, 0x0D, 0x74, 0x55, 0xDD, 0x13, 0x0D, 0x74, 0x55, 0xDD, - 0x14, 0x0D, 0x74, 0x55, 0xDD, 0x15, 0x0D, 0x74, 0x55, 0xDD, 0x16, 0x0D, 0x74, 0x55, 0xDD, 0x17, 0x0D, 0x74, 0x54, 0xDD, - 0x18, 0x0D, 0x76, 0x57, 0xDD, 0x19, 0x0D, 0x5C, 0x39, 0xD7, 0x1A, 0x0D, 0x47, 0x1F, 0xD2, 0x1B, 0x0D, 0x74, 0x55, 0xDD, - 0x0B, 0x0E, 0xBC, 0xAC, 0xEE, 0x0C, 0x0E, 0x45, 0x1C, 0xD1, 0x0D, 0x0E, 0x53, 0x2E, 0xD5, 0x0E, 0x0E, 0x84, 0x69, 0xE0, - 0x0F, 0x0E, 0x98, 0x81, 0xE5, 0x10, 0x0E, 0x85, 0x6A, 0xE1, 0x11, 0x0E, 0x72, 0x53, 0xDD, 0x12, 0x0E, 0x74, 0x56, 0xDD, - 0x13, 0x0E, 0x74, 0x55, 0xDD, 0x14, 0x0E, 0x74, 0x55, 0xDD, 0x15, 0x0E, 0x74, 0x55, 0xDD, 0x16, 0x0E, 0x74, 0x55, 0xDD, - 0x17, 0x0E, 0x74, 0x55, 0xDD, 0x18, 0x0E, 0x76, 0x57, 0xDD, 0x19, 0x0E, 0x6E, 0x4D, 0xDB, 0x1A, 0x0E, 0x53, 0x2E, 0xD5, - 0x1B, 0x0E, 0x45, 0x1C, 0xD1, 0x1C, 0x0E, 0xBC, 0xAC, 0xEE, 0x0B, 0x0F, 0x61, 0x3E, 0xD8, 0x0C, 0x0F, 0x48, 0x20, 0xD2, - 0x0D, 0x0F, 0x70, 0x50, 0xDB, 0x0E, 0x0F, 0x9D, 0x87, 0xE6, 0x0F, 0x0F, 0x97, 0x80, 0xE5, 0x10, 0x0F, 0x99, 0x83, 0xE5, - 0x11, 0x0F, 0x7C, 0x5F, 0xDF, 0x12, 0x0F, 0x72, 0x53, 0xDD, 0x13, 0x0F, 0x74, 0x56, 0xDD, 0x14, 0x0F, 0x74, 0x55, 0xDD, - 0x15, 0x0F, 0x74, 0x55, 0xDD, 0x16, 0x0F, 0x74, 0x55, 0xDD, 0x17, 0x0F, 0x74, 0x55, 0xDD, 0x18, 0x0F, 0x73, 0x54, 0xDD, - 0x19, 0x0F, 0x77, 0x58, 0xDE, 0x1A, 0x0F, 0x61, 0x3E, 0xD8, 0x1B, 0x0F, 0x4B, 0x23, 0xD2, 0x1C, 0x0F, 0x60, 0x3D, 0xD8, - 0x1D, 0x0F, 0xF4, 0xF0, 0xFC, 0x0A, 0x10, 0xA1, 0x8B, 0xE7, 0x0B, 0x10, 0x43, 0x1A, 0xD1, 0x0C, 0x10, 0x59, 0x35, 0xD6, - 0x0D, 0x10, 0x92, 0x7A, 0xE4, 0x0E, 0x10, 0x98, 0x82, 0xE5, 0x0F, 0x10, 0x96, 0x7F, 0xE5, 0x10, 0x10, 0x99, 0x82, 0xE5, - 0x11, 0x10, 0x8F, 0x77, 0xE3, 0x12, 0x10, 0x73, 0x54, 0xDD, 0x13, 0x10, 0x74, 0x55, 0xDD, 0x14, 0x10, 0x74, 0x55, 0xDD, - 0x15, 0x10, 0x74, 0x55, 0xDD, 0x16, 0x10, 0x74, 0x55, 0xDD, 0x17, 0x10, 0x74, 0x55, 0xDD, 0x18, 0x10, 0x74, 0x55, 0xDD, - 0x19, 0x10, 0x75, 0x56, 0xDD, 0x1A, 0x10, 0x71, 0x52, 0xDC, 0x1B, 0x10, 0x56, 0x32, 0xD5, 0x1C, 0x10, 0x43, 0x1A, 0xD1, - 0x1D, 0x10, 0xA1, 0x8B, 0xE8, 0x09, 0x11, 0xE4, 0xDD, 0xF8, 0x0A, 0x11, 0x52, 0x2C, 0xD4, 0x0B, 0x11, 0x4C, 0x25, 0xD3, - 0x0C, 0x11, 0x7A, 0x5D, 0xDE, 0x0D, 0x11, 0x9C, 0x86, 0xE6, 0x0E, 0x11, 0x96, 0x7F, 0xE5, 0x0F, 0x11, 0x97, 0x80, 0xE5, - 0x10, 0x11, 0x96, 0x7F, 0xE5, 0x11, 0x11, 0x99, 0x83, 0xE6, 0x12, 0x11, 0x81, 0x65, 0xE0, 0x13, 0x11, 0x72, 0x52, 0xDC, - 0x14, 0x11, 0x75, 0x56, 0xDD, 0x15, 0x11, 0x74, 0x55, 0xDD, 0x16, 0x11, 0x74, 0x55, 0xDD, 0x17, 0x11, 0x74, 0x55, 0xDD, - 0x18, 0x11, 0x74, 0x55, 0xDD, 0x19, 0x11, 0x73, 0x54, 0xDD, 0x1A, 0x11, 0x77, 0x58, 0xDE, 0x1B, 0x11, 0x65, 0x44, 0xD9, - 0x1C, 0x11, 0x4E, 0x28, 0xD3, 0x1D, 0x11, 0x52, 0x2B, 0xD4, 0x1E, 0x11, 0xE4, 0xDD, 0xF8, 0x09, 0x12, 0x86, 0x6B, 0xE1, - 0x0A, 0x12, 0x43, 0x1A, 0xD1, 0x0B, 0x12, 0x60, 0x3D, 0xD8, 0x0C, 0x12, 0x98, 0x81, 0xE5, 0x0D, 0x12, 0x97, 0x80, 0xE5, - 0x0E, 0x12, 0x97, 0x80, 0xE5, 0x0F, 0x12, 0x97, 0x80, 0xE5, 0x10, 0x12, 0x97, 0x80, 0xE5, 0x11, 0x12, 0x98, 0x81, 0xE5, - 0x12, 0x12, 0x94, 0x7C, 0xE4, 0x13, 0x12, 0x76, 0x57, 0xDD, 0x14, 0x12, 0x74, 0x54, 0xDD, 0x15, 0x12, 0x74, 0x55, 0xDD, - 0x16, 0x12, 0x74, 0x55, 0xDD, 0x17, 0x12, 0x74, 0x55, 0xDD, 0x18, 0x12, 0x74, 0x55, 0xDD, 0x19, 0x12, 0x74, 0x55, 0xDD, - 0x1A, 0x12, 0x74, 0x55, 0xDD, 0x1B, 0x12, 0x74, 0x55, 0xDD, 0x1C, 0x12, 0x5A, 0x36, 0xD6, 0x1D, 0x12, 0x45, 0x1C, 0xD1, - 0x1E, 0x12, 0x86, 0x6B, 0xE1, 0x08, 0x13, 0xCE, 0xC3, 0xF3, 0x09, 0x13, 0x49, 0x21, 0xD2, 0x0A, 0x13, 0x50, 0x29, 0xD4, - 0x0B, 0x13, 0x84, 0x68, 0xE0, 0x0C, 0x13, 0x9B, 0x85, 0xE6, 0x0D, 0x13, 0x96, 0x7F, 0xE5, 0x0E, 0x13, 0x97, 0x80, 0xE5, - 0x0F, 0x13, 0x97, 0x80, 0xE5, 0x10, 0x13, 0x97, 0x80, 0xE5, 0x11, 0x13, 0x96, 0x7F, 0xE5, 0x12, 0x13, 0x9A, 0x83, 0xE6, - 0x13, 0x13, 0x87, 0x6C, 0xE1, 0x14, 0x13, 0x72, 0x52, 0xDC, 0x15, 0x13, 0x75, 0x56, 0xDD, 0x16, 0x13, 0x74, 0x55, 0xDD, - 0x17, 0x13, 0x74, 0x55, 0xDD, 0x18, 0x13, 0x74, 0x55, 0xDD, 0x19, 0x13, 0x74, 0x55, 0xDD, 0x1A, 0x13, 0x74, 0x54, 0xDD, - 0x1B, 0x13, 0x76, 0x58, 0xDE, 0x1C, 0x13, 0x6A, 0x49, 0xDB, 0x1D, 0x13, 0x51, 0x2B, 0xD4, 0x1E, 0x13, 0x48, 0x20, 0xD2, - 0x1F, 0x13, 0xCE, 0xC2, 0xF3, 0x08, 0x14, 0x6F, 0x4E, 0xDB, 0x09, 0x14, 0x46, 0x1E, 0xD1, 0x0A, 0x14, 0x69, 0x48, 0xDA, - 0x0B, 0x14, 0x9C, 0x85, 0xE6, 0x0C, 0x14, 0x96, 0x7F, 0xE5, 0x0D, 0x14, 0x97, 0x80, 0xE5, 0x0E, 0x14, 0x97, 0x80, 0xE5, - 0x0F, 0x14, 0x97, 0x80, 0xE5, 0x10, 0x14, 0x97, 0x80, 0xE5, 0x11, 0x14, 0x97, 0x80, 0xE5, 0x12, 0x14, 0x97, 0x80, 0xE5, - 0x13, 0x14, 0x97, 0x80, 0xE5, 0x14, 0x14, 0x79, 0x5B, 0xDE, 0x15, 0x14, 0x73, 0x54, 0xDD, 0x16, 0x14, 0x74, 0x56, 0xDD, - 0x17, 0x14, 0x74, 0x55, 0xDD, 0x18, 0x14, 0x74, 0x55, 0xDD, 0x19, 0x14, 0x74, 0x55, 0xDD, 0x1A, 0x14, 0x74, 0x55, 0xDD, - 0x1B, 0x14, 0x74, 0x54, 0xDD, 0x1C, 0x14, 0x76, 0x58, 0xDE, 0x1D, 0x14, 0x5E, 0x3B, 0xD7, 0x1E, 0x14, 0x48, 0x20, 0xD2, - 0x1F, 0x14, 0x6F, 0x4E, 0xDC, 0x07, 0x15, 0xB4, 0xA3, 0xED, 0x08, 0x15, 0x45, 0x1C, 0xD1, 0x09, 0x15, 0x52, 0x2C, 0xD4, - 0x0A, 0x15, 0x8A, 0x6F, 0xE1, 0x0B, 0x15, 0x97, 0x80, 0xE5, 0x0C, 0x15, 0x95, 0x7E, 0xE5, 0x0D, 0x15, 0x96, 0x7F, 0xE5, - 0x0E, 0x15, 0x96, 0x7F, 0xE5, 0x0F, 0x15, 0x96, 0x7F, 0xE5, 0x10, 0x15, 0x96, 0x7F, 0xE5, 0x11, 0x15, 0x96, 0x7F, 0xE5, - 0x12, 0x15, 0x95, 0x7E, 0xE5, 0x13, 0x15, 0x98, 0x82, 0xE5, 0x14, 0x15, 0x8B, 0x71, 0xE2, 0x15, 0x15, 0x71, 0x51, 0xDC, - 0x16, 0x15, 0x73, 0x54, 0xDD, 0x17, 0x15, 0x73, 0x54, 0xDD, 0x18, 0x15, 0x73, 0x54, 0xDD, 0x19, 0x15, 0x73, 0x54, 0xDD, - 0x1A, 0x15, 0x73, 0x54, 0xDD, 0x1B, 0x15, 0x73, 0x53, 0xDD, 0x1C, 0x15, 0x74, 0x55, 0xDD, 0x1D, 0x15, 0x6E, 0x4D, 0xDB, - 0x1E, 0x15, 0x53, 0x2E, 0xD5, 0x1F, 0x15, 0x44, 0x1B, 0xD1, 0x20, 0x15, 0xB4, 0xA3, 0xED, 0x06, 0x16, 0xF0, 0xED, 0xFB, - 0x07, 0x16, 0x5E, 0x3A, 0xD7, 0x08, 0x16, 0x46, 0x1D, 0xD1, 0x09, 0x16, 0x86, 0x6B, 0xE1, 0x0A, 0x16, 0xD3, 0xCA, 0xF4, - 0x0B, 0x16, 0xC2, 0xB5, 0xF0, 0x0C, 0x16, 0xB0, 0xA0, 0xEC, 0x0D, 0x16, 0xB2, 0xA1, 0xEC, 0x0E, 0x16, 0xB2, 0xA1, 0xEC, - 0x0F, 0x16, 0xB2, 0xA1, 0xEC, 0x10, 0x16, 0xB2, 0xA1, 0xEC, 0x11, 0x16, 0xB2, 0xA1, 0xEC, 0x12, 0x16, 0xB2, 0xA1, 0xEC, - 0x13, 0x16, 0xB1, 0xA0, 0xEC, 0x14, 0x16, 0xB3, 0xA3, 0xED, 0x15, 0x16, 0x99, 0x82, 0xE6, 0x16, 0x16, 0x8E, 0x74, 0xE3, - 0x17, 0x16, 0x90, 0x77, 0xE3, 0x18, 0x16, 0x8F, 0x77, 0xE3, 0x19, 0x16, 0x8F, 0x77, 0xE3, 0x1A, 0x16, 0x8F, 0x77, 0xE3, - 0x1B, 0x16, 0x8F, 0x77, 0xE3, 0x1C, 0x16, 0x8E, 0x76, 0xE3, 0x1D, 0x16, 0x94, 0x7C, 0xE4, 0x1E, 0x16, 0x70, 0x51, 0xDC, - 0x1F, 0x16, 0x49, 0x22, 0xD2, 0x20, 0x16, 0x5D, 0x38, 0xD7, 0x21, 0x16, 0xF0, 0xED, 0xFB, 0x06, 0x17, 0x9A, 0x83, 0xE6, - 0x07, 0x17, 0x41, 0x18, 0xD0, 0x08, 0x17, 0x60, 0x3D, 0xD8, 0x09, 0x17, 0xD6, 0xCD, 0xF5, 0x0A, 0x17, 0xE2, 0xDC, 0xF7, - 0x0B, 0x17, 0xE0, 0xDB, 0xF7, 0x0C, 0x17, 0xC8, 0xBC, 0xF2, 0x0D, 0x17, 0xB8, 0xA9, 0xEE, 0x0E, 0x17, 0xBB, 0xAD, 0xEE, - 0x0F, 0x17, 0xBB, 0xAC, 0xEE, 0x10, 0x17, 0xBB, 0xAC, 0xEE, 0x11, 0x17, 0xBB, 0xAC, 0xEE, 0x12, 0x17, 0xBB, 0xAC, 0xEE, - 0x13, 0x17, 0xBA, 0xAC, 0xEE, 0x14, 0x17, 0xBC, 0xAE, 0xEF, 0x15, 0x17, 0xB4, 0xA5, 0xED, 0x16, 0x17, 0x98, 0x81, 0xE5, - 0x17, 0x17, 0x99, 0x82, 0xE5, 0x18, 0x17, 0x99, 0x82, 0xE5, 0x19, 0x17, 0x99, 0x82, 0xE5, 0x1A, 0x17, 0x99, 0x82, 0xE5, - 0x1B, 0x17, 0x99, 0x82, 0xE5, 0x1C, 0x17, 0x98, 0x82, 0xE5, 0x1D, 0x17, 0x9A, 0x83, 0xE6, 0x1E, 0x17, 0x94, 0x7D, 0xE4, - 0x1F, 0x17, 0x5B, 0x36, 0xD6, 0x20, 0x17, 0x43, 0x1A, 0xD1, 0x21, 0x17, 0x99, 0x82, 0xE6, 0x05, 0x18, 0xDF, 0xD7, 0xF7, - 0x06, 0x18, 0x51, 0x2A, 0xD4, 0x07, 0x18, 0x48, 0x20, 0xD2, 0x08, 0x18, 0xA7, 0x93, 0xE9, 0x09, 0x18, 0xE6, 0xE1, 0xF8, - 0x0A, 0x18, 0xD9, 0xD1, 0xF5, 0x0B, 0x18, 0xDC, 0xD5, 0xF6, 0x0C, 0x18, 0xD9, 0xD2, 0xF5, 0x0D, 0x18, 0xBB, 0xAC, 0xEE, - 0x0E, 0x18, 0xB8, 0xA9, 0xEE, 0x0F, 0x18, 0xB9, 0xAA, 0xEE, 0x10, 0x18, 0xB9, 0xAA, 0xEE, 0x11, 0x18, 0xB9, 0xAA, 0xEE, - 0x12, 0x18, 0xB9, 0xAA, 0xEE, 0x13, 0x18, 0xB9, 0xAA, 0xEE, 0x14, 0x18, 0xB8, 0xA9, 0xEE, 0x15, 0x18, 0xBB, 0xAD, 0xEF, - 0x16, 0x18, 0xA5, 0x91, 0xE9, 0x17, 0x18, 0x94, 0x7D, 0xE4, 0x18, 0x18, 0x97, 0x80, 0xE5, 0x19, 0x18, 0x97, 0x80, 0xE5, - 0x1A, 0x18, 0x97, 0x80, 0xE5, 0x1B, 0x18, 0x97, 0x80, 0xE5, 0x1C, 0x18, 0x97, 0x80, 0xE5, 0x1D, 0x18, 0x95, 0x7E, 0xE5, - 0x1E, 0x18, 0x9C, 0x86, 0xE6, 0x1F, 0x18, 0x7D, 0x60, 0xDF, 0x20, 0x18, 0x4D, 0x26, 0xD3, 0x21, 0x18, 0x50, 0x28, 0xD4, - 0x22, 0x18, 0xDF, 0xD7, 0xF7, 0x05, 0x19, 0x80, 0x63, 0xE0, 0x06, 0x19, 0x41, 0x17, 0xD0, 0x07, 0x19, 0x70, 0x50, 0xDC, - 0x08, 0x19, 0xDF, 0xD9, 0xF7, 0x09, 0x19, 0xDB, 0xD4, 0xF6, 0x0A, 0x19, 0xDC, 0xD5, 0xF6, 0x0B, 0x19, 0xDB, 0xD4, 0xF6, - 0x0C, 0x19, 0xDE, 0xD8, 0xF7, 0x0D, 0x19, 0xCE, 0xC4, 0xF3, 0x0E, 0x19, 0xB7, 0xA7, 0xED, 0x0F, 0x19, 0xB9, 0xAB, 0xEE, - 0x10, 0x19, 0xB9, 0xAA, 0xEE, 0x11, 0x19, 0xB9, 0xAA, 0xEE, 0x12, 0x19, 0xB9, 0xAA, 0xEE, 0x13, 0x19, 0xB9, 0xAA, 0xEE, - 0x14, 0x19, 0xB9, 0xAA, 0xEE, 0x15, 0x19, 0xBA, 0xAB, 0xEE, 0x16, 0x19, 0xB7, 0xA7, 0xED, 0x17, 0x19, 0x99, 0x83, 0xE6, - 0x18, 0x19, 0x96, 0x7F, 0xE5, 0x19, 0x19, 0x97, 0x80, 0xE5, 0x1A, 0x19, 0x97, 0x80, 0xE5, 0x1B, 0x19, 0x97, 0x80, 0xE5, - 0x1C, 0x19, 0x97, 0x80, 0xE5, 0x1D, 0x19, 0x97, 0x80, 0xE5, 0x1E, 0x19, 0x97, 0x80, 0xE5, 0x1F, 0x19, 0x99, 0x82, 0xE5, - 0x20, 0x19, 0x62, 0x3F, 0xD8, 0x21, 0x19, 0x44, 0x1B, 0xD1, 0x22, 0x19, 0x80, 0x63, 0xE0, 0x04, 0x1A, 0xC8, 0xBA, 0xF1, - 0x05, 0x1A, 0x48, 0x1F, 0xD2, 0x06, 0x1A, 0x4E, 0x27, 0xD3, 0x07, 0x1A, 0xBB, 0xAB, 0xEE, 0x08, 0x1A, 0xE3, 0xDE, 0xF8, - 0x09, 0x1A, 0xDA, 0xD3, 0xF6, 0x0A, 0x1A, 0xDC, 0xD5, 0xF6, 0x0B, 0x1A, 0xDC, 0xD5, 0xF6, 0x0C, 0x1A, 0xDC, 0xD5, 0xF6, - 0x0D, 0x1A, 0xDD, 0xD6, 0xF6, 0x0E, 0x1A, 0xC1, 0xB4, 0xF0, 0x0F, 0x1A, 0xB7, 0xA8, 0xEE, 0x10, 0x1A, 0xB9, 0xAB, 0xEE, - 0x11, 0x1A, 0xB9, 0xAA, 0xEE, 0x12, 0x1A, 0xB9, 0xAA, 0xEE, 0x13, 0x1A, 0xB9, 0xAA, 0xEE, 0x14, 0x1A, 0xB9, 0xAA, 0xEE, - 0x15, 0x1A, 0xB8, 0xA9, 0xEE, 0x16, 0x1A, 0xBB, 0xAD, 0xEF, 0x17, 0x1A, 0xAA, 0x98, 0xEA, 0x18, 0x1A, 0x95, 0x7D, 0xE4, - 0x19, 0x1A, 0x98, 0x81, 0xE5, 0x1A, 0x1A, 0x97, 0x80, 0xE5, 0x1B, 0x1A, 0x97, 0x80, 0xE5, 0x1C, 0x1A, 0x97, 0x80, 0xE5, - 0x1D, 0x1A, 0x97, 0x80, 0xE5, 0x1E, 0x1A, 0x96, 0x7F, 0xE5, 0x1F, 0x1A, 0x9B, 0x84, 0xE6, 0x20, 0x1A, 0x86, 0x6B, 0xE1, - 0x21, 0x1A, 0x51, 0x2B, 0xD4, 0x22, 0x1A, 0x47, 0x1F, 0xD2, 0x23, 0x1A, 0xC8, 0xBB, 0xF1, 0x04, 0x1B, 0x6B, 0x49, 0xDA, - 0x05, 0x1B, 0x42, 0x19, 0xD0, 0x06, 0x1B, 0x83, 0x66, 0xE0, 0x07, 0x1B, 0xE5, 0xE0, 0xF8, 0x08, 0x1B, 0xDA, 0xD2, 0xF5, - 0x09, 0x1B, 0xDC, 0xD5, 0xF6, 0x0A, 0x1B, 0xDC, 0xD5, 0xF6, 0x0B, 0x1B, 0xDC, 0xD5, 0xF6, 0x0C, 0x1B, 0xDC, 0xD5, 0xF6, - 0x0D, 0x1B, 0xDE, 0xD7, 0xF6, 0x0E, 0x1B, 0xD5, 0xCD, 0xF4, 0x0F, 0x1B, 0xB9, 0xA9, 0xEE, 0x10, 0x1B, 0xB9, 0xAA, 0xEE, - 0x11, 0x1B, 0xB9, 0xAA, 0xEE, 0x12, 0x1B, 0xB9, 0xAA, 0xEE, 0x13, 0x1B, 0xB9, 0xAA, 0xEE, 0x14, 0x1B, 0xB9, 0xAA, 0xEE, - 0x15, 0x1B, 0xB9, 0xAA, 0xEE, 0x16, 0x1B, 0xB9, 0xAA, 0xEE, 0x17, 0x1B, 0xB9, 0xAA, 0xEE, 0x18, 0x1B, 0x9D, 0x87, 0xE7, - 0x19, 0x1B, 0x96, 0x7E, 0xE5, 0x1A, 0x1B, 0x97, 0x80, 0xE5, 0x1B, 0x1B, 0x97, 0x80, 0xE5, 0x1C, 0x1B, 0x97, 0x80, 0xE5, - 0x1D, 0x1B, 0x97, 0x80, 0xE5, 0x1E, 0x1B, 0x97, 0x80, 0xE5, 0x1F, 0x1B, 0x96, 0x7F, 0xE5, 0x20, 0x1B, 0x9B, 0x85, 0xE6, - 0x21, 0x1B, 0x6B, 0x4B, 0xDA, 0x22, 0x1B, 0x46, 0x1E, 0xD1, 0x23, 0x1B, 0x69, 0x49, 0xDA, 0x03, 0x1C, 0xAE, 0x9B, 0xEB, - 0x04, 0x1C, 0x43, 0x1A, 0xD1, 0x05, 0x1C, 0x58, 0x32, 0xD6, 0x06, 0x1C, 0xCB, 0xC0, 0xF2, 0x07, 0x1C, 0xE0, 0xDA, 0xF7, - 0x08, 0x1C, 0xDB, 0xD4, 0xF6, 0x09, 0x1C, 0xDC, 0xD5, 0xF6, 0x0A, 0x1C, 0xDC, 0xD5, 0xF6, 0x0B, 0x1C, 0xDC, 0xD5, 0xF6, - 0x0C, 0x1C, 0xDC, 0xD5, 0xF6, 0x0D, 0x1C, 0xDB, 0xD4, 0xF6, 0x0E, 0x1C, 0xDF, 0xD8, 0xF7, 0x0F, 0x1C, 0xC9, 0xBD, 0xF2, - 0x10, 0x1C, 0xB6, 0xA7, 0xED, 0x11, 0x1C, 0xBA, 0xAB, 0xEE, 0x12, 0x1C, 0xB9, 0xAA, 0xEE, 0x13, 0x1C, 0xB9, 0xAA, 0xEE, - 0x14, 0x1C, 0xB9, 0xAA, 0xEE, 0x15, 0x1C, 0xB9, 0xAA, 0xEE, 0x16, 0x1C, 0xB9, 0xA9, 0xEE, 0x17, 0x1C, 0xBB, 0xAD, 0xEE, - 0x18, 0x1C, 0xB0, 0x9E, 0xEB, 0x19, 0x1C, 0x96, 0x7E, 0xE4, 0x1A, 0x1C, 0x97, 0x81, 0xE5, 0x1B, 0x1C, 0x97, 0x80, 0xE5, - 0x1C, 0x1C, 0x97, 0x80, 0xE5, 0x1D, 0x1C, 0x97, 0x80, 0xE5, 0x1E, 0x1C, 0x97, 0x80, 0xE5, 0x1F, 0x1C, 0x97, 0x7F, 0xE5, - 0x20, 0x1C, 0x99, 0x82, 0xE5, 0x21, 0x1C, 0x8F, 0x76, 0xE3, 0x22, 0x1C, 0x56, 0x31, 0xD5, 0x23, 0x1C, 0x44, 0x1A, 0xD1, - 0x24, 0x1C, 0xAD, 0x9A, 0xEB, 0x02, 0x1D, 0xEA, 0xE5, 0xFA, 0x03, 0x1D, 0x5A, 0x35, 0xD6, 0x04, 0x1D, 0x44, 0x1B, 0xD1, - 0x05, 0x1D, 0x97, 0x7F, 0xE5, 0x06, 0x1D, 0xE6, 0xE2, 0xF8, 0x07, 0x1D, 0xD9, 0xD2, 0xF5, 0x08, 0x1D, 0xDC, 0xD5, 0xF6, - 0x09, 0x1D, 0xDC, 0xD5, 0xF6, 0x0A, 0x1D, 0xDC, 0xD5, 0xF6, 0x0B, 0x1D, 0xDC, 0xD5, 0xF6, 0x0C, 0x1D, 0xDC, 0xD5, 0xF6, - 0x0D, 0x1D, 0xDC, 0xD5, 0xF6, 0x0E, 0x1D, 0xDC, 0xD5, 0xF6, 0x0F, 0x1D, 0xDB, 0xD3, 0xF6, 0x10, 0x1D, 0xBD, 0xAF, 0xEF, - 0x11, 0x1D, 0xB8, 0xA9, 0xEE, 0x12, 0x1D, 0xB9, 0xAA, 0xEE, 0x13, 0x1D, 0xB9, 0xAA, 0xEE, 0x14, 0x1D, 0xB9, 0xAA, 0xEE, - 0x15, 0x1D, 0xB9, 0xAA, 0xEE, 0x16, 0x1D, 0xB9, 0xAA, 0xEE, 0x17, 0x1D, 0xB9, 0xA9, 0xEE, 0x18, 0x1D, 0xBB, 0xAC, 0xEF, - 0x19, 0x1D, 0xA2, 0x8D, 0xE8, 0x1A, 0x1D, 0x95, 0x7D, 0xE4, 0x1B, 0x1D, 0x97, 0x81, 0xE5, 0x1C, 0x1D, 0x97, 0x80, 0xE5, - 0x1D, 0x1D, 0x97, 0x80, 0xE5, 0x1E, 0x1D, 0x97, 0x80, 0xE5, 0x1F, 0x1D, 0x97, 0x80, 0xE5, 0x20, 0x1D, 0x96, 0x7F, 0xE5, - 0x21, 0x1D, 0x9C, 0x86, 0xE6, 0x22, 0x1D, 0x75, 0x56, 0xDD, 0x23, 0x1D, 0x4A, 0x22, 0xD2, 0x24, 0x1D, 0x59, 0x34, 0xD6, - 0x25, 0x1D, 0xEA, 0xE6, 0xFA, 0x02, 0x1E, 0xA8, 0x94, 0xEA, 0x03, 0x1E, 0x3F, 0x16, 0xD0, 0x04, 0x1E, 0x67, 0x45, 0xD9, - 0x05, 0x1E, 0xD7, 0xCF, 0xF5, 0x06, 0x1E, 0xDD, 0xD7, 0xF6, 0x07, 0x1E, 0xDB, 0xD4, 0xF6, 0x08, 0x1E, 0xDC, 0xD5, 0xF6, - 0x09, 0x1E, 0xDC, 0xD5, 0xF6, 0x0A, 0x1E, 0xDC, 0xD5, 0xF6, 0x0B, 0x1E, 0xDC, 0xD5, 0xF6, 0x0C, 0x1E, 0xDC, 0xD5, 0xF6, - 0x0D, 0x1E, 0xDC, 0xD5, 0xF6, 0x0E, 0x1E, 0xDB, 0xD4, 0xF6, 0x0F, 0x1E, 0xDE, 0xD8, 0xF6, 0x10, 0x1E, 0xD0, 0xC7, 0xF3, - 0x11, 0x1E, 0xB7, 0xA8, 0xEE, 0x12, 0x1E, 0xB9, 0xAB, 0xEE, 0x13, 0x1E, 0xB9, 0xAA, 0xEE, 0x14, 0x1E, 0xB9, 0xAA, 0xEE, - 0x15, 0x1E, 0xB9, 0xAA, 0xEE, 0x16, 0x1E, 0xB9, 0xAA, 0xEE, 0x17, 0x1E, 0xB9, 0xAA, 0xEE, 0x18, 0x1E, 0xBA, 0xAB, 0xEE, - 0x19, 0x1E, 0xB4, 0xA3, 0xED, 0x1A, 0x1E, 0x98, 0x81, 0xE5, 0x1B, 0x1E, 0x97, 0x80, 0xE5, 0x1C, 0x1E, 0x97, 0x80, 0xE5, - 0x1D, 0x1E, 0x97, 0x80, 0xE5, 0x1E, 0x1E, 0x97, 0x80, 0xE5, 0x1F, 0x1E, 0x97, 0x80, 0xE5, 0x20, 0x1E, 0x97, 0x80, 0xE5, - 0x21, 0x1E, 0x98, 0x81, 0xE5, 0x22, 0x1E, 0x95, 0x7D, 0xE4, 0x23, 0x1E, 0x5E, 0x3A, 0xD7, 0x24, 0x1E, 0x41, 0x18, 0xD0, - 0x25, 0x1E, 0xA8, 0x93, 0xE9, 0x02, 0x1F, 0x8F, 0x77, 0xE3, 0x03, 0x1F, 0x3C, 0x11, 0xCF, 0x04, 0x1F, 0x92, 0x78, 0xE4, - 0x05, 0x1F, 0xE9, 0xE6, 0xF9, 0x06, 0x1F, 0xD8, 0xCF, 0xF5, 0x07, 0x1F, 0xDB, 0xD4, 0xF6, 0x08, 0x1F, 0xDB, 0xD4, 0xF6, - 0x09, 0x1F, 0xDB, 0xD4, 0xF6, 0x0A, 0x1F, 0xDB, 0xD4, 0xF6, 0x0B, 0x1F, 0xDB, 0xD4, 0xF6, 0x0C, 0x1F, 0xDB, 0xD4, 0xF6, - 0x0D, 0x1F, 0xDB, 0xD4, 0xF6, 0x0E, 0x1F, 0xDB, 0xD4, 0xF6, 0x0F, 0x1F, 0xDB, 0xD4, 0xF6, 0x10, 0x1F, 0xDB, 0xD4, 0xF6, - 0x11, 0x1F, 0xBB, 0xAD, 0xEF, 0x12, 0x1F, 0xB7, 0xA8, 0xEE, 0x13, 0x1F, 0xB8, 0xA9, 0xEE, 0x14, 0x1F, 0xB8, 0xA9, 0xEE, - 0x15, 0x1F, 0xB8, 0xA9, 0xEE, 0x16, 0x1F, 0xB8, 0xA9, 0xEE, 0x17, 0x1F, 0xB8, 0xA9, 0xEE, 0x18, 0x1F, 0xB8, 0xA9, 0xEE, - 0x19, 0x1F, 0xBB, 0xAD, 0xEE, 0x1A, 0x1F, 0x9F, 0x8A, 0xE7, 0x1B, 0x1F, 0x94, 0x7D, 0xE5, 0x1C, 0x1F, 0x96, 0x80, 0xE5, - 0x1D, 0x1F, 0x96, 0x7F, 0xE5, 0x1E, 0x1F, 0x96, 0x7F, 0xE5, 0x1F, 0x1F, 0x96, 0x7F, 0xE5, 0x20, 0x1F, 0x96, 0x7F, 0xE5, - 0x21, 0x1F, 0x95, 0x7E, 0xE5, 0x22, 0x1F, 0x9E, 0x88, 0xE6, 0x23, 0x1F, 0x73, 0x54, 0xDC, 0x24, 0x1F, 0x42, 0x18, 0xD0, - 0x25, 0x1F, 0x8E, 0x76, 0xE3, 0x02, 0x20, 0xA0, 0x89, 0xE8, 0x03, 0x20, 0x3F, 0x15, 0xD0, 0x04, 0x20, 0x6E, 0x4F, 0xDB, - 0x05, 0x20, 0xD6, 0xCE, 0xF5, 0x06, 0x20, 0xE3, 0xDE, 0xF8, 0x07, 0x20, 0xE1, 0xDB, 0xF7, 0x08, 0x20, 0xE2, 0xDC, 0xF7, - 0x09, 0x20, 0xE2, 0xDC, 0xF7, 0x0A, 0x20, 0xE2, 0xDC, 0xF7, 0x0B, 0x20, 0xE2, 0xDC, 0xF7, 0x0C, 0x20, 0xE2, 0xDC, 0xF7, - 0x0D, 0x20, 0xE2, 0xDC, 0xF7, 0x0E, 0x20, 0xE1, 0xDB, 0xF7, 0x0F, 0x20, 0xE3, 0xDD, 0xF8, 0x10, 0x20, 0xD5, 0xCD, 0xF5, - 0x11, 0x20, 0xBD, 0xAE, 0xEF, 0x12, 0x20, 0xBD, 0xAF, 0xEF, 0x13, 0x20, 0xBD, 0xAF, 0xEF, 0x14, 0x20, 0xBD, 0xAF, 0xEF, - 0x15, 0x20, 0xBD, 0xAF, 0xEF, 0x16, 0x20, 0xBD, 0xAF, 0xEF, 0x17, 0x20, 0xBD, 0xAF, 0xEF, 0x18, 0x20, 0xBE, 0xB0, 0xEF, - 0x19, 0x20, 0xB7, 0xA7, 0xED, 0x1A, 0x20, 0x9C, 0x86, 0xE6, 0x1B, 0x20, 0x99, 0x83, 0xE6, 0x1C, 0x20, 0x9A, 0x83, 0xE6, - 0x1D, 0x20, 0x9A, 0x83, 0xE6, 0x1E, 0x20, 0x9A, 0x83, 0xE6, 0x1F, 0x20, 0x9A, 0x83, 0xE6, 0x20, 0x20, 0x9A, 0x83, 0xE6, - 0x21, 0x20, 0x9A, 0x84, 0xE6, 0x22, 0x20, 0x94, 0x7C, 0xE4, 0x23, 0x20, 0x62, 0x40, 0xD8, 0x24, 0x20, 0x42, 0x18, 0xD0, - 0x25, 0x20, 0xA0, 0x89, 0xE8, 0x02, 0x21, 0xE1, 0xD9, 0xF8, 0x03, 0x21, 0x50, 0x29, 0xD4, 0x04, 0x21, 0x47, 0x20, 0xD1, - 0x05, 0x21, 0x63, 0x41, 0xD8, 0x06, 0x21, 0x77, 0x5A, 0xDE, 0x07, 0x21, 0x75, 0x57, 0xDD, 0x08, 0x21, 0x75, 0x57, 0xDD, - 0x09, 0x21, 0x75, 0x57, 0xDD, 0x0A, 0x21, 0x75, 0x57, 0xDD, 0x0B, 0x21, 0x75, 0x57, 0xDD, 0x0C, 0x21, 0x75, 0x57, 0xDD, - 0x0D, 0x21, 0x75, 0x57, 0xDD, 0x0E, 0x21, 0x75, 0x58, 0xDD, 0x0F, 0x21, 0x75, 0x57, 0xDD, 0x10, 0x21, 0x6E, 0x4C, 0xDB, - 0x11, 0x21, 0x6D, 0x4B, 0xDB, 0x12, 0x21, 0x6E, 0x4B, 0xDB, 0x13, 0x21, 0x6D, 0x4B, 0xDB, 0x14, 0x21, 0x6D, 0x4B, 0xDB, - 0x15, 0x21, 0x6D, 0x4B, 0xDB, 0x16, 0x21, 0x6D, 0x4B, 0xDB, 0x17, 0x21, 0x6D, 0x4B, 0xDB, 0x18, 0x21, 0x6E, 0x4C, 0xDB, - 0x19, 0x21, 0x67, 0x45, 0xDA, 0x1A, 0x21, 0x64, 0x42, 0xD9, 0x1B, 0x21, 0x66, 0x43, 0xD9, 0x1C, 0x21, 0x65, 0x43, 0xD9, - 0x1D, 0x21, 0x65, 0x43, 0xD9, 0x1E, 0x21, 0x65, 0x43, 0xD9, 0x1F, 0x21, 0x65, 0x43, 0xD9, 0x20, 0x21, 0x65, 0x43, 0xD9, - 0x21, 0x21, 0x66, 0x44, 0xD9, 0x22, 0x21, 0x5C, 0x39, 0xD7, 0x23, 0x21, 0x4A, 0x23, 0xD2, 0x24, 0x21, 0x4F, 0x28, 0xD4, - 0x25, 0x21, 0xE0, 0xD9, 0xF8, 0x03, 0x22, 0xC3, 0xB6, 0xF0, 0x04, 0x22, 0x57, 0x31, 0xD5, 0x05, 0x22, 0x42, 0x18, 0xD0, - 0x06, 0x22, 0x3D, 0x12, 0xCF, 0x07, 0x22, 0x3D, 0x12, 0xCF, 0x08, 0x22, 0x3D, 0x12, 0xCF, 0x09, 0x22, 0x3D, 0x12, 0xCF, - 0x0A, 0x22, 0x3D, 0x12, 0xCF, 0x0B, 0x22, 0x3D, 0x12, 0xCF, 0x0C, 0x22, 0x3D, 0x12, 0xCF, 0x0D, 0x22, 0x3D, 0x12, 0xCF, - 0x0E, 0x22, 0x3D, 0x12, 0xCF, 0x0F, 0x22, 0x3D, 0x12, 0xCF, 0x10, 0x22, 0x3F, 0x14, 0xCF, 0x11, 0x22, 0x3F, 0x15, 0xD0, - 0x12, 0x22, 0x3F, 0x15, 0xD0, 0x13, 0x22, 0x3F, 0x15, 0xD0, 0x14, 0x22, 0x3F, 0x15, 0xD0, 0x15, 0x22, 0x3F, 0x15, 0xD0, - 0x16, 0x22, 0x3F, 0x15, 0xD0, 0x17, 0x22, 0x3F, 0x15, 0xD0, 0x18, 0x22, 0x3F, 0x14, 0xD0, 0x19, 0x22, 0x40, 0x16, 0xD0, - 0x1A, 0x22, 0x41, 0x17, 0xD0, 0x1B, 0x22, 0x41, 0x17, 0xD0, 0x1C, 0x22, 0x41, 0x17, 0xD0, 0x1D, 0x22, 0x41, 0x17, 0xD0, - 0x1E, 0x22, 0x41, 0x17, 0xD0, 0x1F, 0x22, 0x41, 0x17, 0xD0, 0x20, 0x22, 0x41, 0x17, 0xD0, 0x21, 0x22, 0x41, 0x17, 0xD0, - 0x22, 0x22, 0x44, 0x1A, 0xD1, 0x23, 0x22, 0x57, 0x31, 0xD5, 0x24, 0x22, 0xC3, 0xB6, 0xF0, 0x04, 0x23, 0xED, 0xE8, 0xFB, - 0x05, 0x23, 0xBC, 0xAD, 0xEE, 0x06, 0x23, 0xB2, 0xA1, 0xEC, 0x07, 0x23, 0xB4, 0xA2, 0xEC, 0x08, 0x23, 0xB3, 0xA2, 0xEC, - 0x09, 0x23, 0xB3, 0xA2, 0xEC, 0x0A, 0x23, 0xB3, 0xA2, 0xEC, 0x0B, 0x23, 0xB3, 0xA2, 0xEC, 0x0C, 0x23, 0xB3, 0xA2, 0xEC, - 0x0D, 0x23, 0xB3, 0xA2, 0xEC, 0x0E, 0x23, 0xB3, 0xA2, 0xEC, 0x0F, 0x23, 0xB3, 0xA2, 0xEC, 0x10, 0x23, 0xB3, 0xA1, 0xEC, - 0x11, 0x23, 0xB3, 0xA1, 0xEC, 0x12, 0x23, 0xB3, 0xA1, 0xEC, 0x13, 0x23, 0xB3, 0xA1, 0xEC, 0x14, 0x23, 0xB3, 0xA1, 0xEC, - 0x15, 0x23, 0xB3, 0xA1, 0xEC, 0x16, 0x23, 0xB3, 0xA1, 0xEC, 0x17, 0x23, 0xB3, 0xA1, 0xEC, 0x18, 0x23, 0xB3, 0xA1, 0xEC, - 0x19, 0x23, 0xB3, 0xA1, 0xEC, 0x1A, 0x23, 0xB3, 0xA1, 0xEC, 0x1B, 0x23, 0xB3, 0xA1, 0xEC, 0x1C, 0x23, 0xB3, 0xA1, 0xEC, - 0x1D, 0x23, 0xB3, 0xA1, 0xEC, 0x1E, 0x23, 0xB3, 0xA1, 0xEC, 0x1F, 0x23, 0xB3, 0xA1, 0xEC, 0x20, 0x23, 0xB3, 0xA1, 0xEC, - 0x21, 0x23, 0xB2, 0xA0, 0xEC, 0x22, 0x23, 0xBC, 0xAC, 0xEE, 0x23, 0x23, 0xED, 0xE9, 0xFB + 0x12, 0x03, 0x0E, 0x0E, 0x0D, 0x13, 0x03, 0x23, 0x1E, 0x3A, 0x14, 0x03, 0x23, 0x1D, 0x3A, 0x15, 0x03, 0x0D, 0x0E, 0x0D, + 0x11, 0x04, 0x2D, 0x23, 0x4F, 0x12, 0x04, 0x58, 0x39, 0xC7, 0x13, 0x04, 0x5A, 0x35, 0xDF, 0x14, 0x04, 0x5A, 0x34, 0xDF, + 0x15, 0x04, 0x58, 0x38, 0xC7, 0x16, 0x04, 0x2D, 0x23, 0x4F, 0x10, 0x05, 0x26, 0x1F, 0x3F, 0x11, 0x05, 0x60, 0x38, 0xE5, + 0x12, 0x05, 0x4E, 0x26, 0xD8, 0x13, 0x05, 0x4F, 0x29, 0xD1, 0x14, 0x05, 0x4F, 0x29, 0xD1, 0x15, 0x05, 0x4E, 0x26, 0xD8, + 0x16, 0x05, 0x60, 0x38, 0xE5, 0x17, 0x05, 0x26, 0x1E, 0x3F, 0x0F, 0x06, 0x0A, 0x0B, 0x09, 0x10, 0x06, 0x55, 0x38, 0xB9, + 0x11, 0x06, 0x4F, 0x27, 0xDA, 0x12, 0x06, 0x56, 0x33, 0xD2, 0x13, 0x06, 0x6F, 0x4F, 0xDC, 0x14, 0x06, 0x6F, 0x4F, 0xDC, + 0x15, 0x06, 0x56, 0x33, 0xD2, 0x16, 0x06, 0x4F, 0x26, 0xDA, 0x17, 0x06, 0x55, 0x37, 0xB9, 0x18, 0x06, 0x09, 0x0A, 0x09, + 0x0F, 0x07, 0x35, 0x27, 0x63, 0x10, 0x07, 0x5A, 0x31, 0xE6, 0x11, 0x07, 0x4E, 0x28, 0xCF, 0x12, 0x07, 0x6D, 0x4C, 0xDB, + 0x13, 0x07, 0x77, 0x58, 0xDD, 0x14, 0x07, 0x77, 0x58, 0xDD, 0x15, 0x07, 0x6D, 0x4C, 0xDB, 0x16, 0x07, 0x4E, 0x28, 0xD0, + 0x17, 0x07, 0x5A, 0x31, 0xE5, 0x18, 0x07, 0x38, 0x2A, 0x66, 0x0E, 0x08, 0x15, 0x13, 0x1A, 0x0F, 0x08, 0x58, 0x36, 0xCC, + 0x10, 0x08, 0x4D, 0x25, 0xD5, 0x11, 0x08, 0x5E, 0x3B, 0xD7, 0x12, 0x08, 0x76, 0x58, 0xDE, 0x13, 0x08, 0x73, 0x54, 0xDD, + 0x14, 0x08, 0x73, 0x54, 0xDD, 0x15, 0x08, 0x76, 0x58, 0xDE, 0x16, 0x08, 0x5E, 0x3B, 0xD7, 0x17, 0x08, 0x4C, 0x25, 0xD5, + 0x18, 0x08, 0x5C, 0x39, 0xCF, 0x19, 0x08, 0x16, 0x15, 0x1B, 0x0E, 0x09, 0x49, 0x35, 0x8B, 0x0F, 0x09, 0x56, 0x2D, 0xE4, + 0x10, 0x09, 0x51, 0x2C, 0xD1, 0x11, 0x09, 0x71, 0x51, 0xDC, 0x12, 0x09, 0x75, 0x56, 0xDD, 0x13, 0x09, 0x74, 0x55, 0xDD, + 0x14, 0x09, 0x74, 0x55, 0xDD, 0x15, 0x09, 0x75, 0x56, 0xDD, 0x16, 0x09, 0x71, 0x51, 0xDC, 0x17, 0x09, 0x51, 0x2C, 0xD1, + 0x18, 0x09, 0x56, 0x2D, 0xE3, 0x19, 0x09, 0x45, 0x30, 0x87, 0x0D, 0x0A, 0x21, 0x1D, 0x31, 0x0E, 0x0A, 0x5D, 0x37, 0xDD, + 0x0F, 0x0A, 0x4B, 0x24, 0xD1, 0x10, 0x0A, 0x64, 0x42, 0xD9, 0x11, 0x0A, 0x77, 0x58, 0xDE, 0x12, 0x0A, 0x73, 0x54, 0xDD, + 0x13, 0x0A, 0x74, 0x55, 0xDD, 0x14, 0x0A, 0x74, 0x55, 0xDD, 0x15, 0x0A, 0x73, 0x54, 0xDD, 0x16, 0x0A, 0x77, 0x58, 0xDE, + 0x17, 0x0A, 0x64, 0x42, 0xD9, 0x18, 0x0A, 0x4C, 0x25, 0xD2, 0x19, 0x0A, 0x5B, 0x35, 0xDA, 0x1A, 0x0A, 0x1F, 0x1A, 0x2F, + 0x0D, 0x0B, 0x4F, 0x35, 0xA5, 0x0E, 0x0B, 0x52, 0x29, 0xDF, 0x0F, 0x0B, 0x55, 0x31, 0xD3, 0x10, 0x0B, 0x74, 0x55, 0xDD, + 0x11, 0x0B, 0x74, 0x55, 0xDD, 0x12, 0x0B, 0x74, 0x55, 0xDD, 0x13, 0x0B, 0x74, 0x55, 0xDD, 0x14, 0x0B, 0x74, 0x55, 0xDD, + 0x15, 0x0B, 0x74, 0x55, 0xDD, 0x16, 0x0B, 0x74, 0x55, 0xDD, 0x17, 0x0B, 0x74, 0x55, 0xDD, 0x18, 0x0B, 0x56, 0x31, 0xD3, + 0x19, 0x0B, 0x51, 0x29, 0xDE, 0x1A, 0x0B, 0x54, 0x39, 0xAA, 0x1B, 0x0B, 0x07, 0x07, 0x04, 0x0C, 0x0C, 0x2A, 0x20, 0x49, + 0x0D, 0x0C, 0x59, 0x31, 0xE1, 0x0E, 0x0C, 0x4D, 0x27, 0xD1, 0x0F, 0x0C, 0x69, 0x47, 0xDA, 0x10, 0x0C, 0x77, 0x58, 0xDE, + 0x11, 0x0C, 0x73, 0x54, 0xDD, 0x12, 0x0C, 0x74, 0x55, 0xDD, 0x13, 0x0C, 0x74, 0x55, 0xDD, 0x14, 0x0C, 0x74, 0x55, 0xDD, + 0x15, 0x0C, 0x74, 0x55, 0xDD, 0x16, 0x0C, 0x73, 0x54, 0xDD, 0x17, 0x0C, 0x76, 0x58, 0xDE, 0x18, 0x0C, 0x69, 0x48, 0xDA, + 0x19, 0x0C, 0x4C, 0x26, 0xD0, 0x1A, 0x0C, 0x5D, 0x34, 0xE4, 0x1B, 0x0C, 0x2E, 0x23, 0x4D, 0x0B, 0x0D, 0x09, 0x09, 0x09, + 0x0C, 0x0D, 0x56, 0x38, 0xBF, 0x0D, 0x0D, 0x4F, 0x27, 0xDA, 0x0E, 0x0D, 0x5A, 0x35, 0xD5, 0x0F, 0x0D, 0x78, 0x59, 0xDE, + 0x10, 0x0D, 0x72, 0x53, 0xDD, 0x11, 0x0D, 0x74, 0x55, 0xDD, 0x12, 0x0D, 0x74, 0x55, 0xDD, 0x13, 0x0D, 0x74, 0x55, 0xDD, + 0x14, 0x0D, 0x74, 0x55, 0xDD, 0x15, 0x0D, 0x74, 0x55, 0xDD, 0x16, 0x0D, 0x74, 0x55, 0xDD, 0x17, 0x0D, 0x74, 0x54, 0xDD, + 0x18, 0x0D, 0x76, 0x57, 0xDD, 0x19, 0x0D, 0x5A, 0x37, 0xD5, 0x1A, 0x0D, 0x4E, 0x26, 0xD9, 0x1B, 0x0D, 0x55, 0x36, 0xBE, + 0x1C, 0x0D, 0x09, 0x09, 0x09, 0x0B, 0x0E, 0x3A, 0x2B, 0x6D, 0x0C, 0x0E, 0x58, 0x2F, 0xE4, 0x0D, 0x0E, 0x4E, 0x29, 0xD0, + 0x0E, 0x0E, 0x84, 0x69, 0xE1, 0x0F, 0x0E, 0x98, 0x81, 0xE5, 0x10, 0x0E, 0x85, 0x6A, 0xE1, 0x11, 0x0E, 0x72, 0x53, 0xDD, + 0x12, 0x0E, 0x74, 0x56, 0xDD, 0x13, 0x0E, 0x74, 0x55, 0xDD, 0x14, 0x0E, 0x74, 0x55, 0xDD, 0x15, 0x0E, 0x74, 0x55, 0xDD, + 0x16, 0x0E, 0x74, 0x55, 0xDD, 0x17, 0x0E, 0x74, 0x55, 0xDD, 0x18, 0x0E, 0x76, 0x57, 0xDD, 0x19, 0x0E, 0x6E, 0x4D, 0xDB, + 0x1A, 0x0E, 0x4F, 0x2A, 0xD0, 0x1B, 0x0E, 0x58, 0x2F, 0xE4, 0x1C, 0x0E, 0x3A, 0x2B, 0x6D, 0x0A, 0x0F, 0x15, 0x13, 0x1D, + 0x0B, 0x0F, 0x5C, 0x39, 0xD3, 0x0C, 0x0F, 0x4A, 0x22, 0xD4, 0x0D, 0x0F, 0x6F, 0x4F, 0xDA, 0x0E, 0x0F, 0x9D, 0x87, 0xE6, + 0x0F, 0x0F, 0x97, 0x80, 0xE5, 0x10, 0x0F, 0x99, 0x83, 0xE5, 0x11, 0x0F, 0x7C, 0x5F, 0xDF, 0x12, 0x0F, 0x72, 0x53, 0xDD, + 0x13, 0x0F, 0x74, 0x56, 0xDD, 0x14, 0x0F, 0x74, 0x55, 0xDD, 0x15, 0x0F, 0x74, 0x55, 0xDD, 0x16, 0x0F, 0x74, 0x55, 0xDD, + 0x17, 0x0F, 0x74, 0x55, 0xDD, 0x18, 0x0F, 0x73, 0x54, 0xDD, 0x19, 0x0F, 0x77, 0x58, 0xDE, 0x1A, 0x0F, 0x60, 0x3D, 0xD7, + 0x1B, 0x0F, 0x4C, 0x26, 0xD4, 0x1C, 0x0F, 0x5C, 0x38, 0xD3, 0x1D, 0x0F, 0x15, 0x13, 0x1D, 0x0A, 0x10, 0x47, 0x32, 0x8E, + 0x0B, 0x10, 0x54, 0x2B, 0xE1, 0x0C, 0x10, 0x55, 0x30, 0xD2, 0x0D, 0x10, 0x92, 0x7A, 0xE4, 0x0E, 0x10, 0x98, 0x82, 0xE5, + 0x0F, 0x10, 0x96, 0x7F, 0xE5, 0x10, 0x10, 0x99, 0x82, 0xE5, 0x11, 0x10, 0x8F, 0x77, 0xE3, 0x12, 0x10, 0x73, 0x54, 0xDD, + 0x13, 0x10, 0x74, 0x55, 0xDD, 0x14, 0x10, 0x74, 0x55, 0xDD, 0x15, 0x10, 0x74, 0x55, 0xDD, 0x16, 0x10, 0x74, 0x55, 0xDD, + 0x17, 0x10, 0x74, 0x55, 0xDD, 0x18, 0x10, 0x74, 0x55, 0xDD, 0x19, 0x10, 0x75, 0x56, 0xDD, 0x1A, 0x10, 0x71, 0x52, 0xDC, + 0x1B, 0x10, 0x52, 0x2D, 0xD1, 0x1C, 0x10, 0x54, 0x2B, 0xE2, 0x1D, 0x10, 0x47, 0x31, 0x8E, 0x09, 0x11, 0x23, 0x1D, 0x37, + 0x0A, 0x11, 0x5C, 0x36, 0xDD, 0x0B, 0x11, 0x4A, 0x23, 0xD1, 0x0C, 0x11, 0x79, 0x5C, 0xDE, 0x0D, 0x11, 0x9C, 0x86, 0xE6, + 0x0E, 0x11, 0x96, 0x7F, 0xE5, 0x0F, 0x11, 0x97, 0x80, 0xE5, 0x10, 0x11, 0x96, 0x7F, 0xE5, 0x11, 0x11, 0x99, 0x83, 0xE6, + 0x12, 0x11, 0x81, 0x65, 0xE0, 0x13, 0x11, 0x72, 0x52, 0xDC, 0x14, 0x11, 0x75, 0x56, 0xDD, 0x15, 0x11, 0x74, 0x55, 0xDD, + 0x16, 0x11, 0x74, 0x55, 0xDD, 0x17, 0x11, 0x74, 0x55, 0xDD, 0x18, 0x11, 0x74, 0x55, 0xDD, 0x19, 0x11, 0x73, 0x54, 0xDD, + 0x1A, 0x11, 0x77, 0x58, 0xDE, 0x1B, 0x11, 0x65, 0x43, 0xD9, 0x1C, 0x11, 0x4C, 0x26, 0xD1, 0x1D, 0x11, 0x5B, 0x35, 0xDE, + 0x1E, 0x11, 0x20, 0x1A, 0x34, 0x08, 0x12, 0x05, 0x06, 0x02, 0x09, 0x12, 0x54, 0x39, 0xAF, 0x0A, 0x12, 0x50, 0x26, 0xDD, + 0x0B, 0x12, 0x5D, 0x3A, 0xD5, 0x0C, 0x12, 0x98, 0x81, 0xE5, 0x0D, 0x12, 0x97, 0x80, 0xE5, 0x0E, 0x12, 0x97, 0x80, 0xE5, + 0x0F, 0x12, 0x97, 0x80, 0xE5, 0x10, 0x12, 0x97, 0x80, 0xE5, 0x11, 0x12, 0x98, 0x81, 0xE5, 0x12, 0x12, 0x94, 0x7C, 0xE4, + 0x13, 0x12, 0x76, 0x57, 0xDD, 0x14, 0x12, 0x74, 0x54, 0xDD, 0x15, 0x12, 0x74, 0x55, 0xDD, 0x16, 0x12, 0x74, 0x55, 0xDD, + 0x17, 0x12, 0x74, 0x55, 0xDD, 0x18, 0x12, 0x74, 0x55, 0xDD, 0x19, 0x12, 0x74, 0x55, 0xDD, 0x1A, 0x12, 0x74, 0x55, 0xDD, + 0x1B, 0x12, 0x74, 0x55, 0xDD, 0x1C, 0x12, 0x57, 0x32, 0xD3, 0x1D, 0x12, 0x51, 0x29, 0xDD, 0x1E, 0x12, 0x50, 0x34, 0xAB, + 0x1F, 0x12, 0x05, 0x07, 0x03, 0x08, 0x13, 0x35, 0x2A, 0x5A, 0x09, 0x13, 0x5A, 0x32, 0xE3, 0x0A, 0x13, 0x4B, 0x25, 0xCF, + 0x0B, 0x13, 0x84, 0x68, 0xE0, 0x0C, 0x13, 0x9B, 0x85, 0xE6, 0x0D, 0x13, 0x96, 0x7F, 0xE5, 0x0E, 0x13, 0x97, 0x80, 0xE5, + 0x0F, 0x13, 0x97, 0x80, 0xE5, 0x10, 0x13, 0x97, 0x80, 0xE5, 0x11, 0x13, 0x96, 0x7F, 0xE5, 0x12, 0x13, 0x9A, 0x83, 0xE6, + 0x13, 0x13, 0x87, 0x6C, 0xE1, 0x14, 0x13, 0x72, 0x52, 0xDC, 0x15, 0x13, 0x75, 0x56, 0xDD, 0x16, 0x13, 0x74, 0x55, 0xDD, + 0x17, 0x13, 0x74, 0x55, 0xDD, 0x18, 0x13, 0x74, 0x55, 0xDD, 0x19, 0x13, 0x74, 0x55, 0xDD, 0x1A, 0x13, 0x74, 0x54, 0xDD, + 0x1B, 0x13, 0x76, 0x58, 0xDE, 0x1C, 0x13, 0x6A, 0x49, 0xDB, 0x1D, 0x13, 0x4D, 0x27, 0xD0, 0x1E, 0x13, 0x5B, 0x33, 0xE5, + 0x1F, 0x13, 0x31, 0x26, 0x56, 0x07, 0x14, 0x0C, 0x0C, 0x0E, 0x08, 0x14, 0x5B, 0x3A, 0xC7, 0x09, 0x14, 0x4C, 0x24, 0xD7, + 0x0A, 0x14, 0x68, 0x47, 0xD8, 0x0B, 0x14, 0x9B, 0x85, 0xE6, 0x0C, 0x14, 0x96, 0x7F, 0xE5, 0x0D, 0x14, 0x97, 0x80, 0xE5, + 0x0E, 0x14, 0x97, 0x80, 0xE5, 0x0F, 0x14, 0x97, 0x80, 0xE5, 0x10, 0x14, 0x97, 0x80, 0xE5, 0x11, 0x14, 0x97, 0x80, 0xE5, + 0x12, 0x14, 0x97, 0x80, 0xE5, 0x13, 0x14, 0x97, 0x80, 0xE5, 0x14, 0x14, 0x79, 0x5B, 0xDE, 0x15, 0x14, 0x73, 0x54, 0xDD, + 0x16, 0x14, 0x74, 0x56, 0xDD, 0x17, 0x14, 0x74, 0x55, 0xDD, 0x18, 0x14, 0x74, 0x55, 0xDD, 0x19, 0x14, 0x74, 0x55, 0xDD, + 0x1A, 0x14, 0x74, 0x55, 0xDD, 0x1B, 0x14, 0x74, 0x54, 0xDD, 0x1C, 0x14, 0x76, 0x58, 0xDE, 0x1D, 0x14, 0x5C, 0x39, 0xD5, + 0x1E, 0x14, 0x4E, 0x26, 0xD7, 0x1F, 0x14, 0x58, 0x38, 0xC5, 0x20, 0x14, 0x10, 0x0F, 0x11, 0x07, 0x15, 0x3C, 0x2B, 0x74, + 0x08, 0x15, 0x57, 0x2E, 0xE3, 0x09, 0x15, 0x4D, 0x27, 0xD0, 0x0A, 0x15, 0x8A, 0x70, 0xE2, 0x0B, 0x15, 0x97, 0x7F, 0xE5, + 0x0C, 0x15, 0x95, 0x7E, 0xE5, 0x0D, 0x15, 0x96, 0x7F, 0xE5, 0x0E, 0x15, 0x96, 0x7F, 0xE5, 0x0F, 0x15, 0x96, 0x7F, 0xE5, + 0x10, 0x15, 0x96, 0x7F, 0xE5, 0x11, 0x15, 0x96, 0x7F, 0xE5, 0x12, 0x15, 0x95, 0x7E, 0xE5, 0x13, 0x15, 0x98, 0x82, 0xE5, + 0x14, 0x15, 0x8B, 0x71, 0xE2, 0x15, 0x15, 0x71, 0x51, 0xDC, 0x16, 0x15, 0x73, 0x54, 0xDD, 0x17, 0x15, 0x73, 0x54, 0xDD, + 0x18, 0x15, 0x73, 0x54, 0xDD, 0x19, 0x15, 0x73, 0x54, 0xDD, 0x1A, 0x15, 0x73, 0x54, 0xDD, 0x1B, 0x15, 0x73, 0x53, 0xDD, + 0x1C, 0x15, 0x74, 0x55, 0xDD, 0x1D, 0x15, 0x6E, 0x4D, 0xDC, 0x1E, 0x15, 0x4E, 0x29, 0xD0, 0x1F, 0x15, 0x58, 0x30, 0xE5, + 0x20, 0x15, 0x3F, 0x2E, 0x78, 0x06, 0x16, 0x18, 0x14, 0x22, 0x07, 0x16, 0x5B, 0x37, 0xD4, 0x08, 0x16, 0x47, 0x1E, 0xD3, + 0x09, 0x16, 0x85, 0x6A, 0xE0, 0x0A, 0x16, 0xD3, 0xCA, 0xF4, 0x0B, 0x16, 0xC2, 0xB5, 0xF0, 0x0C, 0x16, 0xB0, 0xA0, 0xEC, + 0x0D, 0x16, 0xB2, 0xA1, 0xEC, 0x0E, 0x16, 0xB2, 0xA1, 0xEC, 0x0F, 0x16, 0xB2, 0xA1, 0xEC, 0x10, 0x16, 0xB2, 0xA1, 0xEC, + 0x11, 0x16, 0xB2, 0xA1, 0xEC, 0x12, 0x16, 0xB2, 0xA1, 0xEC, 0x13, 0x16, 0xB1, 0xA0, 0xEC, 0x14, 0x16, 0xB3, 0xA3, 0xED, + 0x15, 0x16, 0x99, 0x82, 0xE6, 0x16, 0x16, 0x8E, 0x74, 0xE3, 0x17, 0x16, 0x90, 0x77, 0xE3, 0x18, 0x16, 0x8F, 0x77, 0xE3, + 0x19, 0x16, 0x8F, 0x77, 0xE3, 0x1A, 0x16, 0x8F, 0x77, 0xE3, 0x1B, 0x16, 0x8F, 0x77, 0xE3, 0x1C, 0x16, 0x8E, 0x76, 0xE3, + 0x1D, 0x16, 0x94, 0x7C, 0xE4, 0x1E, 0x16, 0x70, 0x50, 0xDB, 0x1F, 0x16, 0x4A, 0x23, 0xD3, 0x20, 0x16, 0x59, 0x35, 0xD3, + 0x21, 0x16, 0x18, 0x14, 0x22, 0x06, 0x17, 0x4C, 0x35, 0x98, 0x07, 0x17, 0x52, 0x28, 0xE0, 0x08, 0x17, 0x5C, 0x39, 0xD4, + 0x09, 0x17, 0xD6, 0xCE, 0xF5, 0x0A, 0x17, 0xE2, 0xDC, 0xF7, 0x0B, 0x17, 0xE0, 0xDB, 0xF7, 0x0C, 0x17, 0xC8, 0xBC, 0xF2, + 0x0D, 0x17, 0xB8, 0xA9, 0xEE, 0x0E, 0x17, 0xBB, 0xAD, 0xEE, 0x0F, 0x17, 0xBB, 0xAC, 0xEE, 0x10, 0x17, 0xBB, 0xAC, 0xEE, + 0x11, 0x17, 0xBB, 0xAC, 0xEE, 0x12, 0x17, 0xBB, 0xAC, 0xEE, 0x13, 0x17, 0xBA, 0xAC, 0xEE, 0x14, 0x17, 0xBC, 0xAE, 0xEF, + 0x15, 0x17, 0xB4, 0xA5, 0xED, 0x16, 0x17, 0x98, 0x81, 0xE5, 0x17, 0x17, 0x99, 0x82, 0xE5, 0x18, 0x17, 0x99, 0x82, 0xE5, + 0x19, 0x17, 0x99, 0x82, 0xE5, 0x1A, 0x17, 0x99, 0x82, 0xE5, 0x1B, 0x17, 0x99, 0x82, 0xE5, 0x1C, 0x17, 0x98, 0x82, 0xE5, + 0x1D, 0x17, 0x9A, 0x83, 0xE6, 0x1E, 0x17, 0x94, 0x7D, 0xE4, 0x1F, 0x17, 0x57, 0x32, 0xD2, 0x20, 0x17, 0x53, 0x2A, 0xE1, + 0x21, 0x17, 0x4B, 0x34, 0x98, 0x05, 0x18, 0x25, 0x1D, 0x3C, 0x06, 0x18, 0x5D, 0x37, 0xE0, 0x07, 0x18, 0x45, 0x1D, 0xCF, + 0x08, 0x18, 0xA7, 0x93, 0xE9, 0x09, 0x18, 0xE6, 0xE1, 0xF8, 0x0A, 0x18, 0xD9, 0xD1, 0xF5, 0x0B, 0x18, 0xDC, 0xD5, 0xF6, + 0x0C, 0x18, 0xD9, 0xD2, 0xF5, 0x0D, 0x18, 0xBB, 0xAC, 0xEE, 0x0E, 0x18, 0xB8, 0xA9, 0xEE, 0x0F, 0x18, 0xB9, 0xAA, 0xEE, + 0x10, 0x18, 0xB9, 0xAA, 0xEE, 0x11, 0x18, 0xB9, 0xAA, 0xEE, 0x12, 0x18, 0xB9, 0xAA, 0xEE, 0x13, 0x18, 0xB9, 0xAA, 0xEE, + 0x14, 0x18, 0xB8, 0xA9, 0xEE, 0x15, 0x18, 0xBB, 0xAD, 0xEF, 0x16, 0x18, 0xA5, 0x91, 0xE9, 0x17, 0x18, 0x94, 0x7D, 0xE4, + 0x18, 0x18, 0x97, 0x80, 0xE5, 0x19, 0x18, 0x97, 0x80, 0xE5, 0x1A, 0x18, 0x97, 0x80, 0xE5, 0x1B, 0x18, 0x97, 0x80, 0xE5, + 0x1C, 0x18, 0x97, 0x80, 0xE5, 0x1D, 0x18, 0x95, 0x7E, 0xE5, 0x1E, 0x18, 0x9C, 0x86, 0xE6, 0x1F, 0x18, 0x7D, 0x60, 0xDE, + 0x20, 0x18, 0x4A, 0x23, 0xD0, 0x21, 0x18, 0x5C, 0x35, 0xE0, 0x22, 0x18, 0x27, 0x20, 0x3F, 0x04, 0x19, 0x0A, 0x0B, 0x08, + 0x05, 0x19, 0x55, 0x39, 0xB5, 0x06, 0x19, 0x4B, 0x21, 0xDA, 0x07, 0x19, 0x6E, 0x4E, 0xD9, 0x08, 0x19, 0xDF, 0xD9, 0xF7, + 0x09, 0x19, 0xDB, 0xD4, 0xF6, 0x0A, 0x19, 0xDC, 0xD5, 0xF6, 0x0B, 0x19, 0xDB, 0xD4, 0xF6, 0x0C, 0x19, 0xDE, 0xD8, 0xF7, + 0x0D, 0x19, 0xCE, 0xC4, 0xF3, 0x0E, 0x19, 0xB7, 0xA7, 0xED, 0x0F, 0x19, 0xB9, 0xAB, 0xEE, 0x10, 0x19, 0xB9, 0xAA, 0xEE, + 0x11, 0x19, 0xB9, 0xAA, 0xEE, 0x12, 0x19, 0xB9, 0xAA, 0xEE, 0x13, 0x19, 0xB9, 0xAA, 0xEE, 0x14, 0x19, 0xB9, 0xAA, 0xEE, + 0x15, 0x19, 0xBA, 0xAB, 0xEE, 0x16, 0x19, 0xB7, 0xA7, 0xED, 0x17, 0x19, 0x99, 0x83, 0xE6, 0x18, 0x19, 0x96, 0x7F, 0xE5, + 0x19, 0x19, 0x97, 0x80, 0xE5, 0x1A, 0x19, 0x97, 0x80, 0xE5, 0x1B, 0x19, 0x97, 0x80, 0xE5, 0x1C, 0x19, 0x97, 0x80, 0xE5, + 0x1D, 0x19, 0x97, 0x80, 0xE5, 0x1E, 0x19, 0x97, 0x80, 0xE5, 0x1F, 0x19, 0x99, 0x82, 0xE5, 0x20, 0x19, 0x5F, 0x3D, 0xD6, + 0x21, 0x19, 0x4E, 0x25, 0xDA, 0x22, 0x19, 0x59, 0x3C, 0xB9, 0x23, 0x19, 0x0B, 0x0B, 0x09, 0x04, 0x1A, 0x3B, 0x2E, 0x65, + 0x05, 0x1A, 0x5C, 0x34, 0xE6, 0x06, 0x1A, 0x4A, 0x22, 0xCE, 0x07, 0x1A, 0xBB, 0xAC, 0xEE, 0x08, 0x1A, 0xE3, 0xDE, 0xF8, + 0x09, 0x1A, 0xDA, 0xD3, 0xF6, 0x0A, 0x1A, 0xDC, 0xD5, 0xF6, 0x0B, 0x1A, 0xDC, 0xD5, 0xF6, 0x0C, 0x1A, 0xDC, 0xD5, 0xF6, + 0x0D, 0x1A, 0xDD, 0xD6, 0xF6, 0x0E, 0x1A, 0xC1, 0xB4, 0xF0, 0x0F, 0x1A, 0xB7, 0xA8, 0xEE, 0x10, 0x1A, 0xB9, 0xAB, 0xEE, + 0x11, 0x1A, 0xB9, 0xAA, 0xEE, 0x12, 0x1A, 0xB9, 0xAA, 0xEE, 0x13, 0x1A, 0xB9, 0xAA, 0xEE, 0x14, 0x1A, 0xB9, 0xAA, 0xEE, + 0x15, 0x1A, 0xB8, 0xA9, 0xEE, 0x16, 0x1A, 0xBB, 0xAD, 0xEF, 0x17, 0x1A, 0xAA, 0x98, 0xEA, 0x18, 0x1A, 0x95, 0x7D, 0xE4, + 0x19, 0x1A, 0x98, 0x81, 0xE5, 0x1A, 0x1A, 0x97, 0x80, 0xE5, 0x1B, 0x1A, 0x97, 0x80, 0xE5, 0x1C, 0x1A, 0x97, 0x80, 0xE5, + 0x1D, 0x1A, 0x97, 0x80, 0xE5, 0x1E, 0x1A, 0x96, 0x7F, 0xE5, 0x1F, 0x1A, 0x9B, 0x84, 0xE6, 0x20, 0x1A, 0x86, 0x6C, 0xE1, + 0x21, 0x1A, 0x4C, 0x26, 0xCF, 0x22, 0x1A, 0x5B, 0x33, 0xE6, 0x23, 0x1A, 0x38, 0x2B, 0x61, 0x03, 0x1B, 0x13, 0x12, 0x16, + 0x04, 0x1B, 0x5E, 0x3C, 0xCE, 0x05, 0x1B, 0x46, 0x1C, 0xD4, 0x06, 0x1B, 0x82, 0x65, 0xDF, 0x07, 0x1B, 0xE5, 0xE0, 0xF8, + 0x08, 0x1B, 0xDA, 0xD2, 0xF5, 0x09, 0x1B, 0xDC, 0xD5, 0xF6, 0x0A, 0x1B, 0xDC, 0xD5, 0xF6, 0x0B, 0x1B, 0xDC, 0xD5, 0xF6, + 0x0C, 0x1B, 0xDC, 0xD5, 0xF6, 0x0D, 0x1B, 0xDE, 0xD7, 0xF6, 0x0E, 0x1B, 0xD5, 0xCD, 0xF4, 0x0F, 0x1B, 0xB9, 0xA9, 0xEE, + 0x10, 0x1B, 0xB9, 0xAA, 0xEE, 0x11, 0x1B, 0xB9, 0xAA, 0xEE, 0x12, 0x1B, 0xB9, 0xAA, 0xEE, 0x13, 0x1B, 0xB9, 0xAA, 0xEE, + 0x14, 0x1B, 0xB9, 0xAA, 0xEE, 0x15, 0x1B, 0xB9, 0xAA, 0xEE, 0x16, 0x1B, 0xB9, 0xAA, 0xEE, 0x17, 0x1B, 0xB9, 0xAA, 0xEE, + 0x18, 0x1B, 0x9D, 0x87, 0xE7, 0x19, 0x1B, 0x96, 0x7E, 0xE5, 0x1A, 0x1B, 0x97, 0x80, 0xE5, 0x1B, 0x1B, 0x97, 0x80, 0xE5, + 0x1C, 0x1B, 0x97, 0x80, 0xE5, 0x1D, 0x1B, 0x97, 0x80, 0xE5, 0x1E, 0x1B, 0x97, 0x80, 0xE5, 0x1F, 0x1B, 0x96, 0x7F, 0xE5, + 0x20, 0x1B, 0x9B, 0x85, 0xE6, 0x21, 0x1B, 0x6A, 0x49, 0xD9, 0x22, 0x1B, 0x4B, 0x22, 0xD6, 0x23, 0x1B, 0x59, 0x39, 0xCA, + 0x24, 0x1B, 0x0F, 0x0F, 0x13, 0x03, 0x1C, 0x45, 0x32, 0x82, 0x04, 0x1C, 0x55, 0x2C, 0xE3, 0x05, 0x1C, 0x53, 0x2E, 0xD1, + 0x06, 0x1C, 0xCC, 0xC0, 0xF2, 0x07, 0x1C, 0xE0, 0xDA, 0xF7, 0x08, 0x1C, 0xDB, 0xD4, 0xF6, 0x09, 0x1C, 0xDC, 0xD5, 0xF6, + 0x0A, 0x1C, 0xDC, 0xD5, 0xF6, 0x0B, 0x1C, 0xDC, 0xD5, 0xF6, 0x0C, 0x1C, 0xDC, 0xD5, 0xF6, 0x0D, 0x1C, 0xDB, 0xD4, 0xF6, + 0x0E, 0x1C, 0xDF, 0xD8, 0xF7, 0x0F, 0x1C, 0xC9, 0xBD, 0xF2, 0x10, 0x1C, 0xB6, 0xA7, 0xED, 0x11, 0x1C, 0xBA, 0xAB, 0xEE, + 0x12, 0x1C, 0xB9, 0xAA, 0xEE, 0x13, 0x1C, 0xB9, 0xAA, 0xEE, 0x14, 0x1C, 0xB9, 0xAA, 0xEE, 0x15, 0x1C, 0xB9, 0xAA, 0xEE, + 0x16, 0x1C, 0xB9, 0xA9, 0xEE, 0x17, 0x1C, 0xBB, 0xAD, 0xEE, 0x18, 0x1C, 0xB0, 0x9E, 0xEB, 0x19, 0x1C, 0x96, 0x7E, 0xE4, + 0x1A, 0x1C, 0x97, 0x81, 0xE5, 0x1B, 0x1C, 0x97, 0x80, 0xE5, 0x1C, 0x1C, 0x97, 0x80, 0xE5, 0x1D, 0x1C, 0x97, 0x80, 0xE5, + 0x1E, 0x1C, 0x97, 0x80, 0xE5, 0x1F, 0x1C, 0x97, 0x7F, 0xE5, 0x20, 0x1C, 0x99, 0x82, 0xE5, 0x21, 0x1C, 0x8F, 0x76, 0xE3, + 0x22, 0x1C, 0x51, 0x2D, 0xD1, 0x23, 0x1C, 0x56, 0x2C, 0xE3, 0x24, 0x1C, 0x43, 0x2F, 0x80, 0x02, 0x1D, 0x20, 0x1C, 0x2F, + 0x03, 0x1D, 0x5B, 0x36, 0xD7, 0x04, 0x1D, 0x44, 0x1B, 0xD1, 0x05, 0x1D, 0x97, 0x7F, 0xE5, 0x06, 0x1D, 0xE6, 0xE2, 0xF8, + 0x07, 0x1D, 0xD9, 0xD2, 0xF5, 0x08, 0x1D, 0xDC, 0xD5, 0xF6, 0x09, 0x1D, 0xDC, 0xD5, 0xF6, 0x0A, 0x1D, 0xDC, 0xD5, 0xF6, + 0x0B, 0x1D, 0xDC, 0xD5, 0xF6, 0x0C, 0x1D, 0xDC, 0xD5, 0xF6, 0x0D, 0x1D, 0xDC, 0xD5, 0xF6, 0x0E, 0x1D, 0xDC, 0xD5, 0xF6, + 0x0F, 0x1D, 0xDB, 0xD3, 0xF6, 0x10, 0x1D, 0xBD, 0xAF, 0xEF, 0x11, 0x1D, 0xB8, 0xA9, 0xEE, 0x12, 0x1D, 0xB9, 0xAA, 0xEE, + 0x13, 0x1D, 0xB9, 0xAA, 0xEE, 0x14, 0x1D, 0xB9, 0xAA, 0xEE, 0x15, 0x1D, 0xB9, 0xAA, 0xEE, 0x16, 0x1D, 0xB9, 0xAA, 0xEE, + 0x17, 0x1D, 0xB9, 0xA9, 0xEE, 0x18, 0x1D, 0xBB, 0xAC, 0xEF, 0x19, 0x1D, 0xA2, 0x8D, 0xE8, 0x1A, 0x1D, 0x95, 0x7D, 0xE4, + 0x1B, 0x1D, 0x97, 0x81, 0xE5, 0x1C, 0x1D, 0x97, 0x80, 0xE5, 0x1D, 0x1D, 0x97, 0x80, 0xE5, 0x1E, 0x1D, 0x97, 0x80, 0xE5, + 0x1F, 0x1D, 0x97, 0x80, 0xE5, 0x20, 0x1D, 0x96, 0x7F, 0xE5, 0x21, 0x1D, 0x9C, 0x86, 0xE6, 0x22, 0x1D, 0x74, 0x56, 0xDC, + 0x23, 0x1D, 0x49, 0x21, 0xD2, 0x24, 0x1D, 0x5E, 0x39, 0xDB, 0x25, 0x1D, 0x1F, 0x1B, 0x2E, 0x02, 0x1E, 0x4A, 0x35, 0x8B, + 0x03, 0x1E, 0x53, 0x29, 0xE3, 0x04, 0x1E, 0x62, 0x40, 0xD5, 0x05, 0x1E, 0xD7, 0xCF, 0xF5, 0x06, 0x1E, 0xDD, 0xD7, 0xF6, + 0x07, 0x1E, 0xDB, 0xD4, 0xF6, 0x08, 0x1E, 0xDC, 0xD5, 0xF6, 0x09, 0x1E, 0xDC, 0xD5, 0xF6, 0x0A, 0x1E, 0xDC, 0xD5, 0xF6, + 0x0B, 0x1E, 0xDC, 0xD5, 0xF6, 0x0C, 0x1E, 0xDC, 0xD5, 0xF6, 0x0D, 0x1E, 0xDC, 0xD5, 0xF6, 0x0E, 0x1E, 0xDB, 0xD4, 0xF6, + 0x0F, 0x1E, 0xDE, 0xD8, 0xF6, 0x10, 0x1E, 0xD0, 0xC7, 0xF3, 0x11, 0x1E, 0xB7, 0xA8, 0xEE, 0x12, 0x1E, 0xB9, 0xAB, 0xEE, + 0x13, 0x1E, 0xB9, 0xAA, 0xEE, 0x14, 0x1E, 0xB9, 0xAA, 0xEE, 0x15, 0x1E, 0xB9, 0xAA, 0xEE, 0x16, 0x1E, 0xB9, 0xAA, 0xEE, + 0x17, 0x1E, 0xB9, 0xAA, 0xEE, 0x18, 0x1E, 0xBA, 0xAB, 0xEE, 0x19, 0x1E, 0xB4, 0xA3, 0xED, 0x1A, 0x1E, 0x98, 0x81, 0xE5, + 0x1B, 0x1E, 0x97, 0x80, 0xE5, 0x1C, 0x1E, 0x97, 0x80, 0xE5, 0x1D, 0x1E, 0x97, 0x80, 0xE5, 0x1E, 0x1E, 0x97, 0x80, 0xE5, + 0x1F, 0x1E, 0x97, 0x80, 0xE5, 0x20, 0x1E, 0x97, 0x80, 0xE5, 0x21, 0x1E, 0x98, 0x81, 0xE5, 0x22, 0x1E, 0x95, 0x7D, 0xE4, + 0x23, 0x1E, 0x59, 0x36, 0xD3, 0x24, 0x1E, 0x54, 0x2A, 0xE3, 0x25, 0x1E, 0x46, 0x31, 0x87, 0x02, 0x1F, 0x39, 0x21, 0x8D, + 0x03, 0x1F, 0x4B, 0x20, 0xDD, 0x04, 0x1F, 0x8F, 0x75, 0xE1, 0x05, 0x1F, 0xE9, 0xE6, 0xF9, 0x06, 0x1F, 0xD8, 0xCF, 0xF5, + 0x07, 0x1F, 0xDB, 0xD4, 0xF6, 0x08, 0x1F, 0xDB, 0xD4, 0xF6, 0x09, 0x1F, 0xDB, 0xD4, 0xF6, 0x0A, 0x1F, 0xDB, 0xD4, 0xF6, + 0x0B, 0x1F, 0xDB, 0xD4, 0xF6, 0x0C, 0x1F, 0xDB, 0xD4, 0xF6, 0x0D, 0x1F, 0xDB, 0xD4, 0xF6, 0x0E, 0x1F, 0xDB, 0xD4, 0xF6, + 0x0F, 0x1F, 0xDB, 0xD4, 0xF6, 0x10, 0x1F, 0xDB, 0xD4, 0xF6, 0x11, 0x1F, 0xBB, 0xAD, 0xEF, 0x12, 0x1F, 0xB7, 0xA8, 0xEE, + 0x13, 0x1F, 0xB8, 0xA9, 0xEE, 0x14, 0x1F, 0xB8, 0xA9, 0xEE, 0x15, 0x1F, 0xB8, 0xA9, 0xEE, 0x16, 0x1F, 0xB8, 0xA9, 0xEE, + 0x17, 0x1F, 0xB8, 0xA9, 0xEE, 0x18, 0x1F, 0xB8, 0xA9, 0xEE, 0x19, 0x1F, 0xBB, 0xAD, 0xEE, 0x1A, 0x1F, 0x9F, 0x8A, 0xE7, + 0x1B, 0x1F, 0x94, 0x7D, 0xE5, 0x1C, 0x1F, 0x96, 0x80, 0xE5, 0x1D, 0x1F, 0x96, 0x7F, 0xE5, 0x1E, 0x1F, 0x96, 0x7F, 0xE5, + 0x1F, 0x1F, 0x96, 0x7F, 0xE5, 0x20, 0x1F, 0x96, 0x7F, 0xE5, 0x21, 0x1F, 0x95, 0x7E, 0xE5, 0x22, 0x1F, 0x9E, 0x88, 0xE6, + 0x23, 0x1F, 0x70, 0x51, 0xD9, 0x24, 0x1F, 0x50, 0x27, 0xE0, 0x25, 0x1F, 0x39, 0x21, 0x8E, 0x02, 0x20, 0x47, 0x30, 0x8F, + 0x03, 0x20, 0x50, 0x26, 0xE1, 0x04, 0x20, 0x69, 0x49, 0xD6, 0x05, 0x20, 0xD6, 0xCE, 0xF5, 0x06, 0x20, 0xE3, 0xDE, 0xF8, + 0x07, 0x20, 0xE1, 0xDB, 0xF7, 0x08, 0x20, 0xE2, 0xDC, 0xF7, 0x09, 0x20, 0xE2, 0xDC, 0xF7, 0x0A, 0x20, 0xE2, 0xDC, 0xF7, + 0x0B, 0x20, 0xE2, 0xDC, 0xF7, 0x0C, 0x20, 0xE2, 0xDC, 0xF7, 0x0D, 0x20, 0xE2, 0xDC, 0xF7, 0x0E, 0x20, 0xE1, 0xDB, 0xF7, + 0x0F, 0x20, 0xE3, 0xDD, 0xF8, 0x10, 0x20, 0xD5, 0xCD, 0xF5, 0x11, 0x20, 0xBD, 0xAE, 0xEF, 0x12, 0x20, 0xBD, 0xAF, 0xEF, + 0x13, 0x20, 0xBD, 0xAF, 0xEF, 0x14, 0x20, 0xBD, 0xAF, 0xEF, 0x15, 0x20, 0xBD, 0xAF, 0xEF, 0x16, 0x20, 0xBD, 0xAF, 0xEF, + 0x17, 0x20, 0xBD, 0xAF, 0xEF, 0x18, 0x20, 0xBE, 0xB0, 0xEF, 0x19, 0x20, 0xB7, 0xA7, 0xED, 0x1A, 0x20, 0x9C, 0x86, 0xE6, + 0x1B, 0x20, 0x99, 0x83, 0xE6, 0x1C, 0x20, 0x9A, 0x83, 0xE6, 0x1D, 0x20, 0x9A, 0x83, 0xE6, 0x1E, 0x20, 0x9A, 0x83, 0xE6, + 0x1F, 0x20, 0x9A, 0x83, 0xE6, 0x20, 0x20, 0x9A, 0x83, 0xE6, 0x21, 0x20, 0x9A, 0x84, 0xE6, 0x22, 0x20, 0x94, 0x7C, 0xE5, + 0x23, 0x20, 0x5D, 0x3A, 0xD3, 0x24, 0x20, 0x52, 0x29, 0xE1, 0x25, 0x20, 0x46, 0x30, 0x8E, 0x02, 0x21, 0x2C, 0x25, 0x42, + 0x03, 0x21, 0x60, 0x39, 0xE4, 0x04, 0x21, 0x49, 0x21, 0xD3, 0x05, 0x21, 0x5E, 0x3B, 0xD3, 0x06, 0x21, 0x73, 0x56, 0xDA, + 0x07, 0x21, 0x71, 0x53, 0xD9, 0x08, 0x21, 0x71, 0x53, 0xD9, 0x09, 0x21, 0x71, 0x53, 0xD9, 0x0A, 0x21, 0x71, 0x53, 0xD9, + 0x0B, 0x21, 0x71, 0x53, 0xD9, 0x0C, 0x21, 0x71, 0x53, 0xD9, 0x0D, 0x21, 0x71, 0x53, 0xD9, 0x0E, 0x21, 0x71, 0x53, 0xD9, + 0x0F, 0x21, 0x71, 0x52, 0xD9, 0x10, 0x21, 0x6A, 0x48, 0xD7, 0x11, 0x21, 0x69, 0x46, 0xD7, 0x12, 0x21, 0x69, 0x47, 0xD7, + 0x13, 0x21, 0x69, 0x47, 0xD7, 0x14, 0x21, 0x69, 0x47, 0xD7, 0x15, 0x21, 0x69, 0x47, 0xD7, 0x16, 0x21, 0x69, 0x47, 0xD7, + 0x17, 0x21, 0x69, 0x47, 0xD7, 0x18, 0x21, 0x69, 0x47, 0xD7, 0x19, 0x21, 0x63, 0x41, 0xD5, 0x1A, 0x21, 0x60, 0x3E, 0xD4, + 0x1B, 0x21, 0x61, 0x3F, 0xD5, 0x1C, 0x21, 0x61, 0x3F, 0xD5, 0x1D, 0x21, 0x61, 0x3F, 0xD5, 0x1E, 0x21, 0x61, 0x3F, 0xD5, + 0x1F, 0x21, 0x61, 0x3F, 0xD5, 0x20, 0x21, 0x61, 0x3E, 0xD5, 0x21, 0x21, 0x62, 0x40, 0xD5, 0x22, 0x21, 0x57, 0x33, 0xD1, + 0x23, 0x21, 0x4B, 0x24, 0xD3, 0x24, 0x21, 0x5F, 0x38, 0xE4, 0x25, 0x21, 0x2C, 0x24, 0x42, 0x03, 0x22, 0x3C, 0x2F, 0x68, + 0x04, 0x22, 0x5C, 0x37, 0xDB, 0x05, 0x22, 0x55, 0x2B, 0xE3, 0x06, 0x22, 0x4F, 0x25, 0xE1, 0x07, 0x22, 0x50, 0x25, 0xE2, + 0x08, 0x22, 0x50, 0x25, 0xE2, 0x09, 0x22, 0x50, 0x25, 0xE2, 0x0A, 0x22, 0x50, 0x25, 0xE2, 0x0B, 0x22, 0x50, 0x25, 0xE2, + 0x0C, 0x22, 0x50, 0x25, 0xE2, 0x0D, 0x22, 0x50, 0x25, 0xE2, 0x0E, 0x22, 0x50, 0x25, 0xE2, 0x0F, 0x22, 0x50, 0x25, 0xE2, + 0x10, 0x22, 0x52, 0x27, 0xE2, 0x11, 0x22, 0x52, 0x27, 0xE2, 0x12, 0x22, 0x52, 0x27, 0xE2, 0x13, 0x22, 0x52, 0x27, 0xE2, + 0x14, 0x22, 0x52, 0x27, 0xE2, 0x15, 0x22, 0x52, 0x27, 0xE2, 0x16, 0x22, 0x52, 0x27, 0xE2, 0x17, 0x22, 0x52, 0x27, 0xE2, + 0x18, 0x22, 0x52, 0x27, 0xE2, 0x19, 0x22, 0x53, 0x29, 0xE3, 0x1A, 0x22, 0x54, 0x2A, 0xE3, 0x1B, 0x22, 0x53, 0x29, 0xE3, + 0x1C, 0x22, 0x53, 0x29, 0xE3, 0x1D, 0x22, 0x53, 0x29, 0xE3, 0x1E, 0x22, 0x53, 0x29, 0xE3, 0x1F, 0x22, 0x53, 0x29, 0xE3, + 0x20, 0x22, 0x54, 0x29, 0xE3, 0x21, 0x22, 0x53, 0x29, 0xE2, 0x22, 0x22, 0x56, 0x2D, 0xE3, 0x23, 0x22, 0x5C, 0x36, 0xDB, + 0x24, 0x22, 0x3C, 0x2F, 0x68, 0x04, 0x23, 0x19, 0x15, 0x26, 0x05, 0x23, 0x33, 0x23, 0x65, 0x06, 0x23, 0x2B, 0x1B, 0x65, + 0x07, 0x23, 0x2C, 0x1A, 0x64, 0x08, 0x23, 0x2C, 0x1A, 0x65, 0x09, 0x23, 0x2C, 0x1A, 0x65, 0x0A, 0x23, 0x2C, 0x1A, 0x65, + 0x0B, 0x23, 0x2C, 0x1A, 0x65, 0x0C, 0x23, 0x2C, 0x1A, 0x65, 0x0D, 0x23, 0x2C, 0x1A, 0x65, 0x0E, 0x23, 0x2C, 0x1A, 0x65, + 0x0F, 0x23, 0x2C, 0x1A, 0x65, 0x10, 0x23, 0x2C, 0x1A, 0x64, 0x11, 0x23, 0x2C, 0x1A, 0x64, 0x12, 0x23, 0x2C, 0x1A, 0x64, + 0x13, 0x23, 0x2C, 0x1A, 0x64, 0x14, 0x23, 0x2C, 0x1A, 0x64, 0x15, 0x23, 0x2C, 0x1A, 0x64, 0x16, 0x23, 0x2C, 0x1A, 0x64, + 0x17, 0x23, 0x2C, 0x1A, 0x64, 0x18, 0x23, 0x2C, 0x1A, 0x64, 0x19, 0x23, 0x2B, 0x19, 0x64, 0x1A, 0x23, 0x2B, 0x19, 0x64, + 0x1B, 0x23, 0x2B, 0x19, 0x64, 0x1C, 0x23, 0x2B, 0x19, 0x64, 0x1D, 0x23, 0x2B, 0x19, 0x64, 0x1E, 0x23, 0x2B, 0x19, 0x64, + 0x1F, 0x23, 0x2B, 0x19, 0x64, 0x20, 0x23, 0x2B, 0x19, 0x64, 0x21, 0x23, 0x2B, 0x1A, 0x65, 0x22, 0x23, 0x32, 0x23, 0x65, + 0x23, 0x23, 0x1A, 0x16, 0x26 ]; } From 678f8aab5b3ff3237614ccf7f6e3d5e52ea86a00 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 14:53:20 +1100 Subject: [PATCH 14/34] Add AppHost discovery polling, offline detection, and notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Background polling every 3s rescans for new/offline AppHosts - New AppHosts trigger a notification with a Connect action button - Offline AppHosts show a centered panel with Remove/Reconnect options - Watch stream errors and disconnects auto-mark AppHost as offline - AppHost list shows ⚠ indicator for offline entries - Previously offline AppHosts coming back online trigger a notification --- src/Aspire.Cli/UI/AspireMonitorTui.cs | 204 ++++++++++++++++++++++++-- 1 file changed, 188 insertions(+), 16 deletions(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index a5ae18b9bb5..1511505ba59 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -18,6 +18,7 @@ internal sealed class AppHostEntry public required string DisplayName { get; init; } public required string FullPath { get; init; } public required IAppHostAuxiliaryBackchannel Connection { get; init; } + public bool IsOffline { get; set; } } /// @@ -39,6 +40,7 @@ internal sealed class AspireMonitorTui private object? _focusedResourceKey; private object? _focusedParameterKey; private CancellationTokenSource? _watchCts; + private NotificationStack? _notificationStack; private Hex1bApp? _app; public AspireMonitorTui(IAuxiliaryBackchannelMonitor backchannelMonitor, ILogger logger) @@ -89,6 +91,9 @@ public async Task RunAsync(CancellationToken cancellationToken) { await ConnectToAppHostAsync(0, cancellationToken).ConfigureAwait(false); } + + // Start background polling for new/offline AppHosts + _ = PollAppHostsAsync(cancellationToken); }, cancellationToken); await terminal.RunAsync(cancellationToken).ConfigureAwait(false); @@ -106,16 +111,27 @@ private Hex1bWidget BuildWidget(RootContext ctx) private Hex1bWidget BuildMainScreen(RootContext ctx) { - var appHostItems = _appHosts.Select(a => a.DisplayName).ToArray(); + // Build right-side content depending on selected AppHost state + Hex1bWidget rightContent; + var selectedAppHost = _selectedAppHostIndex < _appHosts.Count ? _appHosts[_selectedAppHostIndex] : null; - // Tab panel with Resources and Parameters tabs - var tabPanel = ctx.TabPanel(tabs => [ - tabs.Tab(MonitorCommandStrings.ResourcesTab, c => BuildResourcesTab(c)), - tabs.Tab(MonitorCommandStrings.ParametersTab, c => BuildParametersTab(c)) - ]).Fill(); + if (selectedAppHost?.IsOffline == true) + { + rightContent = BuildOfflinePanel(ctx, selectedAppHost); + } + else + { + // Tab panel with Resources and Parameters tabs + var tabPanel = ctx.TabPanel(tabs => [ + tabs.Tab(MonitorCommandStrings.ResourcesTab, c => BuildResourcesTab(c)), + tabs.Tab(MonitorCommandStrings.ParametersTab, c => BuildParametersTab(c)) + ]).Fill(); + + rightContent = tabPanel; + } - // Wrap tab panel in notification panel - var mainContent = ctx.NotificationPanel(tabPanel).Fill(); + // Wrap content in notification panel + var mainContent = ctx.NotificationPanel(rightContent).Fill(); // AppHost list for the left pane var appHostList = ctx.VStack(nav => [ @@ -151,18 +167,24 @@ private IEnumerable BuildAppHostList(WidgetContext na } return _appHosts.Select((appHost, i) => - nav.Button(_selectedAppHostIndex == i - ? $" ▸ {appHost.DisplayName}" - : $" {appHost.DisplayName}") + { + var prefix = _selectedAppHostIndex == i ? " ▸ " : " "; + var suffix = appHost.IsOffline ? " ⚠" : ""; + return nav.Button($"{prefix}{appHost.DisplayName}{suffix}") .OnClick(e => { + _notificationStack ??= e.Context.Notifications; + if (i != _selectedAppHostIndex) { _selectedAppHostIndex = i; - _ = ConnectToAppHostAsync(i, CancellationToken.None); + if (!appHost.IsOffline) + { + _ = ConnectToAppHostAsync(i, CancellationToken.None); + } } - }) - ); + }); + }); } private IEnumerable BuildResourcesTab(WidgetContext ctx) @@ -263,6 +285,136 @@ private IEnumerable BuildParametersTab(WidgetContext ]; } + private Hex1bWidget BuildOfflinePanel(RootContext ctx, AppHostEntry appHost) + { + return ctx.VStack(v => [ + v.Text("").Fill(), + v.Center( + ctx.VStack(inner => [ + inner.Text("⚠ AppHost Offline").FixedHeight(1), + inner.Text("").FixedHeight(1), + inner.Text($"{appHost.DisplayName} is no longer running.").FixedHeight(1), + inner.Text("").FixedHeight(1), + inner.HStack(buttons => [ + buttons.Button(" Remove from list ").OnClick(e => + { + _notificationStack ??= e.Context.Notifications; + var idx = _appHosts.IndexOf(appHost); + _appHosts.Remove(appHost); + if (_appHosts.Count == 0) + { + _selectedAppHostIndex = 0; + } + else if (_selectedAppHostIndex >= _appHosts.Count) + { + _selectedAppHostIndex = _appHosts.Count - 1; + _ = ConnectToAppHostAsync(_selectedAppHostIndex, CancellationToken.None); + } + else if (idx == _selectedAppHostIndex) + { + _ = ConnectToAppHostAsync(_selectedAppHostIndex, CancellationToken.None); + } + }), + buttons.Text(" ").FixedWidth(2), + buttons.Button(" Try reconnecting ").OnClick(e => + { + _notificationStack ??= e.Context.Notifications; + appHost.IsOffline = false; + _ = ConnectToAppHostAsync(_appHosts.IndexOf(appHost), CancellationToken.None); + }) + ]).FixedHeight(1) + ]) + ), + v.Text("").Fill() + ]).Fill(); + } + + private async Task PollAppHostsAsync(CancellationToken cancellationToken) + { + var knownPaths = new HashSet(_appHosts.Select(a => a.FullPath)); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Task.Delay(3000, cancellationToken).ConfigureAwait(false); + await _backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false); + + var currentConnections = _backchannelMonitor.Connections + .Where(c => c.AppHostInfo is not null) + .ToList(); + + var currentPaths = new HashSet( + currentConnections.Select(c => c.AppHostInfo!.AppHostPath ?? "Unknown")); + + // Detect new AppHosts + foreach (var connection in currentConnections) + { + var path = connection.AppHostInfo!.AppHostPath ?? "Unknown"; + if (!knownPaths.Contains(path)) + { + var entry = new AppHostEntry + { + DisplayName = ShortenPath(path), + FullPath = path, + Connection = connection + }; + _appHosts.Add(entry); + knownPaths.Add(path); + + _notificationStack?.Post( + new Notification("New AppHost Detected", entry.DisplayName) + .Timeout(TimeSpan.FromSeconds(10)) + .PrimaryAction("Connect", async ctx => + { + var idx = _appHosts.IndexOf(entry); + if (idx >= 0) + { + await ConnectToAppHostAsync(idx, cancellationToken).ConfigureAwait(false); + } + ctx.Dismiss(); + })); + + _app?.Invalidate(); + } + else + { + // Check if a previously offline AppHost came back + var existing = _appHosts.FirstOrDefault(a => a.FullPath == path && a.IsOffline); + if (existing is not null) + { + existing.IsOffline = false; + + _notificationStack?.Post( + new Notification("AppHost Back Online", existing.DisplayName) + .Timeout(TimeSpan.FromSeconds(5))); + + _app?.Invalidate(); + } + } + } + + // Detect offline AppHosts + foreach (var appHost in _appHosts) + { + if (!appHost.IsOffline && !currentPaths.Contains(appHost.FullPath)) + { + appHost.IsOffline = true; + _app?.Invalidate(); + } + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error polling for AppHosts"); + } + } + } + private async Task ConnectToAppHostAsync(int index, CancellationToken cancellationToken) { if (_watchCts is not null) @@ -304,6 +456,14 @@ private async Task ConnectToAppHostAsync(int index, CancellationToken cancellati _resources[snapshot.Name] = snapshot; _app?.Invalidate(); } + + // Stream ended normally — AppHost likely shut down + if (index < _appHosts.Count) + { + _appHosts[index].IsOffline = true; + _resources.Clear(); + _app?.Invalidate(); + } } catch (OperationCanceledException) { @@ -312,7 +472,14 @@ private async Task ConnectToAppHostAsync(int index, CancellationToken cancellati catch (Exception ex) { _logger.LogDebug(ex, "Error watching resource snapshots"); - _errorMessage = ex.Message; + + // Connection lost — mark as offline + if (index < _appHosts.Count) + { + _appHosts[index].IsOffline = true; + _resources.Clear(); + } + _app?.Invalidate(); } }, _watchCts.Token); @@ -321,7 +488,12 @@ private async Task ConnectToAppHostAsync(int index, CancellationToken cancellati { _logger.LogDebug(ex, "Error connecting to AppHost"); _isConnecting = false; - _errorMessage = ex.Message; + + if (index < _appHosts.Count) + { + _appHosts[index].IsOffline = true; + } + _app?.Invalidate(); } } From 5c128143c522e1a9c44daa91c628c885804758bc Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 14:57:45 +1100 Subject: [PATCH 15/34] Fix reconnection: update stale connection and auto-reconnect selected AppHost --- src/Aspire.Cli/UI/AspireMonitorTui.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index 1511505ba59..210bfcbbb2b 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -17,7 +17,7 @@ internal sealed class AppHostEntry { public required string DisplayName { get; init; } public required string FullPath { get; init; } - public required IAppHostAuxiliaryBackchannel Connection { get; init; } + public required IAppHostAuxiliaryBackchannel Connection { get; set; } public bool IsOffline { get; set; } } @@ -384,11 +384,20 @@ private async Task PollAppHostsAsync(CancellationToken cancellationToken) if (existing is not null) { existing.IsOffline = false; + // Update to the fresh connection since the old one is stale + existing.Connection = connection; _notificationStack?.Post( new Notification("AppHost Back Online", existing.DisplayName) .Timeout(TimeSpan.FromSeconds(5))); + // Auto-reconnect if this is the currently selected AppHost + var idx = _appHosts.IndexOf(existing); + if (idx == _selectedAppHostIndex) + { + _ = ConnectToAppHostAsync(idx, cancellationToken); + } + _app?.Invalidate(); } } From 56dbd35c827f9d66a5a25e41af46bdcab57fe6a2 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 15:09:45 +1100 Subject: [PATCH 16/34] =?UTF-8?q?Rework=20splash:=20braille=20whirlwind=20?= =?UTF-8?q?in=20=E2=86=92=20crossfade=20to=20half-blocks=20=E2=86=92=20mel?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Animation sequence: 1. Whirlwind (0-2.2s): Braille particles spiral from random positions, circling their target before settling into place 2. Braille hold (2.2-2.6s): Static braille logo 3. Crossfade (2.6-3.2s): Braille dims through black, half-blocks brighten 4. Half-block hold (3.2-4.0s): Solid half-block logo 5. Dissolve/melt (4.0-7.0s): Rows dissolve to braille, fall with gravity Original half-block fly-in code preserved (RenderFlyIn) for future use. --- src/Aspire.Cli/UI/AspireMonitorSplash.cs | 169 +++++++++++++++++++++-- 1 file changed, 157 insertions(+), 12 deletions(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorSplash.cs b/src/Aspire.Cli/UI/AspireMonitorSplash.cs index e605d3ce72f..2f36bf4faab 100644 --- a/src/Aspire.Cli/UI/AspireMonitorSplash.cs +++ b/src/Aspire.Cli/UI/AspireMonitorSplash.cs @@ -8,17 +8,19 @@ namespace Aspire.Cli.UI; /// -/// Animated splash screen that renders the Aspire logo using half-block characters. -/// Pixels fly in from random off-screen positions, hold, then dissolve into braille -/// dots that fall with gravity and bounce off the bottom of the screen. +/// Animated splash screen that renders the Aspire logo. +/// Braille particles whirlwind into position, crossfade to half-blocks, +/// then dissolve and fall with gravity. /// internal sealed class AspireMonitorSplash { - // Timing - private const int FlyInDurationMs = 1800; - private const int HoldEndMs = 2800; + // Phase timing (ms from start) + private const int WhirlwindDurationMs = 2200; + private const int BrailleHoldEndMs = 2600; + private const int CrossfadeEndMs = 3200; + private const int HoldEndMs = 4000; private const int DissolveDurationMs = 900; - private const int ExitDurationMs = 3200; + private const int ExitDurationMs = 3000; public const int TotalDurationMs = HoldEndMs + ExitDurationMs; // Logo dimensions in logical pixels (each pixel is half a character cell vertically) @@ -36,6 +38,10 @@ private readonly record struct PixelState( int FinalX, int FinalY, byte R, byte G, byte B, float StartOffsetX, float StartOffsetY); + private readonly record struct BrailleDot( + int FinalBx, int FinalBy, byte R, byte G, byte B, + float StartRadius, float StartAngle, float TotalRotations); + private sealed class Particle { public float X; @@ -50,6 +56,7 @@ private sealed class Particle } private readonly PixelState[] _pixels; + private readonly BrailleDot[] _dots; private readonly long _startTicks; private List? _particles; private long _lastPhysicsTick; @@ -66,6 +73,7 @@ public AspireMonitorSplash() var pixelCount = s_pixelData.Length / 5; _pixels = new PixelState[pixelCount]; + // Create pixel states for half-block rendering for (var i = 0; i < pixelCount; i++) { var offset = i * 5; @@ -82,6 +90,35 @@ public AspireMonitorSplash() _pixels[i] = new PixelState(x, y, r, g, b, startOffsetX, startOffsetY); } + + // Create braille dots for the whirlwind animation + var dotList = new List(pixelCount * 4); + var dotRng = new Random(99); + + foreach (var pixel in _pixels) + { + var cellY = pixel.FinalY / 2; + var isTop = pixel.FinalY % 2 == 0; + var baseBx = pixel.FinalX * 2; + var baseBy = cellY * 4 + (isTop ? 0 : 2); + + for (var dr = 0; dr < 2; dr++) + { + for (var dc = 0; dc < 2; dc++) + { + var startRadius = (float)(40 + dotRng.NextDouble() * 60); + var startAngle = (float)(dotRng.NextDouble() * Math.PI * 2); + var rotations = (float)(1.5 + dotRng.NextDouble() * 2.5); + + dotList.Add(new BrailleDot( + baseBx + dc, baseBy + dr, + pixel.R, pixel.G, pixel.B, + startRadius, startAngle, rotations)); + } + } + } + + _dots = dotList.ToArray(); } private long ElapsedMs => Environment.TickCount64 - _startTicks; @@ -98,18 +135,45 @@ public Hex1bWidget Build(RootContext ctx) { var offsetX = Math.Max(0, (surface.Width - LogoWidth) / 2); var offsetY = Math.Max(0, (surface.Height - LogoCellHeight) / 2); + var offsetBx = offsetX * 2; + var offsetBy = offsetY * 4; - if (elapsed < FlyInDurationMs) + if (elapsed < WhirlwindDurationMs) { - var t = EaseOutCubic(elapsed / (float)FlyInDurationMs); - RenderFlyIn(surface, t, offsetX, offsetY); + // Braille particles spiral into position + var progress = elapsed / (float)WhirlwindDurationMs; + RenderBrailleLogo(surface, progress, 1f, offsetBx, offsetBy); + } + else if (elapsed < BrailleHoldEndMs) + { + // Braille logo settled at final positions + RenderBrailleLogo(surface, 1f, 1f, offsetBx, offsetBy); + } + else if (elapsed < CrossfadeEndMs) + { + // Crossfade: braille dims through black, half-blocks brighten + var crossfadeProgress = (elapsed - BrailleHoldEndMs) / (float)(CrossfadeEndMs - BrailleHoldEndMs); + if (crossfadeProgress < 0.5f) + { + // First half: braille dimming + var brightness = 1f - crossfadeProgress * 2f; + RenderBrailleLogo(surface, 1f, brightness, offsetBx, offsetBy); + } + else + { + // Second half: half-blocks brightening + var brightness = (crossfadeProgress - 0.5f) * 2f; + RenderStaticWithBrightness(surface, offsetX, offsetY, brightness); + } } else if (elapsed < HoldEndMs) { + // Static half-block logo RenderStatic(surface, offsetX, offsetY); } else if (elapsed < TotalDurationMs) { + // Dissolve and melt var exitElapsed = elapsed - HoldEndMs; RenderExit(surface, exitElapsed, offsetX, offsetY); } @@ -118,7 +182,84 @@ public Hex1bWidget Build(RootContext ctx) ).Fill(); } + // ── Braille whirlwind rendering ─────────────────────────────────────── + + private void RenderBrailleLogo(Hex1b.Surfaces.Surface surface, float spiralProgress, float brightness, int offsetBx, int offsetBy) + { + var t = EaseOutCubic(spiralProgress); + var cells = new Dictionary<(int cx, int cy), (int pattern, int totalR, int totalG, int totalB, int count)>(); + + foreach (var dot in _dots) + { + // Parametric spiral: radius shrinks, angle unwinds as t → 1 + var radius = dot.StartRadius * (1f - t); + var angle = dot.StartAngle + dot.TotalRotations * 2f * MathF.PI * (1f - t); + var bx = (int)Math.Round(dot.FinalBx + offsetBx + radius * MathF.Cos(angle)); + var by = (int)Math.Round(dot.FinalBy + offsetBy + radius * MathF.Sin(angle)); + + if (bx < 0 || bx >= surface.Width * 2 || by < 0 || by >= surface.Height * 4) + { + continue; + } + + var cx = bx / 2; + var cy = by / 4; + var dotCol = bx % 2; + var dotRow = by % 4; + var bit = s_brailleBits[dotCol * 4 + dotRow]; + + var key = (cx, cy); + if (cells.TryGetValue(key, out var existing)) + { + cells[key] = (existing.pattern | bit, existing.totalR + dot.R, existing.totalG + dot.G, existing.totalB + dot.B, existing.count + 1); + } + else + { + cells[key] = (bit, dot.R, dot.G, dot.B, 1); + } + } + + foreach (var ((cx, cy), (pattern, totalR, totalG, totalB, count)) in cells) + { + if (cx < 0 || cx >= surface.Width || cy < 0 || cy >= surface.Height) + { + continue; + } + + var r = Dim((byte)(totalR / count), brightness); + var g = Dim((byte)(totalG / count), brightness); + var b = Dim((byte)(totalB / count), brightness); + var ch = (char)(0x2800 | pattern); + surface.WriteChar(cx, cy, ch, Hex1bColor.FromRgb(r, g, b)); + } + } + + // ── Half-block rendering ────────────────────────────────────────────── + + private void RenderStaticWithBrightness(Hex1b.Surfaces.Surface surface, int offsetX, int offsetY, float brightness) + { + var gridW = surface.Width; + var gridH = surface.Height * 2; + var grid = new (byte R, byte G, byte B, bool HasPixel)[gridW, gridH]; + + foreach (var pixel in _pixels) + { + var sx = pixel.FinalX + offsetX; + var sy = pixel.FinalY + offsetY * 2; + + if (sx >= 0 && sx < gridW && sy >= 0 && sy < gridH) + { + grid[sx, sy] = (Dim(pixel.R, brightness), Dim(pixel.G, brightness), Dim(pixel.B, brightness), true); + } + } + + RenderHalfBlocks(surface, grid, gridH); + } + + // Kept for potential future use — original half-block fly-in animation +#pragma warning disable IDE0051 private void RenderFlyIn(Hex1b.Surfaces.Surface surface, float t, int offsetX, int offsetY) +#pragma warning restore IDE0051 { var gridW = surface.Width; var gridH = surface.Height * 2; @@ -161,6 +302,8 @@ private void RenderStatic(Hex1b.Surfaces.Surface surface, int offsetX, int offse RenderHalfBlocks(surface, grid, gridH); } + // ── Dissolve / melt exit ────────────────────────────────────────────── + private void RenderExit(Hex1b.Surfaces.Surface surface, long exitElapsedMs, int offsetX, int offsetY) { EnsureParticlesCreated(surface.Height, offsetX, offsetY); @@ -182,7 +325,7 @@ private void RenderExit(Hex1b.Surfaces.Surface surface, long exitElapsedMs, int } // Render particles as braille - RenderBrailleParticles(surface, exitElapsedMs, fadeFactor); + RenderFallingParticles(surface, exitElapsedMs, fadeFactor); } private void EnsureParticlesCreated(int surfaceHeight, int offsetX, int offsetY) @@ -315,7 +458,7 @@ private void RenderStaticRows(Hex1b.Surfaces.Surface surface, int offsetX, int o } } - private void RenderBrailleParticles(Hex1b.Surfaces.Surface surface, long exitElapsedMs, float fadeFactor) + private void RenderFallingParticles(Hex1b.Surfaces.Surface surface, long exitElapsedMs, float fadeFactor) { if (_particles is null) { @@ -372,6 +515,8 @@ private void RenderBrailleParticles(Hex1b.Surfaces.Surface surface, long exitEla } } + // ── Shared helpers ──────────────────────────────────────────────────── + private static void RenderHalfBlocks(Hex1b.Surfaces.Surface surface, (byte R, byte G, byte B, bool HasPixel)[,] grid, int gridH) { for (var cy = 0; cy < surface.Height; cy++) From f5fe59e812baa4de49929a6485abfe4b7c310938 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 15:16:03 +1100 Subject: [PATCH 17/34] Remove half-block animation phases, use braille throughout Splash is now fully braille-based: 1. Whirlwind (0-2.2s): Braille dots spiral into position 2. Hold (2.2-3.0s): Static braille logo 3. Dissolve/fall (3.0-6.0s): Row-by-row braille dissolve with gravity Removed PixelState, RenderFlyIn, RenderStatic, RenderStaticWithBrightness, RenderStaticRows, RenderHalfBlocks, and crossfade phase. --- src/Aspire.Cli/UI/AspireMonitorSplash.cs | 320 ++++++----------------- 1 file changed, 84 insertions(+), 236 deletions(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorSplash.cs b/src/Aspire.Cli/UI/AspireMonitorSplash.cs index 2f36bf4faab..c707439083c 100644 --- a/src/Aspire.Cli/UI/AspireMonitorSplash.cs +++ b/src/Aspire.Cli/UI/AspireMonitorSplash.cs @@ -16,9 +16,7 @@ internal sealed class AspireMonitorSplash { // Phase timing (ms from start) private const int WhirlwindDurationMs = 2200; - private const int BrailleHoldEndMs = 2600; - private const int CrossfadeEndMs = 3200; - private const int HoldEndMs = 4000; + private const int HoldEndMs = 3000; private const int DissolveDurationMs = 900; private const int ExitDurationMs = 3000; public const int TotalDurationMs = HoldEndMs + ExitDurationMs; @@ -34,10 +32,6 @@ internal sealed class AspireMonitorSplash private const float SettleThreshold = 3f; private const int FadeOutMs = 500; - private readonly record struct PixelState( - int FinalX, int FinalY, byte R, byte G, byte B, - float StartOffsetX, float StartOffsetY); - private readonly record struct BrailleDot( int FinalBx, int FinalBy, byte R, byte G, byte B, float StartRadius, float StartAngle, float TotalRotations); @@ -55,7 +49,6 @@ private sealed class Particle public bool Settled; } - private readonly PixelState[] _pixels; private readonly BrailleDot[] _dots; private readonly long _startTicks; private List? _particles; @@ -68,51 +61,36 @@ private sealed class Particle public AspireMonitorSplash() { _startTicks = Environment.TickCount64; - var rng = new Random(42); var pixelCount = s_pixelData.Length / 5; - _pixels = new PixelState[pixelCount]; + var dotList = new List(pixelCount * 4); + var rng = new Random(99); - // Create pixel states for half-block rendering for (var i = 0; i < pixelCount; i++) { var offset = i * 5; - var x = s_pixelData[offset]; - var y = s_pixelData[offset + 1]; + var px = s_pixelData[offset]; + var py = s_pixelData[offset + 1]; var r = s_pixelData[offset + 2]; var g = s_pixelData[offset + 3]; var b = s_pixelData[offset + 4]; - var angle = rng.NextDouble() * Math.PI * 2; - var distance = 30 + rng.NextDouble() * 40; - var startOffsetX = (float)(Math.Cos(angle) * distance); - var startOffsetY = (float)(Math.Sin(angle) * distance); - - _pixels[i] = new PixelState(x, y, r, g, b, startOffsetX, startOffsetY); - } - - // Create braille dots for the whirlwind animation - var dotList = new List(pixelCount * 4); - var dotRng = new Random(99); - - foreach (var pixel in _pixels) - { - var cellY = pixel.FinalY / 2; - var isTop = pixel.FinalY % 2 == 0; - var baseBx = pixel.FinalX * 2; + var cellY = py / 2; + var isTop = py % 2 == 0; + var baseBx = px * 2; var baseBy = cellY * 4 + (isTop ? 0 : 2); for (var dr = 0; dr < 2; dr++) { for (var dc = 0; dc < 2; dc++) { - var startRadius = (float)(40 + dotRng.NextDouble() * 60); - var startAngle = (float)(dotRng.NextDouble() * Math.PI * 2); - var rotations = (float)(1.5 + dotRng.NextDouble() * 2.5); + var startRadius = (float)(40 + rng.NextDouble() * 60); + var startAngle = (float)(rng.NextDouble() * Math.PI * 2); + var rotations = (float)(1.5 + rng.NextDouble() * 2.5); dotList.Add(new BrailleDot( baseBx + dc, baseBy + dr, - pixel.R, pixel.G, pixel.B, + r, g, b, startRadius, startAngle, rotations)); } } @@ -144,38 +122,18 @@ public Hex1bWidget Build(RootContext ctx) var progress = elapsed / (float)WhirlwindDurationMs; RenderBrailleLogo(surface, progress, 1f, offsetBx, offsetBy); } - else if (elapsed < BrailleHoldEndMs) + else if (elapsed < HoldEndMs) { // Braille logo settled at final positions RenderBrailleLogo(surface, 1f, 1f, offsetBx, offsetBy); } - else if (elapsed < CrossfadeEndMs) - { - // Crossfade: braille dims through black, half-blocks brighten - var crossfadeProgress = (elapsed - BrailleHoldEndMs) / (float)(CrossfadeEndMs - BrailleHoldEndMs); - if (crossfadeProgress < 0.5f) - { - // First half: braille dimming - var brightness = 1f - crossfadeProgress * 2f; - RenderBrailleLogo(surface, 1f, brightness, offsetBx, offsetBy); - } - else - { - // Second half: half-blocks brightening - var brightness = (crossfadeProgress - 0.5f) * 2f; - RenderStaticWithBrightness(surface, offsetX, offsetY, brightness); - } - } - else if (elapsed < HoldEndMs) - { - // Static half-block logo - RenderStatic(surface, offsetX, offsetY); - } else if (elapsed < TotalDurationMs) { - // Dissolve and melt + // Dissolve and melt (braille-only) var exitElapsed = elapsed - HoldEndMs; - RenderExit(surface, exitElapsed, offsetX, offsetY); + var offsetBxExit = offsetX * 2; + var offsetByExit = offsetY * 4; + RenderExit(surface, exitElapsed, offsetBxExit, offsetByExit); } }) ]).Fill() @@ -234,101 +192,85 @@ private void RenderBrailleLogo(Hex1b.Surfaces.Surface surface, float spiralProgr } } - // ── Half-block rendering ────────────────────────────────────────────── + // ── Dissolve / melt exit ────────────────────────────────────────────── - private void RenderStaticWithBrightness(Hex1b.Surfaces.Surface surface, int offsetX, int offsetY, float brightness) + private void RenderExit(Hex1b.Surfaces.Surface surface, long exitElapsedMs, int offsetBx, int offsetBy) { - var gridW = surface.Width; - var gridH = surface.Height * 2; - var grid = new (byte R, byte G, byte B, bool HasPixel)[gridW, gridH]; + EnsureParticlesCreated(surface.Height, offsetBx, offsetBy); + UpdateParticles(exitElapsedMs); - foreach (var pixel in _pixels) - { - var sx = pixel.FinalX + offsetX; - var sy = pixel.FinalY + offsetY * 2; + var dissolveProgress = Math.Clamp(exitElapsedMs / (float)DissolveDurationMs, 0f, 1f); + var dissolvedUpToRow = (int)(dissolveProgress * LogoCellHeight); - if (sx >= 0 && sx < gridW && sy >= 0 && sy < gridH) - { - grid[sx, sy] = (Dim(pixel.R, brightness), Dim(pixel.G, brightness), Dim(pixel.B, brightness), true); - } + // Fade factor for the end of the exit + var fadeStart = ExitDurationMs - FadeOutMs; + var fadeFactor = exitElapsedMs > fadeStart + ? 1f - Math.Clamp((exitElapsedMs - fadeStart) / (float)FadeOutMs, 0f, 1f) + : 1f; + + // Render undissolved rows as static braille + if (dissolvedUpToRow < LogoCellHeight) + { + RenderBrailleRows(surface, dissolvedUpToRow, LogoCellHeight, fadeFactor, offsetBx, offsetBy); } - RenderHalfBlocks(surface, grid, gridH); + // Render falling particles as braille + RenderFallingParticles(surface, exitElapsedMs, fadeFactor); } - // Kept for potential future use — original half-block fly-in animation -#pragma warning disable IDE0051 - private void RenderFlyIn(Hex1b.Surfaces.Surface surface, float t, int offsetX, int offsetY) -#pragma warning restore IDE0051 + private void RenderBrailleRows(Hex1b.Surfaces.Surface surface, int fromRow, int toRow, float brightness, int offsetBx, int offsetBy) { - var gridW = surface.Width; - var gridH = surface.Height * 2; - var grid = new (byte R, byte G, byte B, bool HasPixel)[gridW, gridH]; + var cells = new Dictionary<(int cx, int cy), (int pattern, int totalR, int totalG, int totalB, int count)>(); - foreach (var pixel in _pixels) + foreach (var dot in _dots) { - var currentX = (pixel.FinalX + offsetX) + pixel.StartOffsetX * (1f - t); - var currentY = (pixel.FinalY + offsetY * 2) + pixel.StartOffsetY * (1f - t); - - var ix = (int)Math.Round(currentX); - var iy = (int)Math.Round(currentY); - - if (ix >= 0 && ix < gridW && iy >= 0 && iy < gridH) + var cellRow = dot.FinalBy / 4; + if (cellRow < fromRow || cellRow >= toRow) { - grid[ix, iy] = (pixel.R, pixel.G, pixel.B, true); + continue; } - } - RenderHalfBlocks(surface, grid, gridH); - } + var bx = dot.FinalBx + offsetBx; + var by = dot.FinalBy + offsetBy; - private void RenderStatic(Hex1b.Surfaces.Surface surface, int offsetX, int offsetY) - { - var gridW = surface.Width; - var gridH = surface.Height * 2; - var grid = new (byte R, byte G, byte B, bool HasPixel)[gridW, gridH]; + if (bx < 0 || bx >= surface.Width * 2 || by < 0 || by >= surface.Height * 4) + { + continue; + } - foreach (var pixel in _pixels) - { - var sx = pixel.FinalX + offsetX; - var sy = pixel.FinalY + offsetY * 2; + var cx = bx / 2; + var cy = by / 4; + var dotCol = bx % 2; + var dotRow = by % 4; + var bit = s_brailleBits[dotCol * 4 + dotRow]; - if (sx >= 0 && sx < gridW && sy >= 0 && sy < gridH) + var key = (cx, cy); + if (cells.TryGetValue(key, out var existing)) { - grid[sx, sy] = (pixel.R, pixel.G, pixel.B, true); + cells[key] = (existing.pattern | bit, existing.totalR + dot.R, existing.totalG + dot.G, existing.totalB + dot.B, existing.count + 1); + } + else + { + cells[key] = (bit, dot.R, dot.G, dot.B, 1); } } - RenderHalfBlocks(surface, grid, gridH); - } - - // ── Dissolve / melt exit ────────────────────────────────────────────── - - private void RenderExit(Hex1b.Surfaces.Surface surface, long exitElapsedMs, int offsetX, int offsetY) - { - EnsureParticlesCreated(surface.Height, offsetX, offsetY); - UpdateParticles(exitElapsedMs); - - var dissolveProgress = Math.Clamp(exitElapsedMs / (float)DissolveDurationMs, 0f, 1f); - var dissolvedUpToRow = (int)(dissolveProgress * LogoCellHeight); - - // Fade factor for the end of the exit - var fadeStart = ExitDurationMs - FadeOutMs; - var fadeFactor = exitElapsedMs > fadeStart - ? 1f - Math.Clamp((exitElapsedMs - fadeStart) / (float)FadeOutMs, 0f, 1f) - : 1f; - - // Render undissolved rows as half-blocks - if (dissolvedUpToRow < LogoCellHeight) + foreach (var ((cx, cy), (pattern, totalR, totalG, totalB, count)) in cells) { - RenderStaticRows(surface, offsetX, offsetY, dissolvedUpToRow, LogoCellHeight, fadeFactor); - } + if (cx < 0 || cx >= surface.Width || cy < 0 || cy >= surface.Height) + { + continue; + } - // Render particles as braille - RenderFallingParticles(surface, exitElapsedMs, fadeFactor); + var r = Dim((byte)(totalR / count), brightness); + var g = Dim((byte)(totalG / count), brightness); + var b = Dim((byte)(totalB / count), brightness); + var ch = (char)(0x2800 | pattern); + surface.WriteChar(cx, cy, ch, Hex1bColor.FromRgb(r, g, b)); + } } - private void EnsureParticlesCreated(int surfaceHeight, int offsetX, int offsetY) + private void EnsureParticlesCreated(int surfaceHeight, int offsetBx, int offsetBy) { if (_particles is not null) { @@ -337,38 +279,25 @@ private void EnsureParticlesCreated(int surfaceHeight, int offsetX, int offsetY) _maxBrailleY = surfaceHeight * 4 - 1; _lastPhysicsTick = Environment.TickCount64; - _particles = new List(_pixels.Length * 4); + _particles = new List(_dots.Length); var rng = new Random(123); - foreach (var pixel in _pixels) + foreach (var dot in _dots) { - var sx = offsetX + pixel.FinalX; - var sy = offsetY + pixel.FinalY / 2; - var isTopHalf = pixel.FinalY % 2 == 0; - var cellRow = pixel.FinalY / 2; - + var cellRow = (dot.FinalBy / 4); var spawnTimeMs = (cellRow / (float)LogoCellHeight) * DissolveDurationMs; - var baseBx = sx * 2; - var baseBy = sy * 4 + (isTopHalf ? 0 : 2); - - for (var dr = 0; dr < 2; dr++) + _particles.Add(new Particle { - for (var dc = 0; dc < 2; dc++) - { - _particles.Add(new Particle - { - X = baseBx + dc, - Y = baseBy + dr, - Vx = (float)(rng.NextDouble() * 30 - 15), - Vy = (float)(rng.NextDouble() * 25 + 10), - R = pixel.R, - G = pixel.G, - B = pixel.B, - SpawnTimeMs = spawnTimeMs - }); - } - } + X = dot.FinalBx + offsetBx, + Y = dot.FinalBy + offsetBy, + Vx = (float)(rng.NextDouble() * 30 - 15), + Vy = (float)(rng.NextDouble() * 25 + 10), + R = dot.R, + G = dot.G, + B = dot.B, + SpawnTimeMs = spawnTimeMs + }); } } @@ -409,55 +338,6 @@ private void UpdateParticles(long exitElapsedMs) } } - private void RenderStaticRows(Hex1b.Surfaces.Surface surface, int offsetX, int offsetY, int fromRow, int toRow, float fadeFactor) - { - var grid = new (byte R, byte G, byte B, bool HasPixel)[LogoWidth, LogoHeight]; - - foreach (var pixel in _pixels) - { - var cellRow = pixel.FinalY / 2; - if (cellRow >= fromRow && cellRow < toRow) - { - grid[pixel.FinalX, pixel.FinalY] = (pixel.R, pixel.G, pixel.B, true); - } - } - - for (var cy = fromRow; cy < toRow; cy++) - { - for (var cx = 0; cx < LogoWidth; cx++) - { - var sx = offsetX + cx; - var sy = offsetY + cy; - if (sx < 0 || sx >= surface.Width || sy < 0 || sy >= surface.Height) - { - continue; - } - - var topRow = cy * 2; - var botRow = cy * 2 + 1; - var top = grid[cx, topRow]; - var bot = botRow < LogoHeight ? grid[cx, botRow] : default; - - if (top.HasPixel && bot.HasPixel) - { - var fg = Hex1bColor.FromRgb(Dim(top.R, fadeFactor), Dim(top.G, fadeFactor), Dim(top.B, fadeFactor)); - var bg = Hex1bColor.FromRgb(Dim(bot.R, fadeFactor), Dim(bot.G, fadeFactor), Dim(bot.B, fadeFactor)); - surface.WriteChar(sx, sy, '\u2580', fg, bg); - } - else if (top.HasPixel) - { - var fg = Hex1bColor.FromRgb(Dim(top.R, fadeFactor), Dim(top.G, fadeFactor), Dim(top.B, fadeFactor)); - surface.WriteChar(sx, sy, '\u2580', fg); - } - else if (bot.HasPixel) - { - var fg = Hex1bColor.FromRgb(Dim(bot.R, fadeFactor), Dim(bot.G, fadeFactor), Dim(bot.B, fadeFactor)); - surface.WriteChar(sx, sy, '\u2584', fg); - } - } - } - } - private void RenderFallingParticles(Hex1b.Surfaces.Surface surface, long exitElapsedMs, float fadeFactor) { if (_particles is null) @@ -517,38 +397,6 @@ private void RenderFallingParticles(Hex1b.Surfaces.Surface surface, long exitEla // ── Shared helpers ──────────────────────────────────────────────────── - private static void RenderHalfBlocks(Hex1b.Surfaces.Surface surface, (byte R, byte G, byte B, bool HasPixel)[,] grid, int gridH) - { - for (var cy = 0; cy < surface.Height; cy++) - { - for (var cx = 0; cx < surface.Width; cx++) - { - var topRow = cy * 2; - var botRow = cy * 2 + 1; - - var top = grid[cx, topRow]; - var bot = botRow < gridH ? grid[cx, botRow] : default; - - if (top.HasPixel && bot.HasPixel) - { - var fg = Hex1bColor.FromRgb(top.R, top.G, top.B); - var bg = Hex1bColor.FromRgb(bot.R, bot.G, bot.B); - surface.WriteChar(cx, cy, '\u2580', fg, bg); - } - else if (top.HasPixel) - { - var fg = Hex1bColor.FromRgb(top.R, top.G, top.B); - surface.WriteChar(cx, cy, '\u2580', fg); - } - else if (bot.HasPixel) - { - var fg = Hex1bColor.FromRgb(bot.R, bot.G, bot.B); - surface.WriteChar(cx, cy, '\u2584', fg); - } - } - } - } - private static float EaseOutCubic(float x) => 1f - (1f - x) * (1f - x) * (1f - x); private static byte Dim(byte value, float factor) => (byte)(value * factor); From 89bbb2ebbe76c419e21756e051eba323c3b48927 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 15:27:05 +1100 Subject: [PATCH 18/34] Refine theme: lighter purple focused borders, white-on-purple info bar --- src/Aspire.Cli/UI/AspireTheme.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Cli/UI/AspireTheme.cs b/src/Aspire.Cli/UI/AspireTheme.cs index cbf82deb820..0520c9ad75d 100644 --- a/src/Aspire.Cli/UI/AspireTheme.cs +++ b/src/Aspire.Cli/UI/AspireTheme.cs @@ -44,8 +44,8 @@ public static Hex1bTheme Apply(Hex1bTheme theme) // Table .Set(TableTheme.BorderColor, s_border) - .Set(TableTheme.FocusedBorderColor, s_purpleMedium) - .Set(TableTheme.TableFocusedBorderColor, s_purple) + .Set(TableTheme.FocusedBorderColor, s_purpleLight) + .Set(TableTheme.TableFocusedBorderColor, s_purpleLight) .Set(TableTheme.HeaderBackground, s_headerBg) .Set(TableTheme.HeaderForeground, s_purpleLight) .Set(TableTheme.RowBackground, s_bgDark) @@ -55,6 +55,7 @@ public static Hex1bTheme Apply(Hex1bTheme theme) .Set(TableTheme.FocusedRowForeground, s_lavender) .Set(TableTheme.SelectedRowBackground, s_selectedRow) .Set(TableTheme.SelectedRowForeground, s_purpleFaint) + .Set(TableTheme.SelectionColumnBorderColor, s_purpleMedium) .Set(TableTheme.EmptyTextForeground, s_textMuted) .Set(TableTheme.LoadingTextForeground, s_textMuted) .Set(TableTheme.ScrollbarThumbColor, s_purpleMedium) @@ -75,8 +76,8 @@ public static Hex1bTheme Apply(Hex1bTheme theme) .Set(TabBarTheme.SelectedForegroundColor, s_lavender) // InfoBar - .Set(InfoBarTheme.BackgroundColor, s_bgElevated) - .Set(InfoBarTheme.ForegroundColor, s_textMuted) + .Set(InfoBarTheme.BackgroundColor, s_purpleLight) + .Set(InfoBarTheme.ForegroundColor, Hex1bColor.FromRgb(255, 255, 255)) // Button .Set(ButtonTheme.ForegroundColor, s_textPrimary) From 446aa5415ef30509f56dda7a89e1036c8506120f Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 15:43:46 +1100 Subject: [PATCH 19/34] Add resource console log panel with embedded terminal - Create AspireResourceConsoleLogWorkload implementing IHex1bTerminalWorkloadAdapter that streams resource logs via the backchannel into an embedded Hex1b terminal - Add DragBarPanel layout in resources tab: table on top, log terminal on bottom - Wire resource row selection to switch log streams automatically - Update Hex1b to 1.0.0-local.20260209100759 for DragBarPanel/TerminalWidget support - Log workload handles stream switching, cancellation, and ANSI color for errors --- src/Aspire.Cli/UI/AspireMonitorTui.cs | 135 ++++++++++----- .../UI/AspireResourceConsoleLogWorkload.cs | 157 ++++++++++++++++++ 2 files changed, 253 insertions(+), 39 deletions(-) create mode 100644 src/Aspire.Cli/UI/AspireResourceConsoleLogWorkload.cs diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index 210bfcbbb2b..68b1332eb95 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -43,6 +43,11 @@ internal sealed class AspireMonitorTui private NotificationStack? _notificationStack; private Hex1bApp? _app; + // Embedded terminal for resource console logs + private AspireResourceConsoleLogWorkload? _logWorkload; + private TerminalWidgetHandle? _logTerminalHandle; + private Hex1bTerminal? _logTerminal; + public AspireMonitorTui(IAuxiliaryBackchannelMonitor backchannelMonitor, ILogger logger) { _backchannelMonitor = backchannelMonitor; @@ -209,45 +214,67 @@ private IEnumerable BuildResourcesTab(WidgetContext c return [ctx.Text($" {MonitorCommandStrings.NoResourcesAvailable}")]; } - return [ - ctx.Table(resources) - .RowKey(r => r.Name) - .Focus(_focusedResourceKey) - .OnFocusChanged(key => { _focusedResourceKey = key; }) - .Header(h => [ - h.Cell("Name").Width(SizeHint.Fill), - h.Cell("Type").Fixed(12), - h.Cell("State").Fixed(12), - h.Cell("Health").Fixed(12), - h.Cell("URLs").Width(SizeHint.Fill), - h.Cell("Actions").Fixed(20) - ]) - .Row((r, resource, state) => [ - r.Cell(resource.DisplayName ?? resource.Name), - r.Cell(resource.ResourceType ?? ""), - r.Cell(resource.State ?? "Unknown"), - r.Cell(resource.HealthStatus ?? ""), - r.Cell(resource.Urls.Length > 0 - ? string.Join(", ", resource.Urls.Select(u => u.Url)) - : ""), - r.Cell(cell => cell.HStack(h => [ - h.Button("▶").OnClick(e => - { - _ = ExecuteResourceCommandAsync(resource.Name, "resource-start"); - }), - h.Button("■").OnClick(e => - { - _ = ExecuteResourceCommandAsync(resource.Name, "resource-stop"); - }), - h.Button("↻").OnClick(e => - { - _ = ExecuteResourceCommandAsync(resource.Name, "resource-restart"); - }) - ])) - ]) - .Fill() - .Full() - ]; + var table = ctx.Table(resources) + .RowKey(r => r.Name) + .Focus(_focusedResourceKey) + .OnFocusChanged(key => + { + _focusedResourceKey = key; + if (key is string resourceName) + { + SwitchLogStream(resourceName); + } + }) + .Header(h => [ + h.Cell("Name").Width(SizeHint.Fill), + h.Cell("Type").Fixed(12), + h.Cell("State").Fixed(12), + h.Cell("Health").Fixed(12), + h.Cell("URLs").Width(SizeHint.Fill), + h.Cell("Actions").Fixed(20) + ]) + .Row((r, resource, state) => [ + r.Cell(resource.DisplayName ?? resource.Name), + r.Cell(resource.ResourceType ?? ""), + r.Cell(resource.State ?? "Unknown"), + r.Cell(resource.HealthStatus ?? ""), + r.Cell(resource.Urls.Length > 0 + ? string.Join(", ", resource.Urls.Select(u => u.Url)) + : ""), + r.Cell(cell => cell.HStack(h => [ + h.Button("▶").OnClick(e => + { + _ = ExecuteResourceCommandAsync(resource.Name, "resource-start"); + }), + h.Button("■").OnClick(e => + { + _ = ExecuteResourceCommandAsync(resource.Name, "resource-stop"); + }), + h.Button("↻").OnClick(e => + { + _ = ExecuteResourceCommandAsync(resource.Name, "resource-restart"); + }) + ])) + ]) + .Fill() + .Full(); + + // If we have a terminal handle, show table+logs in a vertical split + if (_logTerminalHandle is not null) + { + var logPanel = ctx.DragBarPanel( + ctx.Terminal(_logTerminalHandle) + ).InitialSize(12).MinSize(4); + + return [ + ctx.VStack(v => [ + v.DragBarPanel(table).InitialSize(15).MinSize(6), + logPanel + ]).Fill() + ]; + } + + return [table]; } private IEnumerable BuildParametersTab(WidgetContext ctx) @@ -437,6 +464,17 @@ private async Task ConnectToAppHostAsync(int index, CancellationToken cancellati _errorMessage = null; _resources.Clear(); _selectedAppHostIndex = index; + + // Tear down previous log terminal + _logWorkload?.StopStreaming(); + if (_logTerminal is not null) + { + await _logTerminal.DisposeAsync().ConfigureAwait(false); + } + _logWorkload = null; + _logTerminalHandle = null; + _logTerminal = null; + _app?.Invalidate(); try @@ -448,6 +486,15 @@ private async Task ConnectToAppHostAsync(int index, CancellationToken cancellati var connection = _appHosts[index].Connection; + // Create the embedded log terminal for this connection + _logWorkload = new AspireResourceConsoleLogWorkload(connection); + _logTerminal = Hex1bTerminal.CreateBuilder() + .WithWorkload(_logWorkload) + .WithTerminalWidget(out var handle) + .Build(); + _logTerminalHandle = handle; + _ = _logTerminal.RunAsync(cancellationToken); + var snapshots = await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false); _isConnecting = false; foreach (var snapshot in snapshots) @@ -507,6 +554,16 @@ private async Task ConnectToAppHostAsync(int index, CancellationToken cancellati } } + private void SwitchLogStream(string resourceName) + { + if (_logWorkload is null || _logWorkload.CurrentResourceName == resourceName) + { + return; + } + + _logWorkload.StartStreaming(resourceName, _watchCts?.Token ?? CancellationToken.None); + } + private async Task ExecuteResourceCommandAsync(string resourceName, string commandName) { try diff --git a/src/Aspire.Cli/UI/AspireResourceConsoleLogWorkload.cs b/src/Aspire.Cli/UI/AspireResourceConsoleLogWorkload.cs new file mode 100644 index 00000000000..8b4327a44f1 --- /dev/null +++ b/src/Aspire.Cli/UI/AspireResourceConsoleLogWorkload.cs @@ -0,0 +1,157 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using System.Threading.Channels; +using Aspire.Cli.Backchannel; +using Hex1b; + +namespace Aspire.Cli.UI; + +/// +/// A terminal workload adapter that streams console logs from an Aspire resource +/// via the backchannel and renders them in an embedded terminal widget. +/// +internal sealed class AspireResourceConsoleLogWorkload : IHex1bTerminalWorkloadAdapter +{ + private readonly Channel _outputChannel; + private readonly IAppHostAuxiliaryBackchannel _connection; + private CancellationTokenSource? _streamCts; + private bool _disposed; + private string? _currentResourceName; + + public AspireResourceConsoleLogWorkload(IAppHostAuxiliaryBackchannel connection) + { + _connection = connection; + _outputChannel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false + }); + } + + /// + public event Action? Disconnected; + + /// + public async ValueTask> ReadOutputAsync(CancellationToken ct = default) + { + if (_disposed) + { + return ReadOnlyMemory.Empty; + } + + try + { + if (await _outputChannel.Reader.WaitToReadAsync(ct).ConfigureAwait(false)) + { + if (_outputChannel.Reader.TryRead(out var data)) + { + return data; + } + } + } + catch (OperationCanceledException) + { + } + catch (ChannelClosedException) + { + } + + return ReadOnlyMemory.Empty; + } + + /// + public ValueTask WriteInputAsync(ReadOnlyMemory data, CancellationToken ct = default) + { + // Read-only log view — input is ignored + return ValueTask.CompletedTask; + } + + /// + public ValueTask ResizeAsync(int width, int height, CancellationToken ct = default) + { + return ValueTask.CompletedTask; + } + + /// + /// Starts streaming logs for the specified resource. Cancels any previous stream. + /// + public void StartStreaming(string resourceName, CancellationToken cancellationToken) + { + StopStreaming(); + + _currentResourceName = resourceName; + _streamCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var ct = _streamCts.Token; + + // Clear terminal and show header + WriteOutput($"\x1b[2J\x1b[H\x1b[1;35m── Logs: {resourceName} ──\x1b[0m\r\n\r\n"); + + _ = Task.Run(async () => + { + try + { + await foreach (var logLine in _connection.GetResourceLogsAsync(resourceName, follow: true, cancellationToken: ct).ConfigureAwait(false)) + { + var prefix = logLine.IsError + ? "\x1b[31m" // red for errors + : "\x1b[0m"; // default + WriteOutput($"{prefix}{logLine.Content}\x1b[0m\r\n"); + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + WriteOutput($"\r\n\x1b[31mLog stream error: {ex.Message}\x1b[0m\r\n"); + } + }, ct); + } + + /// + /// Stops the current log stream. + /// + public void StopStreaming() + { + if (_streamCts is not null) + { + _streamCts.Cancel(); + _streamCts.Dispose(); + _streamCts = null; + } + + _currentResourceName = null; + } + + /// + /// Gets the name of the resource currently being streamed, if any. + /// + public string? CurrentResourceName => _currentResourceName; + + private void WriteOutput(string text) + { + if (_disposed) + { + return; + } + + var bytes = Encoding.UTF8.GetBytes(text); + _outputChannel.Writer.TryWrite(bytes); + } + + /// + public ValueTask DisposeAsync() + { + if (!_disposed) + { + _disposed = true; + StopStreaming(); + _outputChannel.Writer.TryComplete(); + Disconnected?.Invoke(); + } + + return ValueTask.CompletedTask; + } +} From ee4eaad8e2f82a1ab2294f03c0586cb53369c6e8 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 15:45:48 +1100 Subject: [PATCH 20/34] Use single DragBarPanel for log terminal only --- src/Aspire.Cli/UI/AspireMonitorTui.cs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index 68b1332eb95..52c7953e8c3 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -262,14 +262,12 @@ private IEnumerable BuildResourcesTab(WidgetContext c // If we have a terminal handle, show table+logs in a vertical split if (_logTerminalHandle is not null) { - var logPanel = ctx.DragBarPanel( - ctx.Terminal(_logTerminalHandle) - ).InitialSize(12).MinSize(4); - return [ ctx.VStack(v => [ - v.DragBarPanel(table).InitialSize(15).MinSize(6), - logPanel + table, + v.DragBarPanel( + ctx.Terminal(_logTerminalHandle) + ).InitialSize(12).MinSize(4) ]).Fill() ]; } From 6baedd2dd1a54388fc8d75cb9e084d3922f698a8 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 15:49:53 +1100 Subject: [PATCH 21/34] Move drag bar above terminal: table in DragBarPanel, terminal fills below --- src/Aspire.Cli/UI/AspireMonitorTui.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index 52c7953e8c3..094b629a71f 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -264,10 +264,8 @@ private IEnumerable BuildResourcesTab(WidgetContext c { return [ ctx.VStack(v => [ - table, - v.DragBarPanel( - ctx.Terminal(_logTerminalHandle) - ).InitialSize(12).MinSize(4) + v.DragBarPanel(table).InitialSize(15).MinSize(6), + ctx.Terminal(_logTerminalHandle).Fill() ]).Fill() ]; } From 64bc5dfcfd29d6767e2cd993ecd155773397280d Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 15:55:07 +1100 Subject: [PATCH 22/34] Add heart/broken heart icons to Health column based on status --- src/Aspire.Cli/UI/AspireMonitorTui.cs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index 094b629a71f..4cfc46fe3a0 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -229,7 +229,7 @@ private IEnumerable BuildResourcesTab(WidgetContext c h.Cell("Name").Width(SizeHint.Fill), h.Cell("Type").Fixed(12), h.Cell("State").Fixed(12), - h.Cell("Health").Fixed(12), + h.Cell("Health").Fixed(14), h.Cell("URLs").Width(SizeHint.Fill), h.Cell("Actions").Fixed(20) ]) @@ -237,7 +237,7 @@ private IEnumerable BuildResourcesTab(WidgetContext c r.Cell(resource.DisplayName ?? resource.Name), r.Cell(resource.ResourceType ?? ""), r.Cell(resource.State ?? "Unknown"), - r.Cell(resource.HealthStatus ?? ""), + r.Cell(FormatHealthStatus(resource.HealthStatus)), r.Cell(resource.Urls.Length > 0 ? string.Join(", ", resource.Urls.Select(u => u.Url)) : ""), @@ -601,6 +601,20 @@ private string GetStatusText() return $"{resourceCount} resource(s)"; } + private static string FormatHealthStatus(string? healthStatus) + { + if (string.IsNullOrEmpty(healthStatus)) + { + return ""; + } + + var icon = string.Equals(healthStatus, "Healthy", StringComparison.OrdinalIgnoreCase) + ? "💚" + : "💔"; + + return $"{icon} {healthStatus}"; + } + private static string ShortenPath(string path) { var fileName = Path.GetFileName(path); From b66922d6d1c99126bb94bdb26a8a05036deb8ad4 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 16:02:40 +1100 Subject: [PATCH 23/34] Add E2E test for aspire monitor command - Creates a starter project, starts AppHost with --detach - Enables monitorCommandEnabled feature flag via aspire config - Launches aspire monitor TUI and waits for 'Healthy' text - Ctrl+C exits the TUI, then stops the AppHost --- .../MonitorCommandTests.cs | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/MonitorCommandTests.cs diff --git a/tests/Aspire.Cli.EndToEnd.Tests/MonitorCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/MonitorCommandTests.cs new file mode 100644 index 00000000000..021db961b09 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/MonitorCommandTests.cs @@ -0,0 +1,138 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end tests for the Aspire CLI monitor command (TUI). +/// Each test class runs as a separate CI job for parallelization. +/// +public sealed class MonitorCommandTests(ITestOutputHelper output) +{ + [Fact] + public async Task MonitorShowsHealthyResources() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(MonitorShowsHealthyResources)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + // Pattern searchers for aspire new prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find($"Enter the output path: (./AspireStarterApp): "); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find($"Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find($"Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find($"Do you want to create a test project?"); + + // Pattern searchers for detached run + var waitForAppHostStartedSuccessfully = new CellPatternSearcher() + .Find("AppHost started successfully."); + + var waitForAppHostStoppedSuccessfully = new CellPatternSearcher() + .Find("AppHost stopped successfully."); + + // Pattern searcher for the monitor TUI showing a healthy resource + var waitForHealthyInMonitor = new CellPatternSearcher() + .Find("Healthy"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Enable the monitor command feature flag + sequenceBuilder.Type("aspire config set features.monitorCommandEnabled true -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Create a new project using aspire new + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Enter() // select first template (Starter App) + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Type("AspireStarterApp") + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitForSuccessPrompt(counter); + + // Navigate to the AppHost directory + sequenceBuilder.Type("cd AspireStarterApp/AspireStarterApp.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Start the AppHost in the background using aspire run --detach + sequenceBuilder.Type("aspire run --detach") + .Enter() + .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitForSuccessPrompt(counter); + + // Launch aspire monitor TUI — this takes over the terminal + sequenceBuilder.Type("aspire monitor") + .Enter() + .WaitUntil(s => waitForHealthyInMonitor.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + // Healthy text found — test passed. Send Ctrl+C to exit the TUI. + .Ctrl().Key(Hex1b.Input.Hex1bKey.C) + .WaitForSuccessPrompt(counter); + + // Stop the AppHost + sequenceBuilder.Type("aspire stop") + .Enter() + .WaitUntil(s => waitForAppHostStoppedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(1)) + .WaitForSuccessPrompt(counter); + + // Exit the shell + sequenceBuilder.Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } +} From 7eb961c20cc717acc8b252d650a2349e5963360b Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 18:21:11 +1100 Subject: [PATCH 24/34] Show waiting panel when no AppHosts are running - Replace full UI with centered message explaining monitor will auto-detect AppHosts when they start - Auto-connect to first AppHost when discovered via polling - Only show notification for subsequent AppHosts --- src/Aspire.Cli/UI/AspireMonitorTui.cs | 64 ++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index 4cfc46fe3a0..60431eee03c 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -116,6 +116,12 @@ private Hex1bWidget BuildWidget(RootContext ctx) private Hex1bWidget BuildMainScreen(RootContext ctx) { + // When no AppHosts are connected, show a waiting panel + if (_appHosts.Count == 0) + { + return BuildWaitingForAppHostsPanel(ctx); + } + // Build right-side content depending on selected AppHost state Hex1bWidget rightContent; var selectedAppHost = _selectedAppHostIndex < _appHosts.Count ? _appHosts[_selectedAppHostIndex] : null; @@ -164,6 +170,33 @@ private Hex1bWidget BuildMainScreen(RootContext ctx) ]).Fill(); } + private static Hex1bWidget BuildWaitingForAppHostsPanel(RootContext ctx) + { + return ctx.VStack(outer => [ + ctx.NotificationPanel( + ctx.VStack(v => [ + v.Text("").Fill(), + v.Center( + ctx.VStack(inner => [ + inner.Text(" ⏳ Waiting for AppHosts...").FixedHeight(1), + inner.Text("").FixedHeight(1), + inner.Text(" No running Aspire AppHosts detected.").FixedHeight(1), + inner.Text("").FixedHeight(1), + inner.Text(" Start an AppHost with 'aspire run' and it will").FixedHeight(1), + inner.Text(" appear here automatically.").FixedHeight(1) + ]) + ), + v.Text("").Fill() + ]).Fill() + ).Fill(), + outer.InfoBar(bar => [ + bar.Section("q: " + MonitorCommandStrings.QuitShortcut), + bar.Separator(" │ "), + bar.Section("Polling for AppHosts...").FillWidth() + ]) + ]).Fill(); + } + private IEnumerable BuildAppHostList(WidgetContext nav) { if (_appHosts.Count == 0) @@ -376,6 +409,7 @@ private async Task PollAppHostsAsync(CancellationToken cancellationToken) var path = connection.AppHostInfo!.AppHostPath ?? "Unknown"; if (!knownPaths.Contains(path)) { + var wasEmpty = _appHosts.Count == 0; var entry = new AppHostEntry { DisplayName = ShortenPath(path), @@ -385,18 +419,26 @@ private async Task PollAppHostsAsync(CancellationToken cancellationToken) _appHosts.Add(entry); knownPaths.Add(path); - _notificationStack?.Post( - new Notification("New AppHost Detected", entry.DisplayName) - .Timeout(TimeSpan.FromSeconds(10)) - .PrimaryAction("Connect", async ctx => - { - var idx = _appHosts.IndexOf(entry); - if (idx >= 0) + // Auto-connect if this is the first AppHost + if (wasEmpty) + { + _ = ConnectToAppHostAsync(0, cancellationToken); + } + else + { + _notificationStack?.Post( + new Notification("New AppHost Detected", entry.DisplayName) + .Timeout(TimeSpan.FromSeconds(10)) + .PrimaryAction("Connect", async ctx => { - await ConnectToAppHostAsync(idx, cancellationToken).ConfigureAwait(false); - } - ctx.Dismiss(); - })); + var idx = _appHosts.IndexOf(entry); + if (idx >= 0) + { + await ConnectToAppHostAsync(idx, cancellationToken).ConfigureAwait(false); + } + ctx.Dismiss(); + })); + } _app?.Invalidate(); } From 50ab1a9f668f91ca7a87a830175d37bcde4437e0 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 18:24:19 +1100 Subject: [PATCH 25/34] Render resource URLs as clickable HyperlinkWidgets --- src/Aspire.Cli/UI/AspireMonitorTui.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index 60431eee03c..3d915105c22 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -271,9 +271,7 @@ private IEnumerable BuildResourcesTab(WidgetContext c r.Cell(resource.ResourceType ?? ""), r.Cell(resource.State ?? "Unknown"), r.Cell(FormatHealthStatus(resource.HealthStatus)), - r.Cell(resource.Urls.Length > 0 - ? string.Join(", ", resource.Urls.Select(u => u.Url)) - : ""), + r.Cell(cell => BuildUrlsCell(cell, resource)), r.Cell(cell => cell.HStack(h => [ h.Button("▶").OnClick(e => { @@ -657,6 +655,17 @@ private static string FormatHealthStatus(string? healthStatus) return $"{icon} {healthStatus}"; } + private static Hex1bWidget BuildUrlsCell(TableCellContext cell, ResourceSnapshot resource) + { + if (resource.Urls.Length == 0) + { + return cell.Text(""); + } + + return cell.HStack(h => + resource.Urls.Select(u => (Hex1bWidget)h.Hyperlink(u.Url, u.Url)).ToArray()); + } + private static string ShortenPath(string path) { var fileName = Path.GetFileName(path); From 89d13f4a752749415b6df86f0ac591a50930c92b Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 19:28:16 +1100 Subject: [PATCH 26/34] Add hack reveal transition between splash and main TUI - After the braille splash completes, the main UI is rendered as a WidgetLayer inside a Surface with a HackRevealEffect overlay - Borders and structural characters fade in bottom-up from black - Alphanumeric text appears as scrambled hacker-style characters before settling into the actual content over 4 seconds - Once the reveal completes, the Surface is replaced with the normal interactive widget tree --- src/Aspire.Cli/UI/AspireMonitorTui.cs | 40 ++++- src/Aspire.Cli/UI/HackRevealEffect.cs | 211 ++++++++++++++++++++++++++ 2 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 src/Aspire.Cli/UI/HackRevealEffect.cs diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index 3d915105c22..c9c9f41363e 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -1,6 +1,7 @@ // 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; using Aspire.Cli.Backchannel; using Aspire.Cli.Resources; using Hex1b; @@ -48,6 +49,12 @@ internal sealed class AspireMonitorTui private TerminalWidgetHandle? _logTerminalHandle; private Hex1bTerminal? _logTerminal; + // Hack reveal transition after splash + private bool _revealing; + private long _revealStart; + private readonly HackRevealEffect _hackReveal = new(); + private const double RevealDurationSeconds = 4.0; + public AspireMonitorTui(IAuxiliaryBackchannelMonitor backchannelMonitor, ILogger logger) { _backchannelMonitor = backchannelMonitor; @@ -90,6 +97,9 @@ public async Task RunAsync(CancellationToken cancellationToken) } _showSplash = false; + _revealing = true; + _revealStart = Stopwatch.GetTimestamp(); + _hackReveal.Reset(); _app?.Invalidate(); if (_appHosts.Count > 0) @@ -111,7 +121,35 @@ private Hex1bWidget BuildWidget(RootContext ctx) return _splash.Build(ctx); } - return ctx.ThemePanel(AspireTheme.Apply, BuildMainScreen(ctx)).Fill(); + var themedMain = ctx.ThemePanel(AspireTheme.Apply, BuildMainScreen(ctx)).Fill(); + + if (_revealing) + { + var progress = Math.Clamp( + Stopwatch.GetElapsedTime(_revealStart).TotalSeconds / RevealDurationSeconds, 0, 1); + + if (progress >= 1.0) + { + _revealing = false; + return themedMain; + } + + _hackReveal.Update(1, 1); + + return ctx.Surface(s => + { + _hackReveal.Update(s.Width, s.Height); + return + [ + s.WidgetLayer(themedMain), + s.Layer(_hackReveal.GetCompute(progress)) + ]; + }) + .RedrawAfter(16) + .Fill(); + } + + return themedMain; } private Hex1bWidget BuildMainScreen(RootContext ctx) diff --git a/src/Aspire.Cli/UI/HackRevealEffect.cs b/src/Aspire.Cli/UI/HackRevealEffect.cs new file mode 100644 index 00000000000..9053cf10b9f --- /dev/null +++ b/src/Aspire.Cli/UI/HackRevealEffect.cs @@ -0,0 +1,211 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Hex1b.Surfaces; +using Hex1b.Theming; + +namespace Aspire.Cli.UI; + +/// +/// Transition effect that reveals the TUI from black after the splash screen. +/// Borders and structural characters fade in first from the bottom up, +/// then alphanumeric text appears as rapidly scrambled characters before +/// settling into the actual content. +/// +internal sealed class HackRevealEffect +{ + private struct CellInfo + { + public bool HasContent; + public bool IsAlphaNumeric; + public string Character; + public Hex1bColor? Foreground; + public Hex1bColor? Background; + public double BgRevealTime; + public double CharRevealTime; + public double SettleTime; + public int ScrambleSeed; + } + + private CellInfo[,]? _cells; + private int _width, _height; + private bool _contentDetected; + private readonly Random _rng = new(); + + private const string ScrambleChars = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef@#$%&!?<>{}[]~"; + + public void Reset() + { + _cells = null; + _contentDetected = false; + } + + public void Update(int width, int height) + { + if (_cells is not null && _width == width && _height == height) + { + return; + } + + _width = width; + _height = height; + _cells = new CellInfo[width, height]; + _contentDetected = false; + } + + public CellCompute GetCompute(double progress) + { + return ctx => + { + var below = ctx.GetBelow(); + + if (!_contentDetected && _cells is not null) + { + bool hasVisibleChar = !below.IsContinuation + && below.Character != "\uE000" + && !string.IsNullOrEmpty(below.Character) + && below.Character != " "; + + double rowFrac = 1.0 - (double)ctx.Y / Math.Max(1, _height - 1); + double jitter = _rng.NextDouble() * 0.06; + + if (hasVisibleChar) + { + bool isAlpha = !IsStructuralChar(below.Character); + + double bgReveal = rowFrac * 0.45 + jitter; + double charReveal = isAlpha + ? 0.55 + _rng.NextDouble() * 0.10 + : bgReveal; + double settle = isAlpha + ? charReveal + 0.20 + _rng.NextDouble() * 0.15 + : charReveal; + + _cells[ctx.X, ctx.Y] = new CellInfo + { + HasContent = true, + IsAlphaNumeric = isAlpha, + Character = below.Character, + Foreground = below.Foreground, + Background = below.Background, + BgRevealTime = bgReveal, + CharRevealTime = charReveal, + SettleTime = settle, + ScrambleSeed = _rng.Next() + }; + } + else + { + _cells[ctx.X, ctx.Y] = new CellInfo + { + HasContent = false, + Background = below.Background, + BgRevealTime = rowFrac * 0.45 + jitter, + }; + } + + if (ctx.X == _width - 1 && ctx.Y == _height - 1) + { + _contentDetected = true; + } + + return new SurfaceCell(" ", null, Hex1bColor.FromRgb(0, 0, 0)); + } + + if (_cells is null) + { + return new SurfaceCell(" ", null, null); + } + + ref var cell = ref _cells[ctx.X, ctx.Y]; + + if (progress >= 1.0) + { + return below; + } + + if (progress < cell.BgRevealTime) + { + return new SurfaceCell(" ", null, Hex1bColor.FromRgb(0, 0, 0)); + } + + double bgFade = Math.Clamp((progress - cell.BgRevealTime) / 0.18, 0, 1); + var bg = FadeFromBlack(cell.Background, bgFade); + + if (!cell.HasContent) + { + return new SurfaceCell(" ", null, bg); + } + + if (progress < cell.CharRevealTime) + { + return new SurfaceCell(" ", null, bg); + } + + double fgFade = Math.Clamp((progress - cell.CharRevealTime) / 0.08, 0, 1); + var fg = FadeFromBlack(cell.Foreground, fgFade); + + if (!cell.IsAlphaNumeric) + { + return new SurfaceCell(cell.Character, fg, bg) with + { + Attributes = below.Attributes, + DisplayWidth = below.DisplayWidth + }; + } + + if (progress >= cell.SettleTime) + { + return new SurfaceCell(cell.Character, fg, bg) with + { + Attributes = below.Attributes, + DisplayWidth = below.DisplayWidth + }; + } + + int idx = (int)(progress * 200 + cell.ScrambleSeed) % ScrambleChars.Length; + return new SurfaceCell(ScrambleChars[idx].ToString(), fg, bg); + }; + } + + private static bool IsStructuralChar(string ch) + { + if (ch.Length == 0) + { + return false; + } + + char c = ch[0]; + if (c is >= '\u2500' and <= '\u259F') + { + return true; + } + + if (c is >= '\u2800' and <= '\u28FF') + { + return true; + } + + if (c is '|' or '+' or '-' or '=' or '_') + { + return true; + } + + return false; + } + + private static Hex1bColor? FadeFromBlack(Hex1bColor? target, double amount) + { + if (target is null || target.Value.IsDefault) + { + return amount >= 1.0 ? target : Hex1bColor.FromRgb(0, 0, 0); + } + + var c = target.Value; + return Hex1bColor.FromRgb( + (byte)(c.R * amount), + (byte)(c.G * amount), + (byte)(c.B * amount)); + } +} From 609a10653bf27e9232f5b9724ec434db282e46bc Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 19:31:13 +1100 Subject: [PATCH 27/34] Fix crash: skip NotificationPanel during reveal phase NotificationPanel requires a ZStack parent which isn't available when rendering inside a WidgetLayer. Pass interactive flag to BuildMainScreen to conditionally omit NotificationPanel during the hack reveal transition. --- src/Aspire.Cli/UI/AspireMonitorTui.cs | 55 ++++++++++++++------------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index c9c9f41363e..4bc5b665186 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -121,8 +121,6 @@ private Hex1bWidget BuildWidget(RootContext ctx) return _splash.Build(ctx); } - var themedMain = ctx.ThemePanel(AspireTheme.Apply, BuildMainScreen(ctx)).Fill(); - if (_revealing) { var progress = Math.Clamp( @@ -131,9 +129,12 @@ private Hex1bWidget BuildWidget(RootContext ctx) if (progress >= 1.0) { _revealing = false; - return themedMain; + return ctx.ThemePanel(AspireTheme.Apply, BuildMainScreen(ctx, interactive: true)).Fill(); } + // During reveal, build without NotificationPanel (it requires ZStack context) + var revealContent = ctx.ThemePanel(AspireTheme.Apply, BuildMainScreen(ctx, interactive: false)).Fill(); + _hackReveal.Update(1, 1); return ctx.Surface(s => @@ -141,7 +142,7 @@ private Hex1bWidget BuildWidget(RootContext ctx) _hackReveal.Update(s.Width, s.Height); return [ - s.WidgetLayer(themedMain), + s.WidgetLayer(revealContent), s.Layer(_hackReveal.GetCompute(progress)) ]; }) @@ -149,15 +150,15 @@ private Hex1bWidget BuildWidget(RootContext ctx) .Fill(); } - return themedMain; + return ctx.ThemePanel(AspireTheme.Apply, BuildMainScreen(ctx, interactive: true)).Fill(); } - private Hex1bWidget BuildMainScreen(RootContext ctx) + private Hex1bWidget BuildMainScreen(RootContext ctx, bool interactive) { // When no AppHosts are connected, show a waiting panel if (_appHosts.Count == 0) { - return BuildWaitingForAppHostsPanel(ctx); + return BuildWaitingForAppHostsPanel(ctx, interactive); } // Build right-side content depending on selected AppHost state @@ -179,8 +180,10 @@ private Hex1bWidget BuildMainScreen(RootContext ctx) rightContent = tabPanel; } - // Wrap content in notification panel - var mainContent = ctx.NotificationPanel(rightContent).Fill(); + // NotificationPanel requires ZStack context — only use in interactive mode + var mainContent = interactive + ? ctx.NotificationPanel(rightContent).Fill() + : rightContent; // AppHost list for the left pane var appHostList = ctx.VStack(nav => [ @@ -208,25 +211,25 @@ private Hex1bWidget BuildMainScreen(RootContext ctx) ]).Fill(); } - private static Hex1bWidget BuildWaitingForAppHostsPanel(RootContext ctx) + private static Hex1bWidget BuildWaitingForAppHostsPanel(RootContext ctx, bool interactive) { + var centerContent = ctx.VStack(v => [ + v.Text("").Fill(), + v.Center( + ctx.VStack(inner => [ + inner.Text(" ⏳ Waiting for AppHosts...").FixedHeight(1), + inner.Text("").FixedHeight(1), + inner.Text(" No running Aspire AppHosts detected.").FixedHeight(1), + inner.Text("").FixedHeight(1), + inner.Text(" Start an AppHost with 'aspire run' and it will").FixedHeight(1), + inner.Text(" appear here automatically.").FixedHeight(1) + ]) + ), + v.Text("").Fill() + ]).Fill(); + return ctx.VStack(outer => [ - ctx.NotificationPanel( - ctx.VStack(v => [ - v.Text("").Fill(), - v.Center( - ctx.VStack(inner => [ - inner.Text(" ⏳ Waiting for AppHosts...").FixedHeight(1), - inner.Text("").FixedHeight(1), - inner.Text(" No running Aspire AppHosts detected.").FixedHeight(1), - inner.Text("").FixedHeight(1), - inner.Text(" Start an AppHost with 'aspire run' and it will").FixedHeight(1), - inner.Text(" appear here automatically.").FixedHeight(1) - ]) - ), - v.Text("").Fill() - ]).Fill() - ).Fill(), + interactive ? ctx.NotificationPanel(centerContent).Fill() : centerContent, outer.InfoBar(bar => [ bar.Section("q: " + MonitorCommandStrings.QuitShortcut), bar.Separator(" │ "), From d2cf25f800fcb32b35f1ecd2dbe605c4d1406f79 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 19:33:42 +1100 Subject: [PATCH 28/34] Fix hack reveal: remove stale Update(1,1) that reset cells every frame The Update(1,1) call before the Surface callback was resizing the cell array to 1x1 on every frame, destroying the captured content from the WidgetLayer. The real Update with correct dimensions happens inside the Surface callback. --- src/Aspire.Cli/UI/AspireMonitorTui.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index 4bc5b665186..13c9e80b7fe 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -135,8 +135,6 @@ private Hex1bWidget BuildWidget(RootContext ctx) // During reveal, build without NotificationPanel (it requires ZStack context) var revealContent = ctx.ThemePanel(AspireTheme.Apply, BuildMainScreen(ctx, interactive: false)).Fill(); - _hackReveal.Update(1, 1); - return ctx.Surface(s => { _hackReveal.Update(s.Width, s.Height); From 19722ca3929a8cc4acf6b31afc28c4abb713dfc5 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 19:38:33 +1100 Subject: [PATCH 29/34] Make hack reveal react to streaming content updates Instead of capturing a static snapshot on the first frame, the effect now continuously checks for new content appearing in cells. When new content streams in (e.g., resources loading), it gets assigned reveal times relative to the current progress so it smoothly joins the animation. Content changes in already-revealed cells update in place. --- src/Aspire.Cli/UI/HackRevealEffect.cs | 113 +++++++++++++------------- 1 file changed, 57 insertions(+), 56 deletions(-) diff --git a/src/Aspire.Cli/UI/HackRevealEffect.cs b/src/Aspire.Cli/UI/HackRevealEffect.cs index 9053cf10b9f..1db2053d191 100644 --- a/src/Aspire.Cli/UI/HackRevealEffect.cs +++ b/src/Aspire.Cli/UI/HackRevealEffect.cs @@ -16,6 +16,7 @@ internal sealed class HackRevealEffect { private struct CellInfo { + public bool Initialized; public bool HasContent; public bool IsAlphaNumeric; public string Character; @@ -29,7 +30,6 @@ private struct CellInfo private CellInfo[,]? _cells; private int _width, _height; - private bool _contentDetected; private readonly Random _rng = new(); private const string ScrambleChars = @@ -38,7 +38,6 @@ private struct CellInfo public void Reset() { _cells = null; - _contentDetected = false; } public void Update(int width, int height) @@ -51,7 +50,6 @@ public void Update(int width, int height) _width = width; _height = height; _cells = new CellInfo[width, height]; - _contentDetected = false; } public CellCompute GetCompute(double progress) @@ -60,65 +58,68 @@ public CellCompute GetCompute(double progress) { var below = ctx.GetBelow(); - if (!_contentDetected && _cells is not null) + if (_cells is null) { - bool hasVisibleChar = !below.IsContinuation - && below.Character != "\uE000" - && !string.IsNullOrEmpty(below.Character) - && below.Character != " "; + return new SurfaceCell(" ", null, null); + } - double rowFrac = 1.0 - (double)ctx.Y / Math.Max(1, _height - 1); - double jitter = _rng.NextDouble() * 0.06; + ref var cell = ref _cells[ctx.X, ctx.Y]; - if (hasVisibleChar) - { - bool isAlpha = !IsStructuralChar(below.Character); - - double bgReveal = rowFrac * 0.45 + jitter; - double charReveal = isAlpha - ? 0.55 + _rng.NextDouble() * 0.10 - : bgReveal; - double settle = isAlpha - ? charReveal + 0.20 + _rng.NextDouble() * 0.15 - : charReveal; - - _cells[ctx.X, ctx.Y] = new CellInfo - { - HasContent = true, - IsAlphaNumeric = isAlpha, - Character = below.Character, - Foreground = below.Foreground, - Background = below.Background, - BgRevealTime = bgReveal, - CharRevealTime = charReveal, - SettleTime = settle, - ScrambleSeed = _rng.Next() - }; - } - else - { - _cells[ctx.X, ctx.Y] = new CellInfo - { - HasContent = false, - Background = below.Background, - BgRevealTime = rowFrac * 0.45 + jitter, - }; - } - - if (ctx.X == _width - 1 && ctx.Y == _height - 1) - { - _contentDetected = true; - } + // Check if this cell has new content that wasn't there before + bool hasVisibleChar = !below.IsContinuation + && below.Character != "\uE000" + && !string.IsNullOrEmpty(below.Character) + && below.Character != " "; - return new SurfaceCell(" ", null, Hex1bColor.FromRgb(0, 0, 0)); + if (hasVisibleChar && !cell.HasContent) + { + // New content appeared — assign reveal times relative to current progress + bool isAlpha = !IsStructuralChar(below.Character); + double jitter = _rng.NextDouble() * 0.04; + + // Start revealing shortly after current progress + double bgReveal = Math.Max(cell.BgRevealTime, progress + jitter); + double charReveal = isAlpha + ? bgReveal + 0.08 + _rng.NextDouble() * 0.06 + : bgReveal; + double settle = isAlpha + ? charReveal + 0.15 + _rng.NextDouble() * 0.10 + : charReveal; + + cell = new CellInfo + { + HasContent = true, + IsAlphaNumeric = isAlpha, + Character = below.Character, + Foreground = below.Foreground, + Background = below.Background, + BgRevealTime = bgReveal, + CharRevealTime = charReveal, + SettleTime = settle, + ScrambleSeed = _rng.Next() + }; } - - if (_cells is null) + else if (hasVisibleChar && cell.HasContent && cell.Character != below.Character) { - return new SurfaceCell(" ", null, null); + // Content changed — update the target character but keep timing + cell.Character = below.Character; + cell.Foreground = below.Foreground; + cell.Background = below.Background; } + else if (!cell.Initialized) + { + // First time seeing this cell — set up background reveal timing + double rowFrac = 1.0 - (double)ctx.Y / Math.Max(1, _height - 1); + double jitter = _rng.NextDouble() * 0.06; - ref var cell = ref _cells[ctx.X, ctx.Y]; + cell = new CellInfo + { + Initialized = true, + HasContent = false, + Background = below.Background, + BgRevealTime = rowFrac * 0.45 + jitter, + }; + } if (progress >= 1.0) { @@ -148,7 +149,7 @@ public CellCompute GetCompute(double progress) if (!cell.IsAlphaNumeric) { - return new SurfaceCell(cell.Character, fg, bg) with + return new SurfaceCell(cell.Character ?? " ", fg, bg) with { Attributes = below.Attributes, DisplayWidth = below.DisplayWidth @@ -157,7 +158,7 @@ public CellCompute GetCompute(double progress) if (progress >= cell.SettleTime) { - return new SurfaceCell(cell.Character, fg, bg) with + return new SurfaceCell(cell.Character ?? " ", fg, bg) with { Attributes = below.Attributes, DisplayWidth = below.DisplayWidth From d8e3826d2b7fc5b8d10061838c8085f50fe37345 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 19:40:57 +1100 Subject: [PATCH 30/34] Fix reveal flickering: restore two-phase capture then reveal Phase 1 (first frame): full-screen capture of WidgetLayer content, all cells return black. Random timing values are locked in once. Phase 2 (subsequent frames): reveal from cached data using the locked-in timing. Empty cells are still promoted to content cells when new data streams in, with reveal times relative to current progress so they smoothly join the animation. --- src/Aspire.Cli/UI/HackRevealEffect.cs | 140 +++++++++++++++++--------- 1 file changed, 91 insertions(+), 49 deletions(-) diff --git a/src/Aspire.Cli/UI/HackRevealEffect.cs b/src/Aspire.Cli/UI/HackRevealEffect.cs index 1db2053d191..b0939083e17 100644 --- a/src/Aspire.Cli/UI/HackRevealEffect.cs +++ b/src/Aspire.Cli/UI/HackRevealEffect.cs @@ -16,7 +16,6 @@ internal sealed class HackRevealEffect { private struct CellInfo { - public bool Initialized; public bool HasContent; public bool IsAlphaNumeric; public string Character; @@ -30,6 +29,7 @@ private struct CellInfo private CellInfo[,]? _cells; private int _width, _height; + private bool _initialScanDone; private readonly Random _rng = new(); private const string ScrambleChars = @@ -38,6 +38,7 @@ private struct CellInfo public void Reset() { _cells = null; + _initialScanDone = false; } public void Update(int width, int height) @@ -50,6 +51,7 @@ public void Update(int width, int height) _width = width; _height = height; _cells = new CellInfo[width, height]; + _initialScanDone = false; } public CellCompute GetCompute(double progress) @@ -65,62 +67,57 @@ public CellCompute GetCompute(double progress) ref var cell = ref _cells[ctx.X, ctx.Y]; - // Check if this cell has new content that wasn't there before - bool hasVisibleChar = !below.IsContinuation - && below.Character != "\uE000" - && !string.IsNullOrEmpty(below.Character) - && below.Character != " "; - - if (hasVisibleChar && !cell.HasContent) + // Phase 1: Initial full-screen capture — show all black + if (!_initialScanDone) { - // New content appeared — assign reveal times relative to current progress - bool isAlpha = !IsStructuralChar(below.Character); - double jitter = _rng.NextDouble() * 0.04; - - // Start revealing shortly after current progress - double bgReveal = Math.Max(cell.BgRevealTime, progress + jitter); - double charReveal = isAlpha - ? bgReveal + 0.08 + _rng.NextDouble() * 0.06 - : bgReveal; - double settle = isAlpha - ? charReveal + 0.15 + _rng.NextDouble() * 0.10 - : charReveal; - - cell = new CellInfo + CaptureCell(ref cell, below, ctx.Y); + + // Mark scan complete after the last cell + if (ctx.X == _width - 1 && ctx.Y == _height - 1) { - HasContent = true, - IsAlphaNumeric = isAlpha, - Character = below.Character, - Foreground = below.Foreground, - Background = below.Background, - BgRevealTime = bgReveal, - CharRevealTime = charReveal, - SettleTime = settle, - ScrambleSeed = _rng.Next() - }; - } - else if (hasVisibleChar && cell.HasContent && cell.Character != below.Character) - { - // Content changed — update the target character but keep timing - cell.Character = below.Character; - cell.Foreground = below.Foreground; - cell.Background = below.Background; + _initialScanDone = true; + } + + return new SurfaceCell(" ", null, Hex1bColor.FromRgb(0, 0, 0)); } - else if (!cell.Initialized) + + // Phase 2: Reveal from cache, but promote empty cells if new content appears + if (!cell.HasContent) { - // First time seeing this cell — set up background reveal timing - double rowFrac = 1.0 - (double)ctx.Y / Math.Max(1, _height - 1); - double jitter = _rng.NextDouble() * 0.06; + bool hasVisibleChar = !below.IsContinuation + && below.Character != "\uE000" + && !string.IsNullOrEmpty(below.Character) + && below.Character != " "; - cell = new CellInfo + if (hasVisibleChar) { - Initialized = true, - HasContent = false, - Background = below.Background, - BgRevealTime = rowFrac * 0.45 + jitter, - }; + // New content streamed in — assign reveal times from current progress + bool isAlpha = !IsStructuralChar(below.Character); + double jitter = _rng.NextDouble() * 0.04; + double bgReveal = progress + jitter; + double charReveal = isAlpha + ? bgReveal + 0.08 + _rng.NextDouble() * 0.06 + : bgReveal; + double settle = isAlpha + ? charReveal + 0.15 + _rng.NextDouble() * 0.10 + : charReveal; + + cell = new CellInfo + { + HasContent = true, + IsAlphaNumeric = isAlpha, + Character = below.Character, + Foreground = below.Foreground, + Background = below.Background, + BgRevealTime = bgReveal, + CharRevealTime = charReveal, + SettleTime = settle, + ScrambleSeed = _rng.Next() + }; + } } + // Render from cached cell data if (progress >= 1.0) { return below; @@ -170,6 +167,51 @@ public CellCompute GetCompute(double progress) }; } + private void CaptureCell(ref CellInfo cell, SurfaceCell below, int y) + { + bool hasVisibleChar = !below.IsContinuation + && below.Character != "\uE000" + && !string.IsNullOrEmpty(below.Character) + && below.Character != " "; + + double rowFrac = 1.0 - (double)y / Math.Max(1, _height - 1); + double jitter = _rng.NextDouble() * 0.06; + + if (hasVisibleChar) + { + bool isAlpha = !IsStructuralChar(below.Character); + double bgReveal = rowFrac * 0.45 + jitter; + double charReveal = isAlpha + ? 0.55 + _rng.NextDouble() * 0.10 + : bgReveal; + double settle = isAlpha + ? charReveal + 0.20 + _rng.NextDouble() * 0.15 + : charReveal; + + cell = new CellInfo + { + HasContent = true, + IsAlphaNumeric = isAlpha, + Character = below.Character, + Foreground = below.Foreground, + Background = below.Background, + BgRevealTime = bgReveal, + CharRevealTime = charReveal, + SettleTime = settle, + ScrambleSeed = _rng.Next() + }; + } + else + { + cell = new CellInfo + { + HasContent = false, + Background = below.Background, + BgRevealTime = rowFrac * 0.45 + jitter, + }; + } + } + private static bool IsStructuralChar(string ch) { if (ch.Length == 0) From aa9abc385bfa95516f1d6543e922fb0555af97e5 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 20:10:16 +1100 Subject: [PATCH 31/34] Enable Hex1b diagnostics on the monitor TUI --- src/Aspire.Cli/UI/AspireMonitorTui.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index 13c9e80b7fe..ea3c7bcb81c 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -77,6 +77,7 @@ public async Task RunAsync(CancellationToken cancellationToken) .ToList(); await using var terminal = Hex1bTerminal.CreateBuilder() + .WithDiagnostics("aspire-monitor") .WithHex1bApp((app, options) => { _app = app; From 1a2d27165bd37a6496b8e1bd76fee1e104b7c027 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 20:13:59 +1100 Subject: [PATCH 32/34] Force-enable Hex1b diagnostics in all build configurations --- src/Aspire.Cli/UI/AspireMonitorTui.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireMonitorTui.cs index ea3c7bcb81c..451d1d0950d 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireMonitorTui.cs @@ -77,7 +77,7 @@ public async Task RunAsync(CancellationToken cancellationToken) .ToList(); await using var terminal = Hex1bTerminal.CreateBuilder() - .WithDiagnostics("aspire-monitor") + .WithDiagnostics("aspire-monitor", forceEnable: true) .WithHex1bApp((app, options) => { _app = app; From ee06daf6456fdf314903a34a2480cdd1ddaf9408 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 11 Feb 2026 12:04:07 +1100 Subject: [PATCH 33/34] Update Hex1b packages to 0.79.0 --- Directory.Packages.props | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 8e4674e5743..d57e1111bb9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -98,9 +98,9 @@ - - - + + + From fe51656788949bfc8855ab4305d6e10e47f04be9 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 12 Feb 2026 13:01:39 +1100 Subject: [PATCH 34/34] Add DragBarPanel for logs, PID in header, repo root discovery with worktree support, vscode:// link, clean up log formatting --- Directory.Packages.props | 6 +- .../AppHostAuxiliaryBackchannel.cs | 21 + .../IAppHostAuxiliaryBackchannel.cs | 6 + .../{MonitorCommand.cs => AtopCommand.cs} | 12 +- src/Aspire.Cli/Commands/RootCommand.cs | 3 +- src/Aspire.Cli/KnownFeatures.cs | 2 +- src/Aspire.Cli/Program.cs | 1 + ...reMonitorSplash.cs => AspireAtopSplash.cs} | 4 +- .../{AspireMonitorTui.cs => AspireAtopTui.cs} | 420 ++++++++++++++---- src/Aspire.Cli/UI/AspireTheme.cs | 100 ++++- src/Aspire.Cli/Utils/GitBranchHelper.cs | 59 +++ .../Backchannel/AppHostRpcTarget.cs | 10 + .../AuxiliaryBackchannelRpcTarget.cs | 54 ++- .../Backchannel/BackchannelDataTypes.cs | 5 + .../Backchannel/BackchannelLoggerProvider.cs | 31 +- .../DistributedApplicationBuilder.cs | 3 +- ...torCommandTests.cs => AtopCommandTests.cs} | 14 +- 17 files changed, 621 insertions(+), 130 deletions(-) rename src/Aspire.Cli/Commands/{MonitorCommand.cs => AtopCommand.cs} (75%) rename src/Aspire.Cli/UI/{AspireMonitorSplash.cs => AspireAtopSplash.cs} (99%) rename src/Aspire.Cli/UI/{AspireMonitorTui.cs => AspireAtopTui.cs} (63%) create mode 100644 src/Aspire.Cli/Utils/GitBranchHelper.cs rename tests/Aspire.Cli.EndToEnd.Tests/{MonitorCommandTests.cs => AtopCommandTests.cs} (92%) diff --git a/Directory.Packages.props b/Directory.Packages.props index d57e1111bb9..c43ea3eed3a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -98,9 +98,9 @@ - - - + + + diff --git a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs index 02c0cab6a27..baa05de8b18 100644 --- a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs @@ -393,6 +393,27 @@ public async Task CallResourceMcpToolAsync( cancellationToken).ConfigureAwait(false); } + /// + public async Task?> GetAppHostLogEntriesAsync(CancellationToken cancellationToken = default) + { + var rpc = EnsureConnected(); + + _logger?.LogDebug("Requesting AppHost log entries"); + + try + { + return await rpc.InvokeWithCancellationAsync>( + "GetAppHostLogEntriesAsync", + [], + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger?.LogDebug(ex, "AppHost log streaming is not available. The AppHost may be running an older version."); + return null; + } + } + #region V2 API Methods /// diff --git a/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs index 6c3d8448b9c..dc472b7b49d 100644 --- a/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs @@ -133,4 +133,10 @@ Task WaitForResourceAsync( string status, int timeoutSeconds, CancellationToken cancellationToken = default); + /// Gets AppHost log entries streamed from the hosting process. + /// Returns null if the AppHost does not support log streaming. + /// + /// Cancellation token. + /// An async enumerable of log entries, or null if not supported. + Task?> GetAppHostLogEntriesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Aspire.Cli/Commands/MonitorCommand.cs b/src/Aspire.Cli/Commands/AtopCommand.cs similarity index 75% rename from src/Aspire.Cli/Commands/MonitorCommand.cs rename to src/Aspire.Cli/Commands/AtopCommand.cs index 63c4f52749d..a2aae57bb8d 100644 --- a/src/Aspire.Cli/Commands/MonitorCommand.cs +++ b/src/Aspire.Cli/Commands/AtopCommand.cs @@ -13,20 +13,20 @@ namespace Aspire.Cli.Commands; -internal sealed class MonitorCommand : BaseCommand +internal sealed class AtopCommand : BaseCommand { private readonly IAuxiliaryBackchannelMonitor _backchannelMonitor; - private readonly ILogger _logger; + private readonly ILogger _logger; - public MonitorCommand( + public AtopCommand( IInteractionService interactionService, IAuxiliaryBackchannelMonitor backchannelMonitor, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, AspireCliTelemetry telemetry, - ILogger logger) - : base("monitor", MonitorCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) + ILogger logger) + : base("atop", MonitorCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { _backchannelMonitor = backchannelMonitor; _logger = logger; @@ -36,7 +36,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell { using var activity = Telemetry.StartDiagnosticActivity(Name); - var tui = new AspireMonitorTui(_backchannelMonitor, _logger); + var tui = new AspireAtopTui(_backchannelMonitor, _logger); await tui.RunAsync(cancellationToken).ConfigureAwait(false); return ExitCodeConstants.Success; diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index a011f98b209..70fce3a2027 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -131,6 +131,7 @@ public RootCommand( SdkCommand sdkCommand, SetupCommand setupCommand, MonitorCommand monitorCommand, + AtopCommand atopCommand, ExtensionInternalCommand extensionInternalCommand, IFeatures featureFlags, IInteractionService interactionService) @@ -223,7 +224,7 @@ public RootCommand( if (featureFlags.IsFeatureEnabled(KnownFeatures.MonitorCommandEnabled, false)) { - Subcommands.Add(monitorCommand); + Subcommands.Add(atopCommand); } } diff --git a/src/Aspire.Cli/KnownFeatures.cs b/src/Aspire.Cli/KnownFeatures.cs index d933b3384fb..217653bcfe9 100644 --- a/src/Aspire.Cli/KnownFeatures.cs +++ b/src/Aspire.Cli/KnownFeatures.cs @@ -117,7 +117,7 @@ internal static class KnownFeatures [MonitorCommandEnabled] = new( MonitorCommandEnabled, - "Enable or disable the 'aspire monitor' command for launching a TUI to monitor running AppHosts and resources", + "Enable or disable the 'aspire atop' command for launching a TUI to monitor running AppHosts and resources", DefaultValue: false) }; diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 51bcccc4d1a..bbc8b8616fb 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -375,6 +375,7 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/UI/AspireMonitorSplash.cs b/src/Aspire.Cli/UI/AspireAtopSplash.cs similarity index 99% rename from src/Aspire.Cli/UI/AspireMonitorSplash.cs rename to src/Aspire.Cli/UI/AspireAtopSplash.cs index c707439083c..863e5a31bae 100644 --- a/src/Aspire.Cli/UI/AspireMonitorSplash.cs +++ b/src/Aspire.Cli/UI/AspireAtopSplash.cs @@ -12,7 +12,7 @@ namespace Aspire.Cli.UI; /// Braille particles whirlwind into position, crossfade to half-blocks, /// then dissolve and fall with gravity. /// -internal sealed class AspireMonitorSplash +internal sealed class AspireAtopSplash { // Phase timing (ms from start) private const int WhirlwindDurationMs = 2200; @@ -58,7 +58,7 @@ private sealed class Particle // Braille dot bit positions: index = col*4 + row private static readonly int[] s_brailleBits = [0x01, 0x02, 0x04, 0x40, 0x08, 0x10, 0x20, 0x80]; - public AspireMonitorSplash() + public AspireAtopSplash() { _startTicks = Environment.TickCount64; diff --git a/src/Aspire.Cli/UI/AspireMonitorTui.cs b/src/Aspire.Cli/UI/AspireAtopTui.cs similarity index 63% rename from src/Aspire.Cli/UI/AspireMonitorTui.cs rename to src/Aspire.Cli/UI/AspireAtopTui.cs index 451d1d0950d..0fd06f871ab 100644 --- a/src/Aspire.Cli/UI/AspireMonitorTui.cs +++ b/src/Aspire.Cli/UI/AspireAtopTui.cs @@ -4,13 +4,24 @@ using System.Diagnostics; using Aspire.Cli.Backchannel; using Aspire.Cli.Resources; +using Aspire.Cli.Utils; using Hex1b; using Hex1b.Layout; +using Hex1b.Logging; using Hex1b.Widgets; using Microsoft.Extensions.Logging; namespace Aspire.Cli.UI; +/// +/// Represents a summary of resource counts for an AppHost. +/// +internal sealed class ResourceSummary +{ + public int TotalCount { get; set; } + public int RunningCount { get; set; } +} + /// /// Represents an AppHost entry in the drawer list. /// @@ -20,12 +31,19 @@ internal sealed class AppHostEntry public required string FullPath { get; init; } public required IAppHostAuxiliaryBackchannel Connection { get; set; } public bool IsOffline { get; set; } + public string? Branch { get; set; } + public ResourceSummary? Summary { get; set; } + public string? DashboardUrl { get; set; } + public string? AspireVersion { get; set; } + public DateTimeOffset? StartedAt { get; set; } + public string? Pid { get; set; } + public string? RepositoryRoot { get; set; } } /// /// Main TUI for the aspire monitor command. /// -internal sealed class AspireMonitorTui +internal sealed class AspireAtopTui { private readonly IAuxiliaryBackchannelMonitor _backchannelMonitor; private readonly ILogger _logger; @@ -37,17 +55,15 @@ internal sealed class AspireMonitorTui private bool _isConnecting; private string? _errorMessage; private bool _showSplash = true; - private readonly AspireMonitorSplash _splash = new(); + private readonly AspireAtopSplash _splash = new(); private object? _focusedResourceKey; private object? _focusedParameterKey; private CancellationTokenSource? _watchCts; private NotificationStack? _notificationStack; private Hex1bApp? _app; - - // Embedded terminal for resource console logs - private AspireResourceConsoleLogWorkload? _logWorkload; - private TerminalWidgetHandle? _logTerminalHandle; - private Hex1bTerminal? _logTerminal; + private IHex1bLogStore? _appHostLogStore; + private ILoggerFactory? _appHostLoggerFactory; + private bool _appHostLogsAvailable; // Hack reveal transition after splash private bool _revealing; @@ -55,7 +71,7 @@ internal sealed class AspireMonitorTui private readonly HackRevealEffect _hackReveal = new(); private const double RevealDurationSeconds = 4.0; - public AspireMonitorTui(IAuxiliaryBackchannelMonitor backchannelMonitor, ILogger logger) + public AspireAtopTui(IAuxiliaryBackchannelMonitor backchannelMonitor, ILogger logger) { _backchannelMonitor = backchannelMonitor; _logger = logger; @@ -76,8 +92,12 @@ public async Task RunAsync(CancellationToken cancellationToken) }) .ToList(); + // Resolve git branches and resource summaries in the background (non-blocking) + _ = ResolveBranchesAsync(_appHosts, cancellationToken); + _ = FetchResourceSummariesAsync(_appHosts, cancellationToken); + await using var terminal = Hex1bTerminal.CreateBuilder() - .WithDiagnostics("aspire-monitor", forceEnable: true) + .WithDiagnostics("aspire-atop", forceEnable: true) .WithHex1bApp((app, options) => { _app = app; @@ -176,7 +196,15 @@ private Hex1bWidget BuildMainScreen(RootContext ctx, bool interactive) tabs.Tab(MonitorCommandStrings.ParametersTab, c => BuildParametersTab(c)) ]).Fill(); - rightContent = tabPanel; + // Logger panel for AppHost logs + Hex1bWidget logPanel = _appHostLogsAvailable && _appHostLogStore is not null + ? ctx.LoggerPanel(_appHostLogStore).Fill() + : ctx.Text(" AppHost log streaming is not available in this version of the Aspire SDK.").Fill(); + + rightContent = ctx.VStack(v => [ + tabPanel, + v.DragBarPanel(logPanel).HandleEdge(DragBarEdge.Top).InitialSize(10).MinSize(3) + ]).Fill(); } // NotificationPanel requires ZStack context — only use in interactive mode @@ -184,26 +212,103 @@ private Hex1bWidget BuildMainScreen(RootContext ctx, bool interactive) ? ctx.NotificationPanel(rightContent).Fill() : rightContent; - // AppHost list for the left pane - var appHostList = ctx.VStack(nav => [ - nav.Text($" {MonitorCommandStrings.AppHostsDrawerTitle}").FixedHeight(1), - nav.Separator(), - ..BuildAppHostList(nav) + // AppHost panels for the left pane + var appHostPanels = ctx.VStack(nav => [ + ..BuildAppHostPanels(ctx, nav) + ]).Fill(); + + // Build the content header with AppHost info + var selectedName = GetSelectedAppHostTitle(); + var dashboardUrl = selectedAppHost?.DashboardUrl; + var aspireVersion = selectedAppHost?.AspireVersion; + var startedAt = selectedAppHost?.StartedAt; + var pid = selectedAppHost?.Pid; + var repoRoot = selectedAppHost?.RepositoryRoot; + + var resourceCount = _resources.Values + .Count(r => !string.Equals(r.ResourceType, "Parameter", StringComparison.OrdinalIgnoreCase)); + var runningCount = _resources.Values + .Count(r => !string.Equals(r.ResourceType, "Parameter", StringComparison.OrdinalIgnoreCase) + && string.Equals(r.State, "Running", StringComparison.OrdinalIgnoreCase)); + + var detailParts = new List(); + if (pid is not null) + { + detailParts.Add($"⚙ PID {pid}"); + } + if (aspireVersion is not null && aspireVersion != "unknown") + { + detailParts.Add($"◆ {aspireVersion}"); + } + if (startedAt is not null) + { + var uptime = DateTimeOffset.UtcNow - startedAt.Value; + detailParts.Add($"⏱ {FormatUptime(uptime)}"); + } + if (resourceCount > 0) + { + detailParts.Add($"▣ {runningCount}/{resourceCount}"); + } + var detailLine = detailParts.Count > 0 ? string.Join(" · ", detailParts) : null; + + var headerRows = new List, Hex1bWidget>>(); + + // Row 1: AppHost name + detail stats + headerRows.Add(h => h.HStack(row => [ + row.Text($"▲ {selectedName}"), + row.Text("").Fill(), + row.Text(detailLine ?? "").FixedWidth(detailLine?.Length ?? 0) + ]).FixedHeight(1)); + + // Row 2: Dashboard URL + Stop button + headerRows.Add(h => h.HStack(row => [ + row.Text("⊞ "), + dashboardUrl is not null + ? row.Hyperlink(dashboardUrl, dashboardUrl) + : (Hex1bWidget)row.Text("Dashboard: connecting..."), + row.Text("").Fill(), + row.Button(" ⏹ Stop ").OnClick(e => + { + _ = StopSelectedAppHostAsync(); + }) + ]).FixedHeight(1)); + + // Row 3: VSCode link (if repo root discovered) + if (repoRoot is not null) + { + var vscodeUrl = $"vscode://file/{Uri.EscapeDataString(repoRoot)}"; + headerRows.Add(h => h.HStack(row => [ + row.Text("⌨ "), + row.Hyperlink(vscodeUrl, repoRoot) + ]).FixedHeight(1)); + } + + var headerContent = ctx.VStack(h => + headerRows.Select(buildRow => buildRow(h)).ToArray() + ); + + var contentHeader = ctx.ThemePanel(AspireTheme.ApplyContentHeaderBorder, + ctx.Border(ctx.ThemePanel(AspireTheme.ApplyContentHeaderInner, headerContent))); + + // Main content area: header + tab content + var rightSide = ctx.VStack(r => [ + contentHeader, + mainContent ]).Fill(); - // Main layout: splitter with AppHost list on the left, content on the right - var body = ctx.HSplitter( - ctx.Border(appHostList, title: "App Hosts"), - ctx.Border(mainContent, title: GetSelectedAppHostTitle()).Fill(), + // Main layout: splitter with AppHost panels on the left, content on the right + var body = ctx.Padding(1, 1, 0, 0, ctx.HSplitter( + appHostPanels, + rightSide, leftWidth: 30 - ).Fill(); + ).Fill()).Fill(); return ctx.VStack(outer => [ body, outer.InfoBar(bar => [ - bar.Section("q: " + MonitorCommandStrings.QuitShortcut), + bar.Section("⎋ q: " + MonitorCommandStrings.QuitShortcut), bar.Separator(" │ "), - bar.Section("Tab: " + MonitorCommandStrings.TabShortcut), + bar.Section("⇥ Tab: " + MonitorCommandStrings.TabShortcut), bar.Separator(" │ "), bar.Section(GetStatusText()).FillWidth() ]) @@ -237,7 +342,7 @@ private static Hex1bWidget BuildWaitingForAppHostsPanel(RootContext ctx, bool in ]).Fill(); } - private IEnumerable BuildAppHostList(WidgetContext nav) + private IEnumerable BuildAppHostPanels(RootContext ctx, WidgetContext nav) { if (_appHosts.Count == 0) { @@ -246,22 +351,30 @@ private IEnumerable BuildAppHostList(WidgetContext na return _appHosts.Select((appHost, i) => { - var prefix = _selectedAppHostIndex == i ? " ▸ " : " "; - var suffix = appHost.IsOffline ? " ⚠" : ""; - return nav.Button($"{prefix}{appHost.DisplayName}{suffix}") - .OnClick(e => - { - _notificationStack ??= e.Context.Notifications; + var branchName = appHost.Branch ?? "unknown"; + var index = i; - if (i != _selectedAppHostIndex) - { - _selectedAppHostIndex = i; - if (!appHost.IsOffline) - { - _ = ConnectToAppHostAsync(i, CancellationToken.None); - } - } - }); + var interactable = nav.Interactable(ic => + { + var focused = ic.IsFocused || ic.IsHovered; + var innerContent = ctx.ThemePanel( + focused ? AspireTheme.ApplyAppHostTileInnerFocused : AspireTheme.ApplyAppHostTileInner, + ic.VStack(v => [ + v.Text($"▲ {appHost.DisplayName}").FixedHeight(1), + v.Text($" ⎇ {branchName}").FixedHeight(1) + ])); + + return (Hex1bWidget)ctx.ThemePanel( + focused + ? AspireTheme.ApplyAppHostTileFocused + : AspireTheme.ApplyAppHostTile, + ctx.Border(innerContent)); + }).OnClick(args => + { + _ = ConnectToAppHostAsync(index, CancellationToken.None); + }); + + return (Hex1bWidget)interactable; }); } @@ -293,10 +406,6 @@ private IEnumerable BuildResourcesTab(WidgetContext c .OnFocusChanged(key => { _focusedResourceKey = key; - if (key is string resourceName) - { - SwitchLogStream(resourceName); - } }) .Header(h => [ h.Cell("Name").Width(SizeHint.Fill), @@ -317,7 +426,7 @@ private IEnumerable BuildResourcesTab(WidgetContext c { _ = ExecuteResourceCommandAsync(resource.Name, "resource-start"); }), - h.Button("■").OnClick(e => + h.Button("⏹").OnClick(e => { _ = ExecuteResourceCommandAsync(resource.Name, "resource-stop"); }), @@ -330,17 +439,6 @@ private IEnumerable BuildResourcesTab(WidgetContext c .Fill() .Full(); - // If we have a terminal handle, show table+logs in a vertical split - if (_logTerminalHandle is not null) - { - return [ - ctx.VStack(v => [ - v.DragBarPanel(table).InitialSize(15).MinSize(6), - ctx.Terminal(_logTerminalHandle).Fill() - ]).Fill() - ]; - } - return [table]; } @@ -457,6 +555,10 @@ private async Task PollAppHostsAsync(CancellationToken cancellationToken) _appHosts.Add(entry); knownPaths.Add(path); + // Resolve branch and resource summary in the background + _ = ResolveBranchAsync(entry, cancellationToken); + _ = FetchResourceSummaryAsync(entry, cancellationToken); + // Auto-connect if this is the first AppHost if (wasEmpty) { @@ -527,6 +629,59 @@ private async Task PollAppHostsAsync(CancellationToken cancellationToken) } } + private async Task ResolveBranchesAsync(List entries, CancellationToken cancellationToken) + { + foreach (var entry in entries) + { + await ResolveBranchAsync(entry, cancellationToken).ConfigureAwait(false); + } + } + + private async Task ResolveBranchAsync(AppHostEntry entry, CancellationToken cancellationToken) + { + try + { + entry.Branch = await GitBranchHelper.GetCurrentBranchAsync(entry.FullPath, cancellationToken).ConfigureAwait(false); + _app?.Invalidate(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to resolve git branch for {Path}", entry.FullPath); + } + } + + private async Task FetchResourceSummariesAsync(List entries, CancellationToken cancellationToken) + { + foreach (var entry in entries) + { + await FetchResourceSummaryAsync(entry, cancellationToken).ConfigureAwait(false); + } + } + + private async Task FetchResourceSummaryAsync(AppHostEntry entry, CancellationToken cancellationToken) + { + if (entry.IsOffline) + { + return; + } + + try + { + var snapshots = await entry.Connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false); + var resources = snapshots.Where(r => !string.Equals(r.ResourceType, "Parameter", StringComparison.OrdinalIgnoreCase)).ToList(); + entry.Summary = new ResourceSummary + { + TotalCount = resources.Count, + RunningCount = resources.Count(r => string.Equals(r.State, "Running", StringComparison.OrdinalIgnoreCase)) + }; + _app?.Invalidate(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to fetch resource summary for {Path}", entry.FullPath); + } + } + private async Task ConnectToAppHostAsync(int index, CancellationToken cancellationToken) { if (_watchCts is not null) @@ -541,16 +696,7 @@ private async Task ConnectToAppHostAsync(int index, CancellationToken cancellati _resources.Clear(); _selectedAppHostIndex = index; - // Tear down previous log terminal - _logWorkload?.StopStreaming(); - if (_logTerminal is not null) - { - await _logTerminal.DisposeAsync().ConfigureAwait(false); - } - _logWorkload = null; - _logTerminalHandle = null; - _logTerminal = null; - + // Tear down previous state _app?.Invalidate(); try @@ -562,21 +708,106 @@ private async Task ConnectToAppHostAsync(int index, CancellationToken cancellati var connection = _appHosts[index].Connection; - // Create the embedded log terminal for this connection - _logWorkload = new AspireResourceConsoleLogWorkload(connection); - _logTerminal = Hex1bTerminal.CreateBuilder() - .WithWorkload(_logWorkload) - .WithTerminalWidget(out var handle) - .Build(); - _logTerminalHandle = handle; - _ = _logTerminal.RunAsync(cancellationToken); - var snapshots = await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false); _isConnecting = false; foreach (var snapshot in snapshots) { _resources[snapshot.Name] = snapshot; } + + // Fetch dashboard URL + try + { + var dashboardInfo = await connection.GetDashboardInfoV2Async(cancellationToken).ConfigureAwait(false); + if (dashboardInfo?.DashboardUrls is { Length: > 0 } urls) + { + _appHosts[index].DashboardUrl = urls[0]; + } + } + catch + { + // Dashboard info is best-effort + } + + // Fetch AppHost info (version, PID, start time) + try + { + var appHostInfo = connection.AppHostInfo; + if (appHostInfo is not null) + { + _appHosts[index].StartedAt = appHostInfo.StartedAt; + _appHosts[index].Pid = appHostInfo.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + if (connection is AppHostAuxiliaryBackchannel { SupportsV2: true } v2Connection) + { + var v2Info = await v2Connection.GetAppHostInfoV2Async(cancellationToken).ConfigureAwait(false); + if (v2Info is not null) + { + _appHosts[index].AspireVersion = v2Info.AspireHostVersion; + _appHosts[index].StartedAt = v2Info.StartedAt; + _appHosts[index].Pid = v2Info.Pid; + _appHosts[index].RepositoryRoot = v2Info.RepositoryRoot; + _logger.LogDebug("V2 info: Pid={Pid}, RepoRoot={RepoRoot}, AppHostPath={Path}", + v2Info.Pid, v2Info.RepositoryRoot ?? "(null)", v2Info.AppHostPath); + } + } + } + catch + { + // AppHost info is best-effort + } + + // Set up AppHost log streaming + _appHostLoggerFactory?.Dispose(); + _appHostLoggerFactory = LoggerFactory.Create(builder => + { + builder.AddHex1b(out var logStore); + _appHostLogStore = logStore; + }); + _appHostLogsAvailable = false; + + try + { + var logStream = await connection.GetAppHostLogEntriesAsync(cancellationToken).ConfigureAwait(false); + if (logStream is not null) + { + _appHostLogsAvailable = true; + var appHostLogger = _appHostLoggerFactory.CreateLogger("AppHost"); + + // Log a startup message to confirm the panel is connected + appHostLogger.LogInformation("Connected to AppHost log stream"); + + _ = Task.Run(async () => + { + try + { + await foreach (var entry in logStream.WithCancellation(_watchCts.Token).ConfigureAwait(false)) + { + appHostLogger.Log(entry.LogLevel, "{Message}", entry.Message); + _app?.Invalidate(); + } + } + catch (OperationCanceledException) + { + // Expected when switching AppHosts + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error streaming AppHost logs"); + } + }, _watchCts.Token); + } + else + { + _logger.LogDebug("AppHost log streaming returned null - not supported by this AppHost"); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "AppHost log streaming is not available"); + } + _app?.Invalidate(); _ = Task.Run(async () => @@ -630,29 +861,41 @@ private async Task ConnectToAppHostAsync(int index, CancellationToken cancellati } } - private void SwitchLogStream(string resourceName) + private async Task ExecuteResourceCommandAsync(string resourceName, string commandName) { - if (_logWorkload is null || _logWorkload.CurrentResourceName == resourceName) + try { - return; + if (_selectedAppHostIndex < _appHosts.Count) + { + var connection = _appHosts[_selectedAppHostIndex].Connection; + await connection.ExecuteResourceCommandAsync(resourceName, commandName, CancellationToken.None).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error executing {Command} on {Resource}", commandName, resourceName); } - - _logWorkload.StartStreaming(resourceName, _watchCts?.Token ?? CancellationToken.None); } - private async Task ExecuteResourceCommandAsync(string resourceName, string commandName) + private async Task StopSelectedAppHostAsync() { try { if (_selectedAppHostIndex < _appHosts.Count) { - var connection = _appHosts[_selectedAppHostIndex].Connection; - await connection.ExecuteResourceCommandAsync(resourceName, commandName, CancellationToken.None).ConfigureAwait(false); + var appHost = _appHosts[_selectedAppHostIndex]; + var stopped = await appHost.Connection.StopAppHostAsync(CancellationToken.None).ConfigureAwait(false); + if (stopped) + { + _notificationStack?.Post( + new Notification("AppHost Stopped", $"{appHost.DisplayName} has been stopped.") + .Timeout(TimeSpan.FromSeconds(5))); + } } } catch (Exception ex) { - _logger.LogDebug(ex, "Error executing {Command} on {Resource}", commandName, resourceName); + _logger.LogDebug(ex, "Error stopping AppHost"); } } @@ -681,6 +924,19 @@ private string GetStatusText() return $"{resourceCount} resource(s)"; } + private static string FormatUptime(TimeSpan uptime) + { + if (uptime.TotalDays >= 1) + { + return $"{(int)uptime.TotalDays}d {uptime.Hours}h"; + } + if (uptime.TotalHours >= 1) + { + return $"{(int)uptime.TotalHours}h {uptime.Minutes}m"; + } + return $"{(int)uptime.TotalMinutes}m"; + } + private static string FormatHealthStatus(string? healthStatus) { if (string.IsNullOrEmpty(healthStatus)) diff --git a/src/Aspire.Cli/UI/AspireTheme.cs b/src/Aspire.Cli/UI/AspireTheme.cs index 0520c9ad75d..7f8e7d8a39c 100644 --- a/src/Aspire.Cli/UI/AspireTheme.cs +++ b/src/Aspire.Cli/UI/AspireTheme.cs @@ -27,11 +27,6 @@ internal static class AspireTheme private static readonly Hex1bColor s_textPrimary = Hex1bColor.FromRgb(230, 237, 243); // #E6EDF3 private static readonly Hex1bColor s_textMuted = Hex1bColor.FromRgb(139, 148, 158); // #8B949E - // Highlights - private static readonly Hex1bColor s_focusedRow = Hex1bColor.FromRgb(45, 31, 94); // #2D1F5E - private static readonly Hex1bColor s_selectedRow = Hex1bColor.FromRgb(30, 20, 69); // #1E1445 - private static readonly Hex1bColor s_headerBg = Hex1bColor.FromRgb(28, 20, 50); // #1C1432 - /// /// Applies the Aspire color theme to the given base theme. /// @@ -43,24 +38,6 @@ public static Hex1bTheme Apply(Hex1bTheme theme) .Set(GlobalTheme.ForegroundColor, s_textPrimary) // Table - .Set(TableTheme.BorderColor, s_border) - .Set(TableTheme.FocusedBorderColor, s_purpleLight) - .Set(TableTheme.TableFocusedBorderColor, s_purpleLight) - .Set(TableTheme.HeaderBackground, s_headerBg) - .Set(TableTheme.HeaderForeground, s_purpleLight) - .Set(TableTheme.RowBackground, s_bgDark) - .Set(TableTheme.RowForeground, s_textPrimary) - .Set(TableTheme.AlternateRowBackground, s_bgSurface) - .Set(TableTheme.FocusedRowBackground, s_focusedRow) - .Set(TableTheme.FocusedRowForeground, s_lavender) - .Set(TableTheme.SelectedRowBackground, s_selectedRow) - .Set(TableTheme.SelectedRowForeground, s_purpleFaint) - .Set(TableTheme.SelectionColumnBorderColor, s_purpleMedium) - .Set(TableTheme.EmptyTextForeground, s_textMuted) - .Set(TableTheme.LoadingTextForeground, s_textMuted) - .Set(TableTheme.ScrollbarThumbColor, s_purpleMedium) - .Set(TableTheme.ScrollbarTrackColor, s_border) - // Border .Set(BorderTheme.BorderColor, s_border) .Set(BorderTheme.TitleColor, s_purpleLight) @@ -76,7 +53,7 @@ public static Hex1bTheme Apply(Hex1bTheme theme) .Set(TabBarTheme.SelectedForegroundColor, s_lavender) // InfoBar - .Set(InfoBarTheme.BackgroundColor, s_purpleLight) + .Set(InfoBarTheme.BackgroundColor, s_purpleMedium) .Set(InfoBarTheme.ForegroundColor, Hex1bColor.FromRgb(255, 255, 255)) // Button @@ -98,10 +75,85 @@ public static Hex1bTheme Apply(Hex1bTheme theme) // Separator .Set(SeparatorTheme.Color, s_border) + // Splitter + .Set(SplitterTheme.DividerColor, s_purple) + .Set(SplitterTheme.FocusedDividerColor, s_purple) + .Set(SplitterTheme.ThumbColor, s_purple) + // Notification card .Set(NotificationCardTheme.BackgroundColor, s_bgElevated) .Set(NotificationCardTheme.TitleColor, s_purpleLight) .Set(NotificationCardTheme.BodyColor, s_textPrimary) .Set(NotificationCardTheme.ProgressBarColor, s_purple); } + + /// + /// Applies the half-block border theme used for both tiles and the content header. + /// + private static Hex1bTheme ApplyHalfBlockBorder(Hex1bTheme theme, Hex1bColor fillColor) + { + return theme + .Set(BorderTheme.BorderColor, fillColor) + .Set(BorderTheme.TopLine, "▄") + .Set(BorderTheme.BottomLine, "▀") + .Set(BorderTheme.LeftLine, "█") + .Set(BorderTheme.RightLine, "█") + .Set(BorderTheme.TopLeftCorner, "▄") + .Set(BorderTheme.TopRightCorner, "▄") + .Set(BorderTheme.BottomLeftCorner, "▀") + .Set(BorderTheme.BottomRightCorner, "▀"); + } + + /// + /// Applies the theme for the content area header border. + /// + public static Hex1bTheme ApplyContentHeaderBorder(Hex1bTheme theme) + { + return ApplyHalfBlockBorder(theme, s_purple); + } + + /// + /// Applies the inner theme for the content area header. + /// + public static Hex1bTheme ApplyContentHeaderInner(Hex1bTheme theme) + { + return theme + .Set(GlobalTheme.BackgroundColor, s_purple) + .Set(GlobalTheme.ForegroundColor, Hex1bColor.FromRgb(255, 255, 255)) + .Set(HyperlinkTheme.ForegroundColor, s_lavender) + .Set(HyperlinkTheme.FocusedForegroundColor, s_lavender) + .Set(HyperlinkTheme.HoveredForegroundColor, Hex1bColor.FromRgb(255, 255, 255)); + } + + /// + /// Applies the AppHost tile theme for the left-pane tiles. + /// + public static Hex1bTheme ApplyAppHostTile(Hex1bTheme theme) + { + return ApplyHalfBlockBorder(theme, s_purple); + } + + /// + /// Applies the focused/hovered AppHost tile theme with a lighter background. + /// + public static Hex1bTheme ApplyAppHostTileFocused(Hex1bTheme theme) + { + return ApplyHalfBlockBorder(theme, s_purpleMedium); + } + + /// + /// Applies the inner fill color for tile content. + /// + public static Hex1bTheme ApplyAppHostTileInner(Hex1bTheme theme) + { + return theme.Set(GlobalTheme.BackgroundColor, s_purple); + } + + /// + /// Applies the inner fill color for focused tile content. + /// + public static Hex1bTheme ApplyAppHostTileInnerFocused(Hex1bTheme theme) + { + return theme.Set(GlobalTheme.BackgroundColor, s_purpleMedium); + } } diff --git a/src/Aspire.Cli/Utils/GitBranchHelper.cs b/src/Aspire.Cli/Utils/GitBranchHelper.cs new file mode 100644 index 00000000000..c93787a6cac --- /dev/null +++ b/src/Aspire.Cli/Utils/GitBranchHelper.cs @@ -0,0 +1,59 @@ +// 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; + +namespace Aspire.Cli.Utils; + +/// +/// Resolves the current git branch for a given file path. +/// +internal static class GitBranchHelper +{ + /// + /// Gets the current git branch for the directory containing the specified file path. + /// + /// A file path whose parent directory is used as the git working directory. + /// A cancellation token. + /// The branch name, or null if git is unavailable or the path is not in a git repository. + public static async Task GetCurrentBranchAsync(string filePath, CancellationToken cancellationToken = default) + { + var directory = Path.GetDirectoryName(filePath); + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) + { + return null; + } + + try + { + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = "git", + Arguments = "rev-parse --abbrev-ref HEAD", + WorkingDirectory = directory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + process.Start(); + + var output = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + if (process.ExitCode != 0) + { + return null; + } + + var branch = output.Trim(); + return string.IsNullOrEmpty(branch) ? null : branch; + } + catch + { + return null; + } + } +} diff --git a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs index 06a99cb3027..bff28001bf6 100644 --- a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs @@ -35,6 +35,16 @@ public async IAsyncEnumerable GetAppHostLogEntriesAsync([En using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _shutdownCts.Token); var linkedToken = linkedCts.Token; + // Replay buffered entries first so late-connecting clients see history + var loggerProvider = serviceProvider.GetService(); + if (loggerProvider is not null) + { + foreach (var entry in loggerProvider.GetReplaySnapshot()) + { + yield return entry; + } + } + Channel? channel = null; try diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 9596720b7e6..0047d2a66d6 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -58,6 +58,7 @@ public async Task GetAppHostInfoAsync(GetAppHostInfoRequ _ = request; var legacyInfo = await GetAppHostInformationAsync(cancellationToken).ConfigureAwait(false); + var repoRoot = DiscoverRepositoryRoot(legacyInfo.AppHostPath); return new GetAppHostInfoResponse { @@ -65,7 +66,8 @@ public async Task GetAppHostInfoAsync(GetAppHostInfoRequ AspireHostVersion = typeof(AuxiliaryBackchannelRpcTarget).Assembly.GetName().Version?.ToString() ?? "unknown", AppHostPath = legacyInfo.AppHostPath, CliProcessId = legacyInfo.CliProcessId, - StartedAt = legacyInfo.StartedAt + StartedAt = legacyInfo.StartedAt, + RepositoryRoot = repoRoot }; } @@ -988,6 +990,16 @@ private HttpClientTransport CreateHttpClientTransport(Uri endpointUri) #endregion + /// + /// Streams AppHost log entries from the hosting process. + /// Delegates to . + /// + public IAsyncEnumerable GetAppHostLogEntriesAsync(CancellationToken cancellationToken = default) + { + var rpcTarget = serviceProvider.GetRequiredService(); + return rpcTarget.GetAppHostLogEntriesAsync(cancellationToken); + } + /// /// Converts a JsonElement to its underlying CLR type for proper serialization. /// @@ -1022,4 +1034,44 @@ private static object ConvertJsonNumber(JsonElement element) // Fall back to double for floating point return element.GetDouble(); } + + /// + /// Walks up from the AppHost project path looking for a .git directory to find the repository root. + /// + private string? DiscoverRepositoryRoot(string appHostPath) + { + try + { + // Resolve to absolute path + var fullPath = Path.GetFullPath(appHostPath); + + var directory = Directory.Exists(fullPath) + ? new DirectoryInfo(fullPath) + : new FileInfo(fullPath).Directory; + + // If the path didn't yield a directory, fall back to current directory + directory ??= new DirectoryInfo(Environment.CurrentDirectory); + + while (directory is not null) + { + var gitPath = Path.Combine(directory.FullName, ".git"); + + // Standard repo: .git is a directory + // Worktree: .git is a file containing "gitdir: ..." + if (Directory.Exists(gitPath) || File.Exists(gitPath)) + { + logger.LogDebug("Found git root at {Dir} (worktree={IsWorktree})", directory.FullName, File.Exists(gitPath)); + return directory.FullName; + } + + directory = directory.Parent; + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to discover repository root from {Path}", appHostPath); + } + + return null; + } } diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index eb84f2a5778..ed9301f98e9 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -98,6 +98,11 @@ internal sealed class GetAppHostInfoResponse /// Gets when the AppHost process started. /// public DateTimeOffset? StartedAt { get; init; } + + /// + /// Gets the root directory of the repository containing the AppHost, if discovered. + /// + public string? RepositoryRoot { get; init; } } /// diff --git a/src/Aspire.Hosting/Backchannel/BackchannelLoggerProvider.cs b/src/Aspire.Hosting/Backchannel/BackchannelLoggerProvider.cs index c05564f80db..50e89e0adcf 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelLoggerProvider.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelLoggerProvider.cs @@ -10,6 +10,9 @@ namespace Aspire.Hosting.Backchannel; internal class BackchannelLoggerProvider : ILoggerProvider { private readonly Channel _channel = Channel.CreateUnbounded(); + private readonly List _replayBuffer = new(); + private readonly object _replayLock = new(); + private const int MaxReplayEntries = 1000; private readonly IServiceProvider _serviceProvider; private readonly object _channelRegisteredLock = new(); private readonly CancellationTokenSource _backgroundChannelRegistrationCts = new(); @@ -21,6 +24,29 @@ public BackchannelLoggerProvider(IServiceProvider serviceProvider) _serviceProvider = serviceProvider; } + /// + /// Gets a snapshot of buffered log entries for replay to late-connecting clients. + /// + internal List GetReplaySnapshot() + { + lock (_replayLock) + { + return new List(_replayBuffer); + } + } + + internal void AddToReplayBuffer(BackchannelLogEntry entry) + { + lock (_replayLock) + { + if (_replayBuffer.Count >= MaxReplayEntries) + { + _replayBuffer.RemoveAt(0); + } + _replayBuffer.Add(entry); + } + } + private void RegisterLogChannel() { // Why do we execute this on a background task? This method is spawned on a background @@ -49,7 +75,7 @@ public ILogger CreateLogger(string categoryName) } } - return new BackchannelLogger(categoryName, _channel); + return new BackchannelLogger(categoryName, _channel, this); } public void Dispose() @@ -59,7 +85,7 @@ public void Dispose() } } -internal class BackchannelLogger(string categoryName, Channel channel) : ILogger +internal class BackchannelLogger(string categoryName, Channel channel, BackchannelLoggerProvider provider) : ILogger { public IDisposable? BeginScope(TState state) where TState : notnull { @@ -84,6 +110,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except Message = formatter(state, exception), }; + provider.AddToReplayBuffer(entry); channel.Writer.TryWrite(entry); } } diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 6e1b56b6b90..b3d9bd63ef5 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -188,7 +188,8 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(TimeProvider.System); - _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddSingleton(sp => sp.GetRequiredService()); _innerBuilder.Logging.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Warning); _innerBuilder.Logging.AddFilter("Microsoft.AspNetCore.Server.Kestrel", LogLevel.Error); _innerBuilder.Logging.AddFilter("Aspire.Hosting.Dashboard", LogLevel.Error); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/MonitorCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AtopCommandTests.cs similarity index 92% rename from tests/Aspire.Cli.EndToEnd.Tests/MonitorCommandTests.cs rename to tests/Aspire.Cli.EndToEnd.Tests/AtopCommandTests.cs index 021db961b09..a5629890b48 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/MonitorCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/AtopCommandTests.cs @@ -10,20 +10,20 @@ namespace Aspire.Cli.EndToEnd.Tests; /// -/// End-to-end tests for the Aspire CLI monitor command (TUI). +/// End-to-end tests for the Aspire CLI atop command (TUI). /// Each test class runs as a separate CI job for parallelization. /// -public sealed class MonitorCommandTests(ITestOutputHelper output) +public sealed class AtopCommandTests(ITestOutputHelper output) { [Fact] - public async Task MonitorShowsHealthyResources() + public async Task AtopShowsHealthyResources() { var workspace = TemporaryWorkspace.Create(output); var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(MonitorShowsHealthyResources)); + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(AtopShowsHealthyResources)); var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() @@ -61,7 +61,7 @@ public async Task MonitorShowsHealthyResources() var waitForAppHostStoppedSuccessfully = new CellPatternSearcher() .Find("AppHost stopped successfully."); - // Pattern searcher for the monitor TUI showing a healthy resource + // Pattern searcher for the atop TUI showing a healthy resource var waitForHealthyInMonitor = new CellPatternSearcher() .Find("Healthy"); @@ -111,8 +111,8 @@ public async Task MonitorShowsHealthyResources() .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3)) .WaitForSuccessPrompt(counter); - // Launch aspire monitor TUI — this takes over the terminal - sequenceBuilder.Type("aspire monitor") + // Launch aspire atop TUI — this takes over the terminal + sequenceBuilder.Type("aspire atop") .Enter() .WaitUntil(s => waitForHealthyInMonitor.Search(s).Count > 0, TimeSpan.FromMinutes(3)) // Healthy text found — test passed. Send Ctrl+C to exit the TUI.