diff --git a/Directory.Packages.props b/Directory.Packages.props
index 8e4674e5743..c43ea3eed3a 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -98,9 +98,9 @@
-
-
-
+
+
+
diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj
index 654c480a3a3..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
@@ -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/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/AtopCommand.cs b/src/Aspire.Cli/Commands/AtopCommand.cs
new file mode 100644
index 00000000000..a2aae57bb8d
--- /dev/null
+++ b/src/Aspire.Cli/Commands/AtopCommand.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 AtopCommand : BaseCommand
+{
+ private readonly IAuxiliaryBackchannelMonitor _backchannelMonitor;
+ private readonly ILogger _logger;
+
+ public AtopCommand(
+ IInteractionService interactionService,
+ IAuxiliaryBackchannelMonitor backchannelMonitor,
+ IFeatures features,
+ ICliUpdateNotifier updateNotifier,
+ CliExecutionContext executionContext,
+ AspireCliTelemetry telemetry,
+ ILogger logger)
+ : base("atop", 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 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 db956046a9a..70fce3a2027 100644
--- a/src/Aspire.Cli/Commands/RootCommand.cs
+++ b/src/Aspire.Cli/Commands/RootCommand.cs
@@ -130,6 +130,8 @@ public RootCommand(
DocsCommand docsCommand,
SdkCommand sdkCommand,
SetupCommand setupCommand,
+ MonitorCommand monitorCommand,
+ AtopCommand atopCommand,
ExtensionInternalCommand extensionInternalCommand,
IFeatures featureFlags,
IInteractionService interactionService)
@@ -220,5 +222,10 @@ public RootCommand(
Subcommands.Add(sdkCommand);
}
+ if (featureFlags.IsFeatureEnabled(KnownFeatures.MonitorCommandEnabled, false))
+ {
+ Subcommands.Add(atopCommand);
+ }
+
}
}
diff --git a/src/Aspire.Cli/KnownFeatures.cs b/src/Aspire.Cli/KnownFeatures.cs
index 97e0fb1d558..217653bcfe9 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 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 e7e1e91cde5..bbc8b8616fb 100644
--- a/src/Aspire.Cli/Program.cs
+++ b/src/Aspire.Cli/Program.cs
@@ -374,6 +374,8 @@ 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();
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/AspireAtopSplash.cs b/src/Aspire.Cli/UI/AspireAtopSplash.cs
new file mode 100644
index 00000000000..863e5a31bae
--- /dev/null
+++ b/src/Aspire.Cli/UI/AspireAtopSplash.cs
@@ -0,0 +1,600 @@
+// 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.Theming;
+using Hex1b.Widgets;
+
+namespace Aspire.Cli.UI;
+
+///
+/// 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 AspireAtopSplash
+{
+ // Phase timing (ms from start)
+ private const int WhirlwindDurationMs = 2200;
+ private const int HoldEndMs = 3000;
+ private const int DissolveDurationMs = 900;
+ private const int ExitDurationMs = 3000;
+ 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;
+
+ // 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 BrailleDot(
+ int FinalBx, int FinalBy, byte R, byte G, byte B,
+ float StartRadius, float StartAngle, float TotalRotations);
+
+ 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 BrailleDot[] _dots;
+ 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 AspireAtopSplash()
+ {
+ _startTicks = Environment.TickCount64;
+
+ var pixelCount = s_pixelData.Length / 5;
+ var dotList = new List(pixelCount * 4);
+ var rng = new Random(99);
+
+ for (var i = 0; i < pixelCount; i++)
+ {
+ var offset = i * 5;
+ 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 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 + 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,
+ r, g, b,
+ startRadius, startAngle, rotations));
+ }
+ }
+ }
+
+ _dots = dotList.ToArray();
+ }
+
+ private long ElapsedMs => Environment.TickCount64 - _startTicks;
+
+ public bool IsComplete => ElapsedMs >= TotalDurationMs;
+
+ public Hex1bWidget Build(RootContext ctx)
+ {
+ var elapsed = ElapsedMs;
+
+ return ctx.ThemePanel(AspireTheme.Apply,
+ ctx.Surface(layerCtx => [
+ layerCtx.Layer(surface =>
+ {
+ 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 < WhirlwindDurationMs)
+ {
+ // Braille particles spiral into position
+ var progress = elapsed / (float)WhirlwindDurationMs;
+ RenderBrailleLogo(surface, progress, 1f, offsetBx, offsetBy);
+ }
+ else if (elapsed < HoldEndMs)
+ {
+ // Braille logo settled at final positions
+ RenderBrailleLogo(surface, 1f, 1f, offsetBx, offsetBy);
+ }
+ else if (elapsed < TotalDurationMs)
+ {
+ // Dissolve and melt (braille-only)
+ var exitElapsed = elapsed - HoldEndMs;
+ var offsetBxExit = offsetX * 2;
+ var offsetByExit = offsetY * 4;
+ RenderExit(surface, exitElapsed, offsetBxExit, offsetByExit);
+ }
+ })
+ ]).Fill()
+ ).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));
+ }
+ }
+
+ // ── Dissolve / melt exit ──────────────────────────────────────────────
+
+ private void RenderExit(Hex1b.Surfaces.Surface surface, long exitElapsedMs, int offsetBx, int offsetBy)
+ {
+ EnsureParticlesCreated(surface.Height, offsetBx, offsetBy);
+ 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 static braille
+ if (dissolvedUpToRow < LogoCellHeight)
+ {
+ RenderBrailleRows(surface, dissolvedUpToRow, LogoCellHeight, fadeFactor, offsetBx, offsetBy);
+ }
+
+ // Render falling particles as braille
+ RenderFallingParticles(surface, exitElapsedMs, fadeFactor);
+ }
+
+ private void RenderBrailleRows(Hex1b.Surfaces.Surface surface, int fromRow, int toRow, float brightness, int offsetBx, int offsetBy)
+ {
+ var cells = new Dictionary<(int cx, int cy), (int pattern, int totalR, int totalG, int totalB, int count)>();
+
+ foreach (var dot in _dots)
+ {
+ var cellRow = dot.FinalBy / 4;
+ if (cellRow < fromRow || cellRow >= toRow)
+ {
+ continue;
+ }
+
+ var bx = dot.FinalBx + offsetBx;
+ var by = dot.FinalBy + offsetBy;
+
+ 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));
+ }
+ }
+
+ private void EnsureParticlesCreated(int surfaceHeight, int offsetBx, int offsetBy)
+ {
+ if (_particles is not null)
+ {
+ return;
+ }
+
+ _maxBrailleY = surfaceHeight * 4 - 1;
+ _lastPhysicsTick = Environment.TickCount64;
+ _particles = new List(_dots.Length);
+ var rng = new Random(123);
+
+ foreach (var dot in _dots)
+ {
+ var cellRow = (dot.FinalBy / 4);
+ var spawnTimeMs = (cellRow / (float)LogoCellHeight) * DissolveDurationMs;
+
+ _particles.Add(new Particle
+ {
+ 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
+ });
+ }
+ }
+
+ 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 RenderFallingParticles(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));
+ }
+ }
+
+ // ── Shared helpers ────────────────────────────────────────────────────
+
+ 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 =
+ [
+ 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
+ ];
+}
diff --git a/src/Aspire.Cli/UI/AspireAtopTui.cs b/src/Aspire.Cli/UI/AspireAtopTui.cs
new file mode 100644
index 00000000000..0fd06f871ab
--- /dev/null
+++ b/src/Aspire.Cli/UI/AspireAtopTui.cs
@@ -0,0 +1,988 @@
+// 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 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.
+///
+internal sealed class AppHostEntry
+{
+ public required string DisplayName { get; init; }
+ 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 AspireAtopTui
+{
+ 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 readonly AspireAtopSplash _splash = new();
+ private object? _focusedResourceKey;
+ private object? _focusedParameterKey;
+ private CancellationTokenSource? _watchCts;
+ private NotificationStack? _notificationStack;
+ private Hex1bApp? _app;
+ private IHex1bLogStore? _appHostLogStore;
+ private ILoggerFactory? _appHostLoggerFactory;
+ private bool _appHostLogsAvailable;
+
+ // Hack reveal transition after splash
+ private bool _revealing;
+ private long _revealStart;
+ private readonly HackRevealEffect _hackReveal = new();
+ private const double RevealDurationSeconds = 4.0;
+
+ public AspireAtopTui(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();
+
+ // 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-atop", forceEnable: true)
+ .WithHex1bApp((app, options) =>
+ {
+ _app = app;
+ options.EnableDefaultCtrlCExit = true;
+ return BuildWidget;
+ })
+ .WithMouse()
+ .Build();
+
+ // Animate the splash, then transition to main screen
+ _ = Task.Run(async () =>
+ {
+ // Drive the animation by invalidating at ~30fps until all phases complete
+ while (!_splash.IsComplete)
+ {
+ _app?.Invalidate();
+ await Task.Delay(33, cancellationToken).ConfigureAwait(false);
+ }
+
+ _showSplash = false;
+ _revealing = true;
+ _revealStart = Stopwatch.GetTimestamp();
+ _hackReveal.Reset();
+ _app?.Invalidate();
+
+ if (_appHosts.Count > 0)
+ {
+ await ConnectToAppHostAsync(0, cancellationToken).ConfigureAwait(false);
+ }
+
+ // Start background polling for new/offline AppHosts
+ _ = PollAppHostsAsync(cancellationToken);
+ }, cancellationToken);
+
+ await terminal.RunAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ private Hex1bWidget BuildWidget(RootContext ctx)
+ {
+ if (_showSplash)
+ {
+ return _splash.Build(ctx);
+ }
+
+ if (_revealing)
+ {
+ var progress = Math.Clamp(
+ Stopwatch.GetElapsedTime(_revealStart).TotalSeconds / RevealDurationSeconds, 0, 1);
+
+ if (progress >= 1.0)
+ {
+ _revealing = false;
+ 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();
+
+ return ctx.Surface(s =>
+ {
+ _hackReveal.Update(s.Width, s.Height);
+ return
+ [
+ s.WidgetLayer(revealContent),
+ s.Layer(_hackReveal.GetCompute(progress))
+ ];
+ })
+ .RedrawAfter(16)
+ .Fill();
+ }
+
+ return ctx.ThemePanel(AspireTheme.Apply, BuildMainScreen(ctx, interactive: true)).Fill();
+ }
+
+ private Hex1bWidget BuildMainScreen(RootContext ctx, bool interactive)
+ {
+ // When no AppHosts are connected, show a waiting panel
+ if (_appHosts.Count == 0)
+ {
+ return BuildWaitingForAppHostsPanel(ctx, interactive);
+ }
+
+ // Build right-side content depending on selected AppHost state
+ Hex1bWidget rightContent;
+ var selectedAppHost = _selectedAppHostIndex < _appHosts.Count ? _appHosts[_selectedAppHostIndex] : null;
+
+ 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();
+
+ // 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
+ var mainContent = interactive
+ ? ctx.NotificationPanel(rightContent).Fill()
+ : rightContent;
+
+ // 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 panels on the left, content on the right
+ var body = ctx.Padding(1, 1, 0, 0, ctx.HSplitter(
+ appHostPanels,
+ rightSide,
+ leftWidth: 30
+ ).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 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 => [
+ interactive ? ctx.NotificationPanel(centerContent).Fill() : centerContent,
+ outer.InfoBar(bar => [
+ bar.Section("q: " + MonitorCommandStrings.QuitShortcut),
+ bar.Separator(" │ "),
+ bar.Section("Polling for AppHosts...").FillWidth()
+ ])
+ ]).Fill();
+ }
+
+ private IEnumerable BuildAppHostPanels(RootContext ctx, WidgetContext nav)
+ {
+ if (_appHosts.Count == 0)
+ {
+ return [nav.Text($" {MonitorCommandStrings.NoRunningAppHostsFound}")];
+ }
+
+ return _appHosts.Select((appHost, i) =>
+ {
+ var branchName = appHost.Branch ?? "unknown";
+ var index = i;
+
+ 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;
+ });
+ }
+
+ 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 table = 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(14),
+ 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(FormatHealthStatus(resource.HealthStatus)),
+ r.Cell(cell => BuildUrlsCell(cell, resource)),
+ 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();
+
+ return [table];
+ }
+
+ 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}")];
+ }
+
+ 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)
+ ])
+ .Row((r, param, state) => [
+ r.Cell(param.DisplayName ?? param.Name),
+ r.Cell(param.State ?? "Unknown")
+ ])
+ .Fill()
+ .Full()
+ ];
+ }
+
+ 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 wasEmpty = _appHosts.Count == 0;
+ var entry = new AppHostEntry
+ {
+ DisplayName = ShortenPath(path),
+ FullPath = path,
+ Connection = connection
+ };
+ _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)
+ {
+ _ = ConnectToAppHostAsync(0, cancellationToken);
+ }
+ else
+ {
+ _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;
+ // 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();
+ }
+ }
+ }
+
+ // 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 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)
+ {
+ await _watchCts.CancelAsync().ConfigureAwait(false);
+ _watchCts.Dispose();
+ }
+ _watchCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+
+ _isConnecting = true;
+ _errorMessage = null;
+ _resources.Clear();
+ _selectedAppHostIndex = index;
+
+ // Tear down previous state
+ _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;
+ }
+
+ // 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 () =>
+ {
+ try
+ {
+ await foreach (var snapshot in connection.WatchResourceSnapshotsAsync(_watchCts.Token).ConfigureAwait(false))
+ {
+ _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)
+ {
+ // Expected when switching AppHosts or shutting down
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Error watching resource snapshots");
+
+ // Connection lost — mark as offline
+ if (index < _appHosts.Count)
+ {
+ _appHosts[index].IsOffline = true;
+ _resources.Clear();
+ }
+
+ _app?.Invalidate();
+ }
+ }, _watchCts.Token);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Error connecting to AppHost");
+ _isConnecting = false;
+
+ if (index < _appHosts.Count)
+ {
+ _appHosts[index].IsOffline = true;
+ }
+
+ _app?.Invalidate();
+ }
+ }
+
+ 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 async Task StopSelectedAppHostAsync()
+ {
+ try
+ {
+ if (_selectedAppHostIndex < _appHosts.Count)
+ {
+ 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 stopping AppHost");
+ }
+ }
+
+ 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 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))
+ {
+ return "";
+ }
+
+ var icon = string.Equals(healthStatus, "Healthy", StringComparison.OrdinalIgnoreCase)
+ ? "💚"
+ : "💔";
+
+ 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);
+
+ 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;
+ }
+}
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;
+ }
+}
diff --git a/src/Aspire.Cli/UI/AspireTheme.cs b/src/Aspire.Cli/UI/AspireTheme.cs
new file mode 100644
index 00000000000..7f8e7d8a39c
--- /dev/null
+++ b/src/Aspire.Cli/UI/AspireTheme.cs
@@ -0,0 +1,159 @@
+// 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
+
+ ///
+ /// 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
+ // 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_purpleMedium)
+ .Set(InfoBarTheme.ForegroundColor, Hex1bColor.FromRgb(255, 255, 255))
+
+ // 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)
+
+ // 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/UI/HackRevealEffect.cs b/src/Aspire.Cli/UI/HackRevealEffect.cs
new file mode 100644
index 00000000000..b0939083e17
--- /dev/null
+++ b/src/Aspire.Cli/UI/HackRevealEffect.cs
@@ -0,0 +1,254 @@
+// 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 _initialScanDone;
+ private readonly Random _rng = new();
+
+ private const string ScrambleChars =
+ "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef@#$%&!?<>{}[]~";
+
+ public void Reset()
+ {
+ _cells = null;
+ _initialScanDone = 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];
+ _initialScanDone = false;
+ }
+
+ public CellCompute GetCompute(double progress)
+ {
+ return ctx =>
+ {
+ var below = ctx.GetBelow();
+
+ if (_cells is null)
+ {
+ return new SurfaceCell(" ", null, null);
+ }
+
+ ref var cell = ref _cells[ctx.X, ctx.Y];
+
+ // Phase 1: Initial full-screen capture — show all black
+ if (!_initialScanDone)
+ {
+ CaptureCell(ref cell, below, ctx.Y);
+
+ // Mark scan complete after the last cell
+ if (ctx.X == _width - 1 && ctx.Y == _height - 1)
+ {
+ _initialScanDone = true;
+ }
+
+ return new SurfaceCell(" ", null, Hex1bColor.FromRgb(0, 0, 0));
+ }
+
+ // Phase 2: Reveal from cache, but promote empty cells if new content appears
+ if (!cell.HasContent)
+ {
+ bool hasVisibleChar = !below.IsContinuation
+ && below.Character != "\uE000"
+ && !string.IsNullOrEmpty(below.Character)
+ && below.Character != " ";
+
+ if (hasVisibleChar)
+ {
+ // 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;
+ }
+
+ 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 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)
+ {
+ 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));
+ }
+}
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/AtopCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AtopCommandTests.cs
new file mode 100644
index 00000000000..a5629890b48
--- /dev/null
+++ b/tests/Aspire.Cli.EndToEnd.Tests/AtopCommandTests.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 atop command (TUI).
+/// Each test class runs as a separate CI job for parallelization.
+///
+public sealed class AtopCommandTests(ITestOutputHelper output)
+{
+ [Fact]
+ 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(AtopShowsHealthyResources));
+
+ 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 atop 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 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.
+ .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;
+ }
+}